diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index cc117e21d5..f0e7ee85fa 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -68,4 +68,4 @@ body: required: true - label: I searched open and closed issues for the same problem. required: true - - label: If an agent wrote this, the body ends with `> AGENT GENERATED: by ` and links the thread or report. + - label: If an agent wrote this, the body ends with `> AGENT GENERATED` and links the thread or report. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 81f2876cc7..7b87e6a5c6 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -39,4 +39,4 @@ body: options: - label: I searched open and closed issues for the same request. required: true - - label: If an agent wrote this, the body ends with `> AGENT GENERATED: by `. + - label: If an agent wrote this, the body ends with `> AGENT GENERATED`. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 84459091cf..e852298c4c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,4 +15,4 @@ Fixes # - + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0dfa6383d..f6e75154c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,14 @@ jobs: - name: Check app bundle budgets run: node apps/app/scripts/check-bundle-budget.mjs + # Provider-literal ratchet (G1 of the provider-plugin migration): + # core must never gain a new provider-id carve-out; the per-file + # baseline may only shrink. See docs/provider-plugin-api.md. + - name: Check provider-literal ratchet + run: | + git fetch --depth=1 origin "${{ github.base_ref || github.event.repository.default_branch }}" || true + node scripts/check-provider-literal-ratchet.mjs --base "origin/${{ github.base_ref || github.event.repository.default_branch }}" + # Exceeding the Actions cache quota is silent: GitHub evicts entries # without failing or warning anything, so the only symptom is caches # quietly ceasing to hit. Warn while there is still headroom. diff --git a/.github/workflows/deploy-demo-server.yml b/.github/workflows/deploy-demo-server.yml new file mode 100644 index 0000000000..7a73ce6c75 --- /dev/null +++ b/.github/workflows/deploy-demo-server.yml @@ -0,0 +1,84 @@ +name: Deploy Demo Server + +# The bb demo server is the mock bb server an App Store reviewer connects to +# (apps/demo-server). It has no database, no secrets, and no route of its +# own, so this is the short form of deploy-connect.yml: install, typecheck +# and test, deploy. It serves from workers.dev; see the wrangler config for +# why it is not on demo.getbb.app. +# +# Paths: the worker bundles @bb/server-contract from source, and the +# contract is what its tests check the fixtures against, so a contract change +# redeploys the demo too. That is the point: a demo that has drifted from the +# contract is a demo that crashes in front of a reviewer. + +on: + push: + branches: + - main + paths: + - "apps/demo-server/**" + - "packages/server-contract/**" + - ".github/workflows/deploy-demo-server.yml" + workflow_dispatch: + +env: + NODE_VERSION: "22.x" + PNPM_VERSION: "9.15.0" + +permissions: + contents: read + +# One deploy at a time, and a queued one waits rather than being dropped, so +# the worker never ends up on a build older than main. +concurrency: + group: demo-server-deploy-${{ github.ref }} + cancel-in-progress: false + +jobs: + deploy: + name: Deploy demo server to Cloudflare Workers + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + # The tests parse every fixture with the contract's own schemas. Run + # them here too, not only in ci.yml: this job also fires on a contract + # change, and that is exactly when a fixture can go stale. + - name: Typecheck and test + run: pnpm exec turbo run typecheck test --filter=@bb/demo-server + + # No build step: wrangler's esbuild bundles the worker and its workspace + # dependency straight from source. The account is pinned in + # wrangler.jsonc, so the token never has to pick one. + - name: Deploy demo server + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: pnpm --filter @bb/demo-server exec wrangler deploy | tee deploy.log + + # The workers.dev URL is what goes in the App Store review notes. Surface + # it on the run so nobody has to open the Cloudflare dashboard for it. + - name: Report the URL + run: | + { + echo "## Demo server" + echo + grep -Eo 'https://[^ ]+\.workers\.dev' deploy.log | sort -u | sed 's/^/- /' || echo "- URL not found in the wrangler output; check the deploy step log." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml new file mode 100644 index 0000000000..a9516a6021 --- /dev/null +++ b/.github/workflows/mobile-e2e.yml @@ -0,0 +1,320 @@ +name: Mobile E2E + +# iOS simulator end-to-end run for apps/mobile (Maestro flows against the +# integration harness backend). Label-gated on pull requests (`mobile-e2e`), +# manual, and nightly — the macOS runner is slow and paid, and the Linux +# `checks` / `tests` jobs in ci.yml already typecheck, lint and unit-test +# @bb/mobile on every pull request (turbo picks the package up; the +# `packages` test shard covers it). +# +# The app is built once in Release: a Release build embeds the JS bundle and +# never starts the dev launcher, so the flows run without Metro +# (`BB_E2E_EMBEDDED_BUNDLE=1` → apps/mobile/e2e/subflows/launch-app.yaml +# uses `launchApp` instead of the dev-client deep link). The bundle is built +# with `EXPO_PUBLIC_BB_E2E=1` so the app wipes its state on every launch and +# exposes the e2e-only affordances, exactly like the local Metro setup. +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + inputs: + flows: + description: Space-separated flow names (default = the CI set; see apps/mobile/e2e/scripts/ci-run-flows.sh) + required: false + default: "" + schedule: + # Nightly, 09:17 UTC (runs on the default branch). + - cron: "17 9 * * *" + +permissions: + contents: read + +concurrency: + group: mobile-e2e-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + PNPM_VERSION: 9.15.0 + NODE_VERSION: 22.x + MAESTRO_VERSION: 2.8.0 + # sha256 of the cli-${MAESTRO_VERSION} maestro.zip release asset. Maestro + # publishes no checksums, so recompute it when bumping the version: + # curl -fsSL -o maestro.zip https://github.com/mobile-dev-inc/maestro/releases/download/cli-/maestro.zip && shasum -a 256 maestro.zip + MAESTRO_SHA256: b3e561161904fb391875ca5834d5b22cf0b01c052dd1b408ad83e30d8f8951b3 + # The harness backend port the flows' env blocks point at. + BB_MOBILE_E2E_PORT: "41999" + # Xcode DerivedData for the Release build measured 6.9 GB raw on a Mac + # (Build/ 5.1 GB, of which Pods intermediates 2.4 GB) and the repository's + # Actions cache quota is 10 GB shared with the Turbo caches, so the + # DerivedData cache is a knob: flip it to "false" if it starts evicting the + # Linux caches, and the job does a clean Release build each run. + CACHE_DERIVED_DATA: "true" + +jobs: + ios: + name: iOS simulator flows + if: >- + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'mobile-e2e') + runs-on: blacksmith-6vcpu-macos-15 + timeout-minutes: 90 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # No Turbo cache on macOS: the cache moves over GitHub's service at + # ~50 MB/s on these runners and costs more than the tasks it skips + # (see ci.yml). + - name: Setup workspace + uses: ./.github/actions/setup-workspace + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + cache-prefix: mobile-e2e + turbo-cache: "false" + + # The macOS 15 image ships several Xcodes with 16.4 as the default; + # apps/mobile is verified on Xcode 26.2 (iOS 26 runtime). Prefer that, + # then the newest 26.x, and only then whatever is selected. DEVELOPER_DIR + # needs no sudo and is honored by xcodebuild, xcrun/simctl and CocoaPods. + - name: Select Xcode + run: | + set -x + if [ -d /Applications/Xcode_26.2.app ]; then + xcode=/Applications/Xcode_26.2.app + else + xcode=$(find /Applications -maxdepth 1 -name 'Xcode_26*.app' | sort -V | tail -1) + fi + if [ -n "${xcode:-}" ]; then + echo "DEVELOPER_DIR=$xcode/Contents/Developer" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$xcode/Contents/Developer" + else + echo "::warning::No Xcode 26.x found; using the selected Xcode" + fi + sw_vers + xcodebuild -version + xcrun simctl list runtimes + if ! command -v pod >/dev/null 2>&1; then + echo "::warning::CocoaPods not found on the image; installing with Homebrew" + brew install cocoapods + fi + pod --version + + - name: Pick and boot a simulator + id: simulator + run: | + set -x + udid=$(node apps/mobile/e2e/scripts/pick-simulator.mjs) + echo "udid=$udid" >> "$GITHUB_OUTPUT" + echo "SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" + xcrun simctl boot "$udid" || true + xcrun simctl bootstatus "$udid" -b + xcrun simctl list devices booted + + # Java: Maestro needs a JDK 17+. The image has one on PATH; install + # Temurin 17 only when it does not. + - name: Check Java + id: java + run: | + if java -version 2>&1 | head -1 | grep -Eq '"(1[7-9]|[2-9][0-9])'; then + java -version 2>&1 + echo "install=false" >> "$GITHUB_OUTPUT" + else + echo "No JDK 17+ on PATH; installing Temurin 17" + echo "install=true" >> "$GITHUB_OUTPUT" + fi + - name: Install Java 17 + if: steps.java.outputs.install == 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: "17" + + # Pinned direct download of the release asset (what the + # get.maestro.mobile.dev installer fetches) instead of piping that + # unversioned script to bash, so GitHub releases stay the only trust + # root. The zip unpacks to maestro/{bin,lib}; it lands in ~/.maestro + # where the installer would put it. + - name: Install Maestro + run: | + set -x + curl -fsSL --retry 3 -o /tmp/maestro.zip \ + "https://github.com/mobile-dev-inc/maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip" + echo "${MAESTRO_SHA256} /tmp/maestro.zip" | shasum -a 256 -c - + rm -rf "$HOME/.maestro" /tmp/maestro-unzip + unzip -qo /tmp/maestro.zip -d /tmp/maestro-unzip + mv /tmp/maestro-unzip/maestro "$HOME/.maestro" + rm -rf /tmp/maestro.zip /tmp/maestro-unzip + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + "$HOME/.maestro/bin/maestro" --version + + # Native deps are a function of the lockfile, the Expo config and the + # pnpm patches; JS-only changes reuse both caches. `ios/` itself is + # generated by `expo prebuild` (gitignored), so its Podfile.lock cannot + # be part of the key — it is restored alongside Pods/ (Podfile.lock == + # Pods/Manifest.lock makes `pod install` a no-op when nothing native + # changed). DerivedData keeps only `Build/` (intermediates + products; + # the module cache is rebuilt) and is keyed without the commit so one + # entry per native dependency set is saved; incremental xcodebuild then + # rebuilds only the app target on top of it. + - name: Compute native cache key + id: native-key + run: | + echo "key=${{ runner.os }}-mobile-native-${{ hashFiles('pnpm-lock.yaml', 'apps/mobile/app.json', 'apps/mobile/package.json', 'patches/**') }}" >> "$GITHUB_OUTPUT" + - name: Restore CocoaPods + id: pods-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + apps/mobile/ios/Pods + apps/mobile/ios/Podfile.lock + key: ${{ steps.native-key.outputs.key }}-pods + - name: Restore DerivedData + if: env.CACHE_DERIVED_DATA == 'true' + id: deriveddata-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/Library/Developer/Xcode/DerivedData/bb-*/Build + key: ${{ steps.native-key.outputs.key }}-deriveddata + + - name: Prebuild and install pods + working-directory: apps/mobile + env: + LANG: en_US.UTF-8 + CI: "1" + run: | + set -x + npx expo prebuild --platform ios --no-install + (cd ios && pod install) + + # Release: the Xcode "Bundle React Native code and images" phase runs + # `expo export:embed` with the env below (EXPO_PUBLIC_* values are + # inlined at bundle time). + # + # `--device generic` builds for the generic iOS Simulator destination + # and copies the product to `--output`; `--device ` must not be + # used here. The app declares `associatedDomains` (universal links), and + # `expo run:ios` insists on a development signing identity whenever the + # entitlements contain `com.apple.developer.associated-domains` — even + # for a simulator build — which a runner without a certificate cannot + # provide. Simulator products need no signature, so the binary is + # installed with simctl instead. DerivedData is still the default + # location, so the cache below keeps working. + - name: Build the Release app + id: build + working-directory: apps/mobile + env: + LANG: en_US.UTF-8 + CI: "1" + NODE_OPTIONS: --max-old-space-size=8192 + EXPO_PUBLIC_BB_E2E: "1" + EXPO_PUBLIC_BB_SERVER_URL: http://127.0.0.1:${{ env.BB_MOBILE_E2E_PORT }} + run: | + set -x + rm -rf build-output + time npx expo run:ios --configuration Release --no-bundler \ + --device generic --output build-output + + # The flows cold-start the app themselves; installing it here also + # primes the simulator so the first flow does not pay for it. + - name: Install the app on the simulator + working-directory: apps/mobile + run: | + set -x + app=$(find build-output -maxdepth 1 -name '*.app' | head -1) + if [ -z "$app" ]; then + echo "No .app product in apps/mobile/build-output"; ls -la build-output; exit 1 + fi + xcrun simctl install "$SIMULATOR_UDID" "$app" + xcrun simctl listapps "$SIMULATOR_UDID" | grep -A3 app.getbb.mobile || true + + - name: Save CocoaPods + if: always() && steps.build.outcome == 'success' && steps.pods-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + apps/mobile/ios/Pods + apps/mobile/ios/Podfile.lock + key: ${{ steps.native-key.outputs.key }}-pods + - name: Save DerivedData + if: always() && env.CACHE_DERIVED_DATA == 'true' && steps.build.outcome == 'success' && steps.deriveddata-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/Library/Developer/Xcode/DerivedData/bb-*/Build + key: ${{ steps.native-key.outputs.key }}-deriveddata + + # The harness backend (in-process server + host daemon, fake provider, + # seeded project + threads) on the port the flows expect. Turbo builds + # its prerequisites (plugin SDK, native modules) first; the script + # prints one JSON line when it is ready and keeps running. + - name: Start the e2e backend + run: | + set -x + mkdir -p e2e-artifacts + # `--log-order=stream` because turbo groups task output in CI and + # only flushes it when the task ends — a persistent task would keep + # its readiness line buffered forever. + pnpm exec turbo run e2e:mobile-backend --filter=@bb/integration-tests --output-logs=full --log-order=stream > e2e-artifacts/backend.log 2>&1 & + echo $! > e2e-artifacts/backend.pid + # The server listens (and answers /health) before it finishes seeding + # the project and threads the flows look for; the details JSON line is + # printed once seeding is done, so that is the readiness signal. + # + # `$!` is the `pnpm exec` wrapper, which exits on macOS while turbo + # and the backend keep running, so its death alone does not mean the + # backend died — only give up when the backend process is gone too. + ready=0 + for _ in $(seq 1 240); do + if grep -q '"serverUrl"' e2e-artifacts/backend.log 2>/dev/null; then + ready=1 + break + fi + if ! kill -0 "$(cat e2e-artifacts/backend.pid)" 2>/dev/null && + ! pgrep -f "mobile-e2e/backend.ts" >/dev/null 2>&1; then + echo "backend exited early"; cat e2e-artifacts/backend.log; exit 1 + fi + sleep 2 + done + if [ "$ready" != "1" ]; then + echo "backend did not finish seeding in time" + cat e2e-artifacts/backend.log + exit 1 + fi + grep -m1 '"serverUrl"' e2e-artifacts/backend.log + curl -fsS "http://127.0.0.1:$BB_MOBILE_E2E_PORT/health" + + - name: Run Maestro flows + env: + SERVER_URL: http://127.0.0.1:${{ env.BB_MOBILE_E2E_PORT }} + FLOWS: ${{ inputs.flows }} + MAESTRO_CLI_NO_ANALYTICS: "true" + MAESTRO_DISABLE_UPDATE_CHECK: "true" + # The first run installs the XCUITest driver on the simulator. + MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000" + run: | + # shellcheck disable=SC2086 + apps/mobile/e2e/scripts/ci-run-flows.sh "$SIMULATOR_UDID" "$GITHUB_WORKSPACE/e2e-artifacts/maestro" $FLOWS + + - name: Collect simulator logs + if: always() + run: | + xcrun simctl spawn "$SIMULATOR_UDID" log show --last 30m --predicate 'process == "bb"' > e2e-artifacts/simulator-bb.log 2>&1 || true + cp -R "$HOME/.maestro/tests" e2e-artifacts/maestro-debug 2>/dev/null || true + + - name: Stop the e2e backend + if: always() + run: | + if [ -f e2e-artifacts/backend.pid ]; then + kill "$(cat e2e-artifacts/backend.pid)" 2>/dev/null || true + fi + pkill -f "mobile-e2e/backend.ts" 2>/dev/null || true + + - name: Upload artifacts (screenshots, Maestro logs, backend log) + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mobile-e2e-ios + path: e2e-artifacts + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/mobile-ios-eas.yml b/.github/workflows/mobile-ios-eas.yml new file mode 100644 index 0000000000..2c60533732 --- /dev/null +++ b/.github/workflows/mobile-ios-eas.yml @@ -0,0 +1,213 @@ +# Build the bb mobile app for iOS on EAS and (optionally) submit it to +# TestFlight. Runs on its own from the Actions tab and as the nightly job in +# publish-bb-app.yml. EAS builds on its own macOS workers, so this only needs +# an Ubuntu runner that starts the build and waits for the result. +name: Mobile iOS (EAS) + +on: + workflow_dispatch: + inputs: + profile: + description: EAS build profile from apps/mobile/eas.json. + required: true + type: choice + default: production + options: + - production + - preview + - development-device + submit: + description: Submit the finished build to TestFlight (production profile only). + required: true + type: boolean + default: true + version: + description: Marketing version for app.json (X.Y.Z; a -prerelease suffix is dropped). Empty keeps the committed value. + required: false + type: string + default: "" + external_group: + description: TestFlight external group that receives the submitted build. Empty skips this step. + required: false + type: string + default: External testers + workflow_call: + inputs: + profile: + required: true + type: string + submit: + required: true + type: boolean + version: + required: true + type: string + external_group: + required: true + type: string + secrets: + EXPO_TOKEN: + required: true + ASC_API_KEY_P8: + required: true + +permissions: + contents: read + +jobs: + build: + name: Build bb iOS on EAS (${{ inputs.profile }}) + runs-on: ubuntu-latest + # The job waits for the EAS build and the TestFlight upload. On the free + # EAS tier the build and submit queues alone can take 30+ minutes each. + timeout-minutes: 150 + # One EAS submission at a time: two runs with --auto-submit would race on + # the remote build number and upload two TestFlight builds at once. The + # job holds this lock until EAS reports the final result. + concurrency: + group: mobile-ios-eas + cancel-in-progress: false + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 9.15.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.x + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Require EAS and App Store Connect secrets + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} + run: | + set -euo pipefail + + missing_secret_names=() + for secret_name in EXPO_TOKEN ASC_API_KEY_P8; do + if [[ -z "${!secret_name:-}" ]]; then + missing_secret_names+=("$secret_name") + fi + done + + if [[ "${#missing_secret_names[@]}" -gt 0 ]]; then + echo "::error::iOS EAS builds need EAS and App Store Connect access. Missing: ${missing_secret_names[*]}." + exit 1 + fi + + - name: Apply the marketing version + if: ${{ inputs.version != '' }} + env: + MOBILE_VERSION: ${{ inputs.version }} + working-directory: apps/mobile + run: | + set -euo pipefail + + # iOS accepts only numeric dotted versions, so a prerelease version + # (0.38.1-nightly.N.M) carries its base 0.38.1; the remote EAS + # build number tells builds apart. + MOBILE_VERSION="${MOBILE_VERSION%%-*}" + if [[ ! "$MOBILE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Marketing version must be numeric (X.Y.Z), got '${MOBILE_VERSION}'." + exit 1 + fi + + node --input-type=module -e ' + import { readFileSync, writeFileSync } from "node:fs"; + const config = JSON.parse(readFileSync("app.json", "utf8")); + config.expo.version = process.argv[1]; + writeFileSync("app.json", `${JSON.stringify(config, null, 2)}\n`); + ' "$MOBILE_VERSION" + echo "Applied mobile version ${MOBILE_VERSION}." + + - name: Write the App Store Connect API key + env: + ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} + working-directory: apps/mobile + run: | + set -euo pipefail + # eas.json submit.production points at this gitignored path. + umask 077 + printf '%s\n' "$ASC_API_KEY_P8" > asc-api-key.p8 + + - name: Build on EAS and wait for the result + id: eas_build + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EAS_PROFILE: ${{ inputs.profile }} + EAS_SUBMIT: ${{ inputs.submit }} + working-directory: apps/mobile + # EAS builds on its own workers for ~20 minutes and, with --auto-submit, + # then uploads to App Store Connect with the submit profile of the same + # name. The command waits for both and exits non-zero when either + # fails, so a red EAS build or upload makes this job red. expo.dev + # holds the build and submission logs; the run summary links to them. + run: | + set -euo pipefail + + args=(build --platform ios --profile "$EAS_PROFILE" --non-interactive --json) + if [[ "$EAS_SUBMIT" == "true" ]]; then + if [[ "$EAS_PROFILE" != "production" ]]; then + echo "::error::Only the production profile has a submit profile; got '${EAS_PROFILE}'." + exit 1 + fi + args+=(--auto-submit) + fi + + # --json puts the finished build records on stdout and the progress + # log on stderr. The records carry the marketing version and the + # remote build number that the distribute step needs. + pnpm exec eas "${args[@]}" 2> >(tee eas-build.log >&2) > eas-build.json + { + echo "## EAS build" + echo + grep -Eo 'https://expo\.dev/[^ ]+' eas-build.log | sed 's/^/- /' || true + } >> "$GITHUB_STEP_SUMMARY" + + node --input-type=module -e ' + import { readFileSync, appendFileSync } from "node:fs"; + const builds = JSON.parse(readFileSync("eas-build.json", "utf8")); + const build = Array.isArray(builds) ? builds[0] : builds; + const { appVersion, appBuildVersion } = build ?? {}; + if (typeof appVersion !== "string" || typeof appBuildVersion !== "string") { + throw new Error(`eas build --json returned no appVersion/appBuildVersion: ${JSON.stringify(build)}`); + } + appendFileSync(process.env.GITHUB_OUTPUT, `app_version=${appVersion}\nbuild_number=${appBuildVersion}\n`); + console.log(`EAS built ${appVersion} (${appBuildVersion}).`); + ' + + - name: Add the build to the TestFlight external group + if: ${{ inputs.submit && inputs.external_group != '' }} + env: + APP_VERSION: ${{ steps.eas_build.outputs.app_version }} + BUILD_NUMBER: ${{ steps.eas_build.outputs.build_number }} + EXTERNAL_GROUP: ${{ inputs.external_group }} + working-directory: apps/mobile + # Apple offers automatic distribution only for internal groups. This + # waits for App Store Connect to finish processing the upload, submits + # the build for Beta App Review when the build has none, and adds it to + # the external group. A later build of an approved marketing version + # usually clears review in minutes with no human step. + run: | + set -euo pipefail + node scripts/testflight-distribute.mjs \ + --version "$APP_VERSION" \ + --build "$BUILD_NUMBER" \ + --group "$EXTERNAL_GROUP" \ + --key-path ./asc-api-key.p8 + + - name: Remove the App Store Connect API key + if: ${{ always() }} + working-directory: apps/mobile + run: rm -f asc-api-key.p8 diff --git a/.github/workflows/mobile-runner-probe.yml b/.github/workflows/mobile-runner-probe.yml new file mode 100644 index 0000000000..b4284819c8 --- /dev/null +++ b/.github/workflows/mobile-runner-probe.yml @@ -0,0 +1,80 @@ +name: Mobile Runner Probe + +# Manual probe of the macOS runner before we commit to an iOS simulator e2e +# job for apps/mobile (see plans/bb-mobile-expo.md, Phase 0). Prints the +# toolchain versions Maestro/Expo need and times a Release iOS build. +on: + workflow_dispatch: + inputs: + build: + description: Also time a Release simulator build of apps/mobile + required: false + default: "false" + +permissions: + contents: read + +env: + PNPM_VERSION: 9.15.0 + +jobs: + probe: + name: Probe macOS runner toolchain + runs-on: blacksmith-6vcpu-macos-15 + timeout-minutes: 60 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Same Xcode selection as mobile-e2e.yml: the macOS 15 image defaults to + # Xcode 16.4 but also ships 26.x; apps/mobile is verified on 26.2. + - name: Select Xcode + run: | + if [ -d /Applications/Xcode_26.2.app ]; then + xcode=/Applications/Xcode_26.2.app + else + xcode=$(find /Applications -maxdepth 1 -name 'Xcode_26*.app' | sort -V | tail -1) + fi + if [ -n "${xcode:-}" ]; then + echo "DEVELOPER_DIR=$xcode/Contents/Developer" >> "$GITHUB_ENV" + else + echo "::warning::No Xcode 26.x found; using the selected Xcode" + fi + + - name: Toolchain versions + run: | + set -x + sw_vers + find /Applications -maxdepth 1 -name 'Xcode*.app' + xcodebuild -version + xcrun simctl list runtimes + xcrun simctl list devices available | head -40 + java -version 2>&1 || true + /usr/libexec/java_home -V 2>&1 || true + which maestro || echo "maestro: not installed" + pod --version || true + node --version + df -h / | tail -1 + + - name: Setup workspace + if: ${{ inputs.build == 'true' }} + uses: ./.github/actions/setup-workspace + with: + node-version: "22.x" + pnpm-version: ${{ env.PNPM_VERSION }} + cache-prefix: mobile-probe + turbo-cache: "false" + + - name: Time a Release simulator build + if: ${{ inputs.build == 'true' }} + working-directory: apps/mobile + env: + LANG: en_US.UTF-8 + CI: "1" + run: | + set -x + time npx expo prebuild --platform ios --no-install + time (cd ios && pod install) + DEVICE=$(node e2e/scripts/pick-simulator.mjs) + echo "device=$DEVICE" + time npx expo run:ios --configuration Release --no-bundler --device "$DEVICE" diff --git a/.github/workflows/publish-bb-app.yml b/.github/workflows/publish-bb-app.yml index e12cc3552e..f8a4041512 100644 --- a/.github/workflows/publish-bb-app.yml +++ b/.github/workflows/publish-bb-app.yml @@ -717,8 +717,48 @@ jobs: apps/desktop/release/desktop-version-linux.json if-no-files-found: error + nightly-mobile-ios: + name: Build bb Nightly iOS (EAS) + # Same gate as the desktop jobs: only after the npm publish succeeded on + # the scheduled or manual nightly path. The reusable workflow starts an + # EAS production build that auto-submits to TestFlight. + if: >- + ${{ !cancelled() + && needs.publish.result == 'success' + && (github.event_name == 'schedule' + || (inputs.npm_tag == 'nightly' && inputs.dry_run == false) + || needs.publish-nightly.result == 'success') }} + needs: + - publish + - publish-nightly + uses: ./.github/workflows/mobile-ios-eas.yml + with: + profile: production + submit: true + # An empty version keeps the marketing version committed in + # apps/mobile/app.json. TestFlight needs a Beta App Review for the + # first build of each new marketing version, so a nightly that tracked + # the bb-app version (0.39.0, 0.39.1, ...) blocked external testers + # every time the base version moved. With a pinned version, the remote + # EAS build number tells nightly builds apart and later builds skip + # the review. + version: "" + external_group: External testers + secrets: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} + nightly-desktop-publish: name: Publish bb Nightly desktop + # See the macOS job for why this condition uses `!cancelled()`. GitHub + # propagates a skip transitively, so the skipped publish-nightly reaches + # this job through the build jobs even though they run. Without the + # override this job is skipped on exactly the paths that exist to produce + # a nightly, and only a stable release run ever moves the release. + if: >- + ${{ !cancelled() + && needs.nightly-desktop-macos.result == 'success' + && needs.nightly-desktop-linux.result == 'success' }} needs: - nightly-desktop-macos - nightly-desktop-linux diff --git a/.gitignore b/.gitignore index 216f178225..8808bc1138 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,13 @@ apps/cli/.packaged-plugin-build-* # Generated by the bb:bundle-stats Vite plugin for the boot-payload budget check. apps/app/bundle-stats.json + +# Raw provider bridge recordings (record mode output) can hold secrets; only +# the redacted copies under packages/provider-bridge-protocol/recordings ship. +provider-recordings/raw/ + +# Private provider corpus (tests read it through BB_PROVIDER_CORPUS_DIR). +# Only the in-repo harness and scripts directories of that name are tracked. +**/provider-corpus/** +!apps/server/test/provider-corpus/** +!scripts/provider-corpus/** diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000000..846d330e0e --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,33 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 80, + "sortPackageJson": false, + "ignorePatterns": [ + "node_modules/", + "dist/", + "build/", + "coverage/", + ".next/", + "out/", + ".turbo/", + "data/", + "!apps/mobile/src/data/", + "output.txt", + ".codex/", + ".claude/", + "pnpm-lock.yaml", + "plugins/provider-codex/src/generated/", + "packages/templates/src/generated/", + "packages/plugin-sdk/bundled-types/", + "packages/plugin-build/src/generated/", + "packages/db/drizzle/meta/", + "apps/mobile/assets/terminal/", + "*.snapshot.json", + "apps/mobile/ios/", + "apps/mobile/android/", + "apps/mobile/.expo/", + "apps/mobile/expo-env.d.ts", + "apps/mobile/build-output/", + "packages/templates/src/templates/" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..8b3a6924e0 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,161 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [], + "jsPlugins": ["./scripts/oxlint-plugin.mjs"], + "categories": { + "correctness": "off" + }, + "env": { + "builtin": true + }, + "ignorePatterns": [ + "**/node_modules/**", + "**/dist/**", + "**/coverage/**", + "**/routeTree.gen.ts", + "packages/core/src/generated/**", + "apps/mobile/ios/**", + "apps/mobile/android/**", + "apps/mobile/.expo/**", + "packages/templates/src/generated/**", + "packages/plugin-build/src/generated/**", + "packages/plugin-sdk/bundled-types/**" + ], + "overrides": [ + { + "files": ["**/*.{ts,tsx}"], + "plugins": ["react"], + "rules": { + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "error", + "react/static-components": "error", + "react/use-memo": "error", + "react/void-use-memo": "error", + // Oxlint's native analyzer finds additional existing adoption issues; + // keep them visible without making the tooling migration blocking. + "react/preserve-manual-memoization": "warn", + "react/incompatible-library": "warn", + "react/immutability": "warn", + "react/globals": "error", + "react/refs": "warn", + "react/set-state-in-effect": "warn", + "react/error-boundaries": "error", + "react/purity": "warn", + "react/set-state-in-render": "error", + "react/unsupported-syntax": "warn" + } + }, + { + "files": ["apps/**/*.{ts,tsx}", "packages/**/*.{ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:child_process", + "importNames": ["spawnSync", "execSync", "execFileSync"], + "message": "Use async child_process APIs instead of blocking sync variants." + }, + { + "name": "child_process", + "importNames": ["spawnSync", "execSync", "execFileSync"], + "message": "Use async child_process APIs instead of blocking sync variants." + } + ] + } + ], + "bb/no-blocking-child-process-call": "error" + } + }, + { + "files": [ + "**/__tests__/**", + "**/*.test.ts", + "**/*.test.tsx", + "**/scripts/**" + ], + "rules": { + "no-restricted-imports": "off", + "bb/no-blocking-child-process-call": "off" + } + }, + { + "files": ["apps/server/src/**/*.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@bb/host-workspace", + "message": "Server must not access workspaces directly. Use daemon commands instead." + }, + { + "name": "@bb/host-watcher", + "message": "Server must not access host watchers directly. Use daemon commands instead." + }, + { + "name": "node:fs", + "message": "Server must not use node:fs. Use daemon commands for workspace access. (attachments.ts is the only exception — it manages server-local storage.)" + }, + { + "name": "node:fs/promises", + "message": "Server must not use node:fs/promises. Use daemon commands for workspace access. (attachments.ts is the only exception — it manages server-local storage.)" + } + ] + } + ] + } + }, + { + "files": [ + "apps/server/src/**/__tests__/**", + "apps/server/src/**/*.test.ts" + ], + "rules": { + "no-restricted-imports": "off" + } + }, + { + "files": ["apps/mobile/**/*.{ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@bb/sdk", + "message": "Import @bb/sdk/browser: the root entry resolves to the Node SDK under Metro's source condition." + } + ], + "patterns": [ + { + "group": ["@bb/shared-ui", "@bb/shared-ui/*"], + "message": "@bb/shared-ui is React DOM + Radix. Use the mobile primitives instead." + } + ] + } + ] + } + }, + { + "files": ["apps/app/src/**/*.{ts,tsx}"], + "rules": { + "bb/no-native-title-with-aria-label": "error", + "bb/no-native-title-on-button": "error" + } + }, + { + "files": [ + "apps/app/src/**/*.test.ts", + "apps/app/src/**/*.test.tsx", + "apps/app/src/**/*.stories.tsx" + ], + "rules": { + "bb/no-native-title-with-aria-label": "off", + "bb/no-native-title-on-button": "off" + } + } + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 86e0d06c3f..0000000000 --- a/.prettierignore +++ /dev/null @@ -1,28 +0,0 @@ -# Dependencies and build output -node_modules/ -dist/ -build/ -coverage/ -.next/ -out/ -.turbo/ - -# Local/runtime state -data/ -output.txt -.codex/ -.claude/ - -# Lockfiles are package-manager owned. -pnpm-lock.yaml - -# Generated or captured artifacts. Keep these byte-stable unless regenerated by -# their owning tool. -plugins/provider-codex/src/generated/ -packages/templates/src/generated/ -packages/plugin-sdk/bundled-types/ -packages/plugin-build/src/generated/ -packages/db/drizzle/meta/ - -# These templates intentionally use aligned plain-text command columns. -packages/templates/src/templates/ diff --git a/AGENTS.md b/AGENTS.md index 60813b6606..8bfc5fd17d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,10 +64,9 @@ - When an agent creates a GitHub issue or pull request, add this line at the end of the body: ``` - > AGENT GENERATED: by + > AGENT GENERATED ``` -- Replace `` with the name of the model that writes the text, for example `Claude Opus 5`. - Add this line to each new issue and pull request. It shows the readers that an agent made the content. ## Debugging And QA diff --git a/README.md b/README.md index 8abfad19b4..0792800f9a 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,19 @@ checkout path. The checkout instance id is the sanitized path to the checkout, relative to your home directory, plus a short hash suffix. Separate worktrees can run alongside each other and the packaged `npx bb-app@latest` instance. +To test the production bundle and serving path without switching to production +data or ports, use: + +```bash +pnpm start:worktree +``` + +This builds the same optimized frontend and runtime artifacts as `pnpm start`, +then serves the app from the BB server on the checkout-specific dev server port. +It keeps the normal checkout-specific dev data directory and host-daemon port. +There is no Vite dev server or hot reload in this mode; rerun the command after +source changes. As with `pnpm dev`, worktree starts do not send telemetry. + To run that same source dev server with the Electron desktop shell: ```bash @@ -130,6 +143,18 @@ Then open `https://..ts.net`. Source dev binds both the Vite app and main server to loopback by default; Vite continues to proxy API and WebSocket traffic. +For direct access at `http://:` instead, run: + +```bash +pnpm dev:remote +``` + +This binds the Vite app and main server to all IPv4 interfaces. The remote +browser must be able to reach both the printed app and server ports for realtime +updates. The server API is unauthenticated and permits command execution and +file reads, so use this only behind a trusted network boundary and restrict the +ports to Tailscale traffic with the host firewall when the LAN is not trusted. + To use the component storybook from another machine, run: ```bash diff --git a/apps/app/.ladle/model-picker-query-provider.tsx b/apps/app/.ladle/model-picker-query-provider.tsx index 865956a2f8..852466ffb9 100644 --- a/apps/app/.ladle/model-picker-query-provider.tsx +++ b/apps/app/.ladle/model-picker-query-provider.tsx @@ -54,6 +54,9 @@ const STORY_PROVIDER_INFOS: ProviderInfo[] = STORY_PROVIDER_OPTIONS.map( displayName: provider.label, logoUrl: null, available: true, + experimental_providerHealth: true, + experimental_providerUsage: true, + experimental_providerInstallation: true, composerActions: [ ...(STORY_COMPOSER_ACTIONS_BY_PROVIDER[provider.value] ?? []), ], diff --git a/apps/app/.ladle/settings-story-fixtures.tsx b/apps/app/.ladle/settings-story-fixtures.tsx new file mode 100644 index 0000000000..d83b372a48 --- /dev/null +++ b/apps/app/.ladle/settings-story-fixtures.tsx @@ -0,0 +1,250 @@ +import { useState, type ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { + PERSONAL_PROJECT_ID, + defaultAppSettings, + defaultAppTheme, + defaultExperiments, +} from "@bb/domain"; +import { UPDATE_ACTION_ICON } from "@bb/domain/update-state"; +import type { + SidebarBootstrapResponse, + SystemConfigResponse, + SystemVersionResponse, +} from "@bb/server-contract"; +import type { ProviderCliStatusResponse } from "@bb/host-daemon-contract"; +import { + hostProviderCliStatusQueryKey, + hostsQueryKey, + pluginListQueryKey, + pluginMarketplacesQueryKey, + sidebarNavigationQueryKey, + systemConfigQueryKey, + systemVersionQueryKey, +} from "../src/hooks/queries/query-keys"; +import { + buildUpdateInventoryProviderIssues, + type UpdateInventoryMachine, +} from "../src/hooks/useUpdateInventory"; +import { createAppQueryClient } from "../src/lib/query-client"; +import { getSettingsRoutePath } from "../src/lib/route-paths"; +import { + BbAppUpdateRows, + MachineUpdatesRows, + MachineUpdatesSection, + UpdateActionButton, +} from "../src/components/settings/UpdatesSettingsSection"; +import { + HOST_IDS, + HOST_NAMES, + PROJECT_IDS, + STORY_PROJECT_SOURCES, + makeHost, + makeProject, + makeProviderCliStatus, +} from "./story-fixtures"; + +const SETTINGS_STORY_NOW = Date.parse("2026-08-19T08:00:00.000Z"); + +const SETTINGS_STORY_PRIMARY_HOST = makeHost({ + createdAt: SETTINGS_STORY_NOW - 45 * 24 * 60 * 60_000, + lastSeenAt: SETTINGS_STORY_NOW, +}); + +const SETTINGS_STORY_HOSTS = [ + SETTINGS_STORY_PRIMARY_HOST, + makeHost({ + id: HOST_IDS.remote, + name: HOST_NAMES.remote, + maxPermissionMode: "auto", + createdAt: SETTINGS_STORY_NOW - 18 * 24 * 60 * 60_000, + lastSeenAt: SETTINGS_STORY_NOW - 3 * 60_000, + }), +]; + +const localProviderStatus = { + codex: makeProviderCliStatus("codex", { + currentVersion: "0.145.0", + latestVersion: "0.146.0", + needsUpdate: true, + installAction: { + kind: "update", + label: "Update", + command: "codex update", + }, + }), + "claude-code": makeProviderCliStatus("claude-code", { + currentVersion: "2.1.0", + latestVersion: "2.1.0", + }), + "acp-cursor": makeProviderCliStatus("acp-cursor", { + currentVersion: "0.49.0", + latestVersion: "0.49.0", + }), +} satisfies ProviderCliStatusResponse; + +const remoteProviderStatus = { + codex: makeProviderCliStatus("codex", { + currentVersion: "0.145.0", + latestVersion: "0.146.0", + needsUpdate: true, + installAction: { + kind: "update", + label: "Update", + command: "codex update", + }, + }), + "claude-code": makeProviderCliStatus("claude-code", { + currentVersion: "2.1.0", + latestVersion: "2.1.0", + }), + "acp-cursor": makeProviderCliStatus("acp-cursor", { + installed: false, + executablePath: null, + currentVersion: null, + latestVersion: "0.49.0", + installSource: undefined, + }), +} satisfies ProviderCliStatusResponse; + +const project = makeProject({ + id: PROJECT_IDS.bb, + sources: [...STORY_PROJECT_SOURCES], +}); +const personalProject = makeProject({ + id: PERSONAL_PROJECT_ID, + kind: "personal", + name: "Personal", + sources: [], +}); + +const sidebarNavigation = { + sections: [], + projects: [{ ...project, defaultExecutionOptions: null, threads: [] }], + personalProject: { + ...personalProject, + defaultExecutionOptions: null, + threads: [], + }, +} satisfies SidebarBootstrapResponse; + +const systemConfig = { + generalSettings: defaultAppSettings, + keybindings: [], + defaultKeybindings: [], + keybindingOverrides: [], + experiments: defaultExperiments, + appearance: defaultAppTheme, + customThemes: [], + pluginThemes: [], + featureFlags: { placeholder: false, timelineWindowEventBudget: 1_500 }, + hostDaemonPort: null, + localHelperPorts: [], + serverUrl: "http://localhost:38886", + primaryHostId: HOST_IDS.local, + primaryHostPlatform: "darwin", + voiceTranscriptionEnabled: true, + dataDir: "/Users/michael/.bb", +} satisfies SystemConfigResponse; + +const systemVersion = { + currentVersion: "0.39.0", + latestVersion: "0.39.0", + source: "npm", + updateAvailable: false, + isDevelopment: false, + upgradeCommand: "npx bb-app@latest", +} satisfies SystemVersionResponse; + +const settingsUpdateMachine = { + host: SETTINGS_STORY_PRIMARY_HOST, + isPrimary: true, + providerStatus: localProviderStatus, + statusPending: false, + statusError: false, + statusFetching: false, + issues: buildUpdateInventoryProviderIssues(localProviderStatus), + canRetryDaemonUpdate: false, +} satisfies UpdateInventoryMachine; + +const noJobs: ReadonlySet = new Set(); +const noop = () => {}; + +/** The representative, side-effect-free Updates route inside the Settings story. */ +export function SettingsUpdatesStory() { + const navigate = useNavigate(); + return ( +
+ + +
+ } + > + + navigate(getSettingsRoutePath("providers"))} + /> + + + ); +} + +function createSettingsStoryQueryClient() { + const queryClient = createAppQueryClient({ + showMutationErrorToasts: false, + defaultOptions: { + mutations: { retry: false }, + queries: { + gcTime: Infinity, + retry: false, + staleTime: Infinity, + }, + }, + }); + queryClient.setQueryData(hostsQueryKey(), SETTINGS_STORY_HOSTS); + queryClient.setQueryData(systemConfigQueryKey(), systemConfig); + queryClient.setQueryData(systemVersionQueryKey(), systemVersion); + queryClient.setQueryData(sidebarNavigationQueryKey(), sidebarNavigation); + queryClient.setQueryData(pluginMarketplacesQueryKey(), []); + queryClient.setQueryData( + hostProviderCliStatusQueryKey(HOST_IDS.local), + localProviderStatus, + ); + queryClient.setQueryData( + hostProviderCliStatusQueryKey(HOST_IDS.remote), + remoteProviderStatus, + ); + queryClient.setQueryData(pluginListQueryKey(true), { plugins: [] }); + return queryClient; +} + +/** Deterministic production-query fixtures shared by every Settings route. */ +export function SettingsStoryFixtures({ children }: { children: ReactNode }) { + const [queryClient] = useState(createSettingsStoryQueryClient); + return ( + {children} + ); +} diff --git a/apps/app/.ladle/story-card.tsx b/apps/app/.ladle/story-card.tsx index d594382fd3..a12385bb7d 100644 --- a/apps/app/.ladle/story-card.tsx +++ b/apps/app/.ladle/story-card.tsx @@ -26,7 +26,7 @@ const StoryCardContext = createContext<{ valueAlign: "start", }); -export interface StoryCardProps { +interface StoryCardProps { children: ReactNode; className?: string; labelWidth?: string; @@ -90,7 +90,7 @@ export function StoryCard({ ); } -export interface StoryRowProps { +interface StoryRowProps { label: ReactNode; hint?: ReactNode; children: ReactNode; diff --git a/apps/app/.ladle/story-dialog-stage.tsx b/apps/app/.ladle/story-dialog-stage.tsx index 9123aa3ac7..ddf5fe1d21 100644 --- a/apps/app/.ladle/story-dialog-stage.tsx +++ b/apps/app/.ladle/story-dialog-stage.tsx @@ -5,7 +5,7 @@ import { Icon } from "@bb/shared-ui/icon"; const noop = () => {}; -export interface DialogStageProps { +interface DialogStageProps { className?: string; children: ReactNode; } diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index b9ca878c78..1ee7dc5316 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -7,6 +7,10 @@ import type { ThreadListEntry, WorkspaceStatus, } from "@bb/domain"; +import type { + ProviderCliKey, + ProviderCliStatus, +} from "@bb/host-daemon-contract"; import type { ProjectResponse } from "@bb/server-contract"; import { ClaudeIcon } from "../src/components/icons/ClaudeIcon"; import { OpenAiIcon } from "../src/components/icons/OpenAiIcon"; @@ -413,6 +417,34 @@ export function makeHost(overrides: Partial = {}): Host { return { ...base, ...overrides }; } +export function makeProviderCliStatus( + provider: ProviderCliKey, + overrides: Partial = {}, +): ProviderCliStatus { + const identity = + provider === "codex" + ? { displayName: "Codex", executableName: "codex" } + : provider === "claude-code" + ? { displayName: "Claude Code", executableName: "claude" } + : { displayName: "Cursor", executableName: "agent" }; + return { + displayName: identity.displayName, + executableName: identity.executableName, + executablePath: `/usr/local/bin/${identity.executableName}`, + installed: true, + installSource: "npmGlobal", + currentVersion: "1.0.0", + latestVersion: "1.0.0", + minimumSupportedVersion: null, + npmPackageName: null, + npmGlobalPackageVersion: null, + installAction: null, + needsUpdate: false, + versionUnsupported: false, + ...overrides, + }; +} + export function makeEnvironment( overrides: Partial = {}, ): Environment { diff --git a/apps/app/.ladle/story-settings-chrome.tsx b/apps/app/.ladle/story-settings-chrome.tsx new file mode 100644 index 0000000000..9b8a372651 --- /dev/null +++ b/apps/app/.ladle/story-settings-chrome.tsx @@ -0,0 +1,98 @@ +import type { CSSProperties, ReactNode } from "react"; +import { matchPath, useLocation } from "react-router-dom"; +import { AppPageHeader } from "@/components/layout/AppPageHeader"; +import { SettingsSidebarContent } from "@/components/settings/SettingsSidebar"; +import { + SETTINGS_NAV_SECTIONS, + type SettingsSectionId, +} from "@/components/settings/settings-nav"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { PageShell } from "@/components/ui/page-shell"; +import { + SETTINGS_ROUTE_PATH, + SETTINGS_MACHINE_ROUTE_PATH, + getSettingsRoutePath, +} from "@/lib/route-paths"; + +export type SettingsStoryRoute = + | { kind: "machine"; id: string } + | { kind: "section"; id: SettingsSectionId }; + +/** Resolve the story's real Settings links without depending on live app data. */ +export function useSettingsStoryRoute(): SettingsStoryRoute { + const { pathname } = useLocation(); + const machineMatch = matchPath(SETTINGS_MACHINE_ROUTE_PATH, pathname); + if (machineMatch?.params.hostId !== undefined) { + return { kind: "machine", id: machineMatch.params.hostId }; + } + const section = SETTINGS_NAV_SECTIONS.find((entry) => + entry.id === "general" + ? pathname === SETTINGS_ROUTE_PATH + : getSettingsRoutePath(entry.id) === pathname, + ); + return { kind: "section", id: section?.id ?? "general" }; +} + +/** Production application chrome around full-page Settings stories. */ +export function SettingsStoryChrome({ + activeSection, + children, + contentOwnsPageShell = false, +}: { + activeSection?: SettingsSectionId; + children: ReactNode; + /** Detail routes already render their production PageShell. */ + contentOwnsPageShell?: boolean; +}) { + const route = useSettingsStoryRoute(); + const resolvedActiveSection = + activeSection ?? (route.kind === "section" ? route.id : "machines"); + + return ( + + {}} + showTopReserve + testIdPrefix="settings-story" + /> + +
+ + + Settings +
+ } + /> +
+ {contentOwnsPageShell ? ( + children + ) : ( + +
+ {children} +
+
+ )} +
+ +
+
+ ); +} diff --git a/apps/app/.ladle/story-states.tsx b/apps/app/.ladle/story-states.tsx new file mode 100644 index 0000000000..5e1bf09e54 --- /dev/null +++ b/apps/app/.ladle/story-states.tsx @@ -0,0 +1,112 @@ +import type { CSSProperties, ReactNode } from "react"; + +/** + * The two-column state-catalogue shell: what a state is on the left, the real + * component on the right, every state stacked down one scrollable page. + * + * Extracted from the Extensions detail stories so a second catalogue does not + * have to reimplement the shell — and so the two cannot drift into looking + * like different kinds of document. Keep layout here and fixtures in the + * story file; this module knows nothing about any particular component. + */ +export function StoryStates({ + title, + description, + renderedLabel = "Rendered page", + renderedNote = "The real component", + children, +}: { + title: string; + description: string; + renderedLabel?: string; + renderedNote?: string; + children: ReactNode; +}) { + return ( +
+
+

{title}

+

+ {description} +

+
+
+
+ + + State + + + When it happens + + + + + {renderedLabel} + + + {renderedNote} + + +
+ {children} +
+
+ ); +} + +/** + * One state: what it is on the left, the real page on the right. The caption + * sticks while a tall page scrolls past it, so you never lose track of which + * state you are looking at. + */ +export function StoryState({ + name, + note, + children, +}: { + name: string; + /** A sentence, or a list when the state has several ways in. */ + note: ReactNode; + children: ReactNode; +}) { + return ( +
+
+
+

{name}

+
+ {note} +
+
+
+
{children}
+
+ ); +} + +/** + * A band across both columns that names the group of states beneath it, so a + * long catalogue reads as sections rather than one undifferentiated list. + */ +export function StoryStateGroup({ + title, + note, +}: { + title: string; + note?: string; +}) { + return ( +
+

+ {title} +

+ {note ? ( +

{note}

+ ) : null} +
+ ); +} diff --git a/apps/app/.ladle/vite.config.ts b/apps/app/.ladle/vite.config.ts index 8e92f48cc4..b7a5266b14 100644 --- a/apps/app/.ladle/vite.config.ts +++ b/apps/app/.ladle/vite.config.ts @@ -51,6 +51,8 @@ export default defineConfig({ // mutations as the app. Proxy them to this checkout's isolated dev server // instead of reconstructing server state with Ladle-only fixtures. server: { + // bb Connect shares use authenticated `--.getbb.app` hosts. + allowedHosts: [".getbb.app"], proxy: { "/api": { target: devInstance.serverUrl, diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index 74cf6608bf..7032e9dcae 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -8,7 +8,7 @@ "Chunks behind a dynamic import are deliberately not counted. Moving code", "behind React.lazy or import() is the fix this budget exists to encourage.", "", - "maxBootBytes / maxBootBrotliBytes are a ratchet set 3% above the measured", + "maxBootBytes / maxBootBrotliBytes are a ratchet set 10% above the measured", "payload: enough for a normal feature's worth of shell code and for build", "noise, tight enough that a barrel regression cannot hide inside it. Lower", "them when a change wins headroom. Raising one is a deliberate decision", @@ -38,15 +38,16 @@ "route chunk minus the boot chunks: the JavaScript between 'app shell", "painted' and 'route content painted'. SplitWorkspaceRoute is every thread,", "compose and plugin-panel page, so its closure is the second number that", - "decides how slow bb feels on a phone. Same 3% ratchet; its forbiddenPackages", - "are the diff engine, math and terminal code that only a user action needs.", + "decides how slow bb feels on a phone. Same 10% ratchet; its forbiddenPackages", + "are the diff engine, math, file tree and terminal code that only a user", + "action needs.", "The composer (tiptap/prosemirror) is visible on every thread page and is", "allowed until it moves behind a first-focus handoff. Run", "`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx `", "to print the static chain that pulled a package into the closure." ], - "maxBootBytes": 1626679, - "maxBootBrotliBytes": 438233, + "maxBootBytes": 1723617, + "maxBootBrotliBytes": 479067, "forbiddenBootPackages": [ "@pierre/diffs", "@pierre/theming", @@ -70,16 +71,18 @@ "shiki" ], "onDemandPackages": { + "@pierre/trees": "src/components/secondary-panel/ThreadStorageFileTree.tsx", "katex": "src/components/ui/markdown-katex.ts", "rehype-katex": "src/components/ui/markdown-katex.ts" }, "routeClosures": { "SplitWorkspaceRoute": { - "maxBytes": 2559064, - "maxBrotliBytes": 670688, + "maxBytes": 2268430, + "maxBrotliBytes": 605332, "forbiddenPackages": [ "@pierre/diffs", "@pierre/theming", + "@pierre/trees", "@shikijs/core", "@shikijs/engine-javascript", "@shikijs/engine-oniguruma", diff --git a/apps/app/package.json b/apps/app/package.json index 200bfed2ca..2c671181d5 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -13,11 +13,12 @@ "clean": "rimraf dist bundle-stats.json", "storybook": "ladle serve", "storybook:build": "ladle build", - "lint": "eslint src vite.config.ts vite.dev.config.ts vite-bundle-stats.ts vite-font-preload.ts vite-shared-ui-seam.ts --ext .ts,.tsx", + "lint": "oxlint src vite.config.ts vite.dev.config.ts vite-bundle-stats.ts vite-font-preload.ts vite-shared-ui-seam.ts", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", "test": "node scripts/generate-pwa-icons.mjs --check && vitest run --config vitest.config.ts" }, "dependencies": { + "@bb/client-core": "workspace:*", "@bb/config": "workspace:*", "@bb/core-ui": "workspace:*", "@bb/desktop-contract": "workspace:*", @@ -83,7 +84,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "cronstrue": "^3.14.0", "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", @@ -110,7 +110,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "sonner": "^1.7.4", - "sugar-high": "^1.2.1", + "sugar-high": "^2.0.1", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", "unist-util-visit": "^5.1.0", @@ -129,14 +129,12 @@ "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@typescript-eslint/parser": "^8.63.0", "@vitejs/plugin-react": "^6.0.1", "babel-plugin-react-compiler": "^1.0.0", "bb-plugin-automations": "workspace:*", - "eslint": "^9.39.3", - "eslint-plugin-react-hooks": "^7.0.1", "lightningcss": "^1.32.0", "mdast-util-to-hast": "^13.2.1", + "oniguruma-to-es": "^4.3.4", "sharp": "^0.34.5", "tailwindcss": "^4.3.0", "typescript": "npm:@typescript/typescript6@^6.0.2", diff --git a/apps/app/src/App.legacy-skill-route.test.tsx b/apps/app/src/App.legacy-skill-route.test.tsx index d9454399b2..0c5c195da8 100644 --- a/apps/app/src/App.legacy-skill-route.test.tsx +++ b/apps/app/src/App.legacy-skill-route.test.tsx @@ -5,7 +5,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { ExtensionsLandingRedirect, - LegacyPluginBrowseRedirect, LegacySkillDetailRedirect, LegacyToolsPathRedirect, } from "./App"; @@ -149,7 +148,7 @@ describe("LegacyToolsPathRedirect", () => { }); }); -describe("LegacyPluginBrowseRedirect", () => { +describe("legacy plugin browse redirect", () => { afterEach(cleanup); it("redirects the old Browse path to the canonical bare Plugins route", () => { @@ -158,7 +157,7 @@ describe("LegacyPluginBrowseRedirect", () => { } + element={} /> } /> diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 9773f91a1f..722e1db661 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -10,6 +10,8 @@ import { AppLayout } from "./components/layout/AppLayout"; import { AuthCallbackView } from "./views/AuthCallbackView"; import { QuickCreateProjectProvider } from "./hooks/useQuickCreateProject"; import { RouteNavigationProvider } from "./components/ui/app-route-anchor"; +import { AppNavigationUrlHost } from "./lib/url-open-routing"; +import { AppFileExternalNavigationHost } from "./components/plugin/AppFileExternalNavigationHost"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; import { useDesktopThemeSync } from "./hooks/useDesktopThemeSync"; @@ -35,7 +37,6 @@ import { SETTINGS_PLUGIN_ROUTE_PATH, SETTINGS_PLUGINS_ROUTE_PATH, SETTINGS_MACHINE_ROUTE_PATH, - SETTINGS_PROVIDER_ROUTE_PATH, SETTINGS_ROUTE_PATH, SETTINGS_SECTION_ROUTE_PATH, SKILLS_ROUTE_PATH, @@ -53,7 +54,6 @@ import { getSkillDetailRoutePath, } from "./lib/route-paths"; import { AppCommandProvider } from "./components/commands/AppCommandProvider"; -import { OnboardingHost } from "@/components/onboarding/OnboardingHost"; import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provider-cli-install"; import { PluginSettingsCompatibilityRoute } from "./components/settings/PluginSettingsCompatibilityRoute"; import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton"; @@ -165,10 +165,6 @@ export function LegacyToolsPathRedirect() { ); } -export function LegacyPluginBrowseRedirect() { - return ; -} - function hashTargetId(hash: string): string | null { if (hash.length <= 1) return null; try { @@ -256,10 +252,6 @@ function AppRoutes() { path={SETTINGS_MACHINE_ROUTE_PATH} element={} /> - } - /> } @@ -325,7 +317,7 @@ function AppRoutes() { } /> } + element={} /> - - - } - /> - } /> - - {/* Outside : a provider CLI install outlives the page that - started it, so its failure toast can be clicked from any route — - including auth callback, which renders no app shell. */} - - {/* First-run onboarding. Outside so it is not tied to a - page. It self-gates on the experiment and completion timestamp. */} - + + + + + } + /> + } /> + + {/* Outside : a provider CLI install outlives the page that + started it, so its failure toast can be clicked from any route — + including auth callback, which renders no app shell. */} + + + diff --git a/apps/app/src/app.css b/apps/app/src/app.css index 7a6829cb87..a56cdfbad3 100644 --- a/apps/app/src/app.css +++ b/apps/app/src/app.css @@ -55,6 +55,19 @@ overscroll-behavior-y: none; } + /* + * App chrome (sidebar, page headers, composer toolbars) opts out of text + * selection with `select-none` so drags and Select All only pick up + * content. `user-select: auto` resolves from the parent, so WebKit would + * carry that opt-out into editable controls inside those regions (the + * sidebar thread search, the inline thread-title rename) and refuse to + * select their text. Restore native selection on the controls themselves. + */ + .select-none + :where(input, textarea, [contenteditable]:not([contenteditable="false"])) { + user-select: text; + } + .chat-prompt-box { padding-bottom: 0.5rem; } @@ -434,16 +447,12 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) { + [data-promptbox]:not([data-promptbox-voice-active]) { overflow: hidden; } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-action-row] { position: absolute; inset-block: 0; @@ -453,25 +462,19 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-main] { height: 3rem; } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-expanded-only] { display: none !important; } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-scroll] { height: 3rem !important; min-height: 3rem !important; @@ -483,9 +486,7 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] { display: flex; align-items: center; @@ -493,9 +494,7 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .ProseMirror[contenteditable] { width: 100%; @@ -507,16 +506,12 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .ProseMirror[contenteditable] > *, [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .ProseMirror[contenteditable] :where(blockquote, h1, h2, h3, h4, h5, h6, li, ol, p, ul) { @@ -533,26 +528,20 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .ProseMirror[contenteditable] > * + *::before, [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .ProseMirror[contenteditable] :where(blockquote, li) > * + *::before, [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .ProseMirror[contenteditable] :where(ol, ul) @@ -562,9 +551,7 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .ProseMirror[contenteditable] br { @@ -573,9 +560,7 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .ProseMirror[contenteditable] br::after { @@ -583,9 +568,7 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .prompt-mention-pill > span:last-child { @@ -593,9 +576,7 @@ } [data-follow-up-composer]:not([data-follow-up-composer-expanded]) - [data-promptbox]:not([data-promptbox-zen]):not( - [data-promptbox-voice-active] - ) + [data-promptbox]:not([data-promptbox-voice-active]) [data-promptbox-editor-content] .ProseMirror p.is-editor-empty:first-child::before { diff --git a/apps/app/src/components/AppToaster.tsx b/apps/app/src/components/AppToaster.tsx index e5a74bb147..9e2c84f1b0 100644 --- a/apps/app/src/components/AppToaster.tsx +++ b/apps/app/src/components/AppToaster.tsx @@ -1,4 +1,4 @@ -import { Toaster, type ToasterProps } from "@/components/ui/sonner.js"; +import { Toaster, type ToasterProps } from "sonner"; import { usePreferredTheme } from "@/hooks/useTheme"; export function AppToaster(props: ToasterProps) { diff --git a/apps/app/src/components/code/BbDiff.test.tsx b/apps/app/src/components/code/BbDiff.test.tsx new file mode 100644 index 0000000000..ad5b4b049a --- /dev/null +++ b/apps/app/src/components/code/BbDiff.test.tsx @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultResolvedCodeTheme } from "@bb/domain"; +import { applyResolvedCodeTheme } from "@/lib/code-theme"; +import { parseGitDiffFiles } from "@/components/git-diff/git-diff-parsing"; +import { BbDiff } from "./BbDiff"; + +interface RenderedOptions { + theme: { dark: string; light: string }; + diffStyle: string; + overflow: string; + disableLineNumbers: boolean; + disableFileHeader: boolean; + expansionLineCount?: number; +} + +const pierre = vi.hoisted(() => ({ + lastOptions: null as RenderedOptions | null, + lastFileDiff: null as object | null, + processFileCalls: 0, +})); + +vi.mock("@pierre/diffs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + processFile: (...args: Parameters) => { + pierre.processFileCalls += 1; + return actual.processFile(...args); + }, + }; +}); + +vi.mock("@pierre/diffs/react", async () => { + const React = await import("react"); + return { + FileDiff: ({ + fileDiff, + options, + }: { + fileDiff: object; + options: RenderedOptions; + }) => { + pierre.lastFileDiff = fileDiff; + pierre.lastOptions = options; + return React.createElement("div", { "data-testid": "pierre-file-diff" }); + }, + }; +}); + +const PATCH = [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,3 +1,3 @@", + " const a = 1;", + "-const b = 2;", + "+const b = 3;", + " const c = 4;", + "", +].join("\n"); + +const FULL_FILE_CONTENTS = { + old: { + path: "src/app.ts", + content: [ + "const a = 1;", + "const b = 2;", + "const c = 4;", + "const oldTail = true;", + "", + ].join("\n"), + }, + new: { + path: "src/app.ts", + content: [ + "const a = 1;", + "const b = 3;", + "const c = 4;", + "const newTail = true;", + "", + ].join("\n"), + }, +}; + +function fixture() { + const file = parseGitDiffFiles(PATCH)[0]; + if (file === undefined) throw new Error("fixture patch did not parse"); + return file; +} + +beforeEach(() => { + pierre.lastOptions = null; + pierre.lastFileDiff = null; + pierre.processFileCalls = 0; + applyResolvedCodeTheme(defaultResolvedCodeTheme); +}); + +afterEach(() => { + cleanup(); + applyResolvedCodeTheme(defaultResolvedCodeTheme); + vi.restoreAllMocks(); +}); + +describe("BbDiff", () => { + it("follows the resolved code theme without any consumer watching the DOM", async () => { + render( + , + ); + await screen.findByTestId("pierre-file-diff"); + expect(pierre.lastOptions?.theme.dark).toBe(defaultResolvedCodeTheme.dark); + + act(() => { + applyResolvedCodeTheme({ + dark: "custom-dark", + light: "custom-light", + files: {}, + }); + }); + + expect(pierre.lastOptions?.theme).toEqual({ + dark: "custom-dark", + light: "custom-light", + }); + }); + + it("omits the expansion budget unless the caller can supply file contents", async () => { + // pierre renders an EMPTY diff when it is handed an expansion budget for a + // hunk-only patch — which is exactly what timeline file-change rows carry, + // since they have no way to fetch the full file. Sending the option + // unconditionally blanked every timeline diff. + render( + , + ); + await screen.findByTestId("pierre-file-diff"); + + expect(pierre.lastOptions).not.toBeNull(); + expect("expansionLineCount" in (pierre.lastOptions ?? {})).toBe(false); + }); + + it("enriches matching full contents and enables context expansion", async () => { + render( + , + ); + await screen.findByTestId("pierre-file-diff"); + + expect(pierre.lastOptions?.expansionLineCount).toBe(30); + expect(pierre.lastFileDiff).toMatchObject({ + isPartial: false, + additionLines: expect.arrayContaining(["const newTail = true;\n"]), + }); + }); + + it("rejects full contents that do not match the patch", async () => { + const file = fixture(); + render( + , + ); + await screen.findByTestId("pierre-file-diff"); + + expect(pierre.lastFileDiff).toBe(file); + expect(pierre.lastOptions).not.toHaveProperty("expansionLineCount"); + }); + + it("does not reparse when a new wrapper carries the same primitive contents", async () => { + const file = fixture(); + const { rerender } = render( + , + ); + await screen.findByTestId("pierre-file-diff"); + const firstResolvedFile = pierre.lastFileDiff; + expect(pierre.processFileCalls).toBe(1); + + rerender( + , + ); + + expect(pierre.lastFileDiff).toBe(firstResolvedFile); + expect(pierre.processFileCalls).toBe(1); + }); + + it("maps semantic presentation onto the renderer's options", async () => { + render( + , + ); + await screen.findByTestId("pierre-file-diff"); + + expect(pierre.lastOptions?.diffStyle).toBe("split"); + expect(pierre.lastOptions?.overflow).toBe("wrap"); + expect(pierre.lastOptions?.disableLineNumbers).toBe(true); + // The card header owns the file name; the renderer must never draw a second. + expect(pierre.lastOptions?.disableFileHeader).toBe(true); + }); +}); diff --git a/apps/app/src/components/code/BbDiff.tsx b/apps/app/src/components/code/BbDiff.tsx new file mode 100644 index 0000000000..ae3851437a --- /dev/null +++ b/apps/app/src/components/code/BbDiff.tsx @@ -0,0 +1,180 @@ +import { useCallback, useMemo, useRef, type CSSProperties } from "react"; +import type { FileDiffOptions, SelectedLineRange } from "@pierre/diffs"; +import { FileDiff as DiffView } from "@pierre/diffs/react"; +import { usePierreLineSelectionActions } from "@/components/git-diff/PierreLineSelectionActions.js"; +import { PierreWorkerPoolBoundary } from "@/lib/pierre-worker-pool-boundary"; +import { useRequirePierreWorkerPool } from "@/lib/pierre-worker-pool-gate"; +import { usePierreStrictModeRecoveryOptions } from "@/lib/pierre-strict-mode-recovery"; +import { + buildFileDiffPatchText, + buildDiffDomSelectionText, + buildDiffLineSelectionText, +} from "@/components/git-diff/git-diff-patch-text"; +import { enrichGitDiffFileForContext } from "@/components/git-diff/git-diff-parsing"; +import { useResolvedCodeThemePair } from "@/lib/code-theme"; +import { usePreferredTheme } from "@/hooks/useTheme"; +import { Skeleton } from "@bb/shared-ui/skeleton"; +import { cn } from "@bb/shared-ui/lib/utils"; +import type { BbDiffProps } from "./code-rendering"; + +const DIFF_VIEW_STYLE = { + "--diffs-font-size": "12px", + "--diffs-line-height": "18px", +} as CSSProperties; + +/** Unchanged lines revealed by one built-in expand-context action. */ +const DEFAULT_DIFF_EXPANSION_LINE_COUNT = 30; + +function BbDiffSkeleton() { + return ( +
+ + + + + + +
+ ); +} + +/** + * BB's default diff renderer: the `@pierre/diffs` `FileDiff` plus BB's line + * selection menu, resolved code theme, and presentation defaults. Reached only + * through {@link import("./DiffHost").DiffHost}, and only lazily — a plugin + * that replaces the renderer and never delegates never loads this module. + */ +export function BbDiff({ + file, + patchText, + fullFileContents, + view, + overflow, + showLineNumbers, + className, + onSelectionAddToChat, +}: BbDiffProps) { + const oldPath = fullFileContents?.old.path; + const oldContent = fullFileContents?.old.content; + const newPath = fullFileContents?.new.path; + const newContent = fullFileContents?.new.content; + const resolvedFile = useMemo(() => { + if ( + oldPath === undefined || + oldContent === undefined || + newPath === undefined || + newContent === undefined + ) { + return file; + } + return enrichGitDiffFileForContext({ + fileDiff: file, + oldFile: { name: oldPath, contents: oldContent }, + newFile: { name: newPath, contents: newContent }, + patchText: patchText ?? buildFileDiffPatchText(file), + }); + }, [file, newContent, newPath, oldContent, oldPath, patchText]); + const expansionLineCount = + resolvedFile !== file && resolvedFile.isPartial === false + ? DEFAULT_DIFF_EXPANSION_LINE_COUNT + : undefined; + const containerRef = useRef(null); + const codeTheme = useResolvedCodeThemePair(); + const themeType = usePreferredTheme(); + const buildSelectionText = useCallback( + (range: SelectedLineRange) => + buildDiffLineSelectionText({ + displayStyle: view, + fileDiff: resolvedFile, + range, + }), + [resolvedFile, view], + ); + const buildFallbackSelectionText = useCallback( + ({ + containerElement, + }: { + containerElement: HTMLElement | null; + range: SelectedLineRange; + }) => + buildDiffDomSelectionText({ containerElement, fileDiff: resolvedFile }), + [resolvedFile], + ); + const lineSelectionActions = usePierreLineSelectionActions({ + buildFallbackSelectionText, + buildSelectionText, + containerRef, + enabled: onSelectionAddToChat !== undefined, + onSelectionAddToChat, + }); + const baseOptions = useMemo>( + () => ({ + diffStyle: view, + overflow, + disableLineNumbers: !showLineNumbers, + // The card's own header owns the file name, path actions, and stats. + disableFileHeader: true, + // Only set when the caller can actually supply full file contents: + // pierre renders an empty diff when it is handed an expansion budget + // for a hunk-only patch, which is what the timeline supplies. + ...(expansionLineCount === undefined ? {} : { expansionLineCount }), + themeType, + theme: codeTheme, + enableGutterUtility: onSelectionAddToChat !== undefined, + enableLineSelection: onSelectionAddToChat !== undefined, + lineHoverHighlight: + onSelectionAddToChat === undefined ? "disabled" : "number", + onGutterUtilityClick: + onSelectionAddToChat === undefined + ? undefined + : lineSelectionActions.onGutterUtilityClick, + onLineSelectionChange: lineSelectionActions.onLineSelectionChange, + onLineSelectionEnd: lineSelectionActions.onLineSelectionEnd, + onLineSelectionStart: lineSelectionActions.onLineSelectionStart, + }), + [ + codeTheme, + expansionLineCount, + lineSelectionActions.onGutterUtilityClick, + lineSelectionActions.onLineSelectionChange, + lineSelectionActions.onLineSelectionEnd, + lineSelectionActions.onLineSelectionStart, + onSelectionAddToChat, + overflow, + showLineNumbers, + themeType, + view, + ], + ); + const options = usePierreStrictModeRecoveryOptions(baseOptions); + // `DiffView` captures the worker pool when it creates its instance, so wait + // for the workspace to build the pool before the first render. Asking here + // rather than in the host keeps the pool unbuilt when a plugin replacement + // owns the render and never delegates. + const isWorkerPoolReady = useRequirePierreWorkerPool(); + if (!isWorkerPoolReady) { + return ; + } + return ( +
+
+ + + +
+ {lineSelectionActions.menu} +
+ ); +} + +export default BbDiff; diff --git a/apps/app/src/components/code/BbSourceCode.tsx b/apps/app/src/components/code/BbSourceCode.tsx new file mode 100644 index 0000000000..02fba38fc7 --- /dev/null +++ b/apps/app/src/components/code/BbSourceCode.tsx @@ -0,0 +1,624 @@ +import { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { File as PierreFile, VirtualizerContext } from "@pierre/diffs/react"; +import type { FileOptions } from "@pierre/diffs/react"; +import { + DIFFS_TAG_NAME, + Virtualizer as PierreVirtualizer, + type FileContents as PierreFileContents, + type SelectedLineRange, + type VirtualFileMetrics, +} from "@pierre/diffs"; +import { Button } from "@bb/shared-ui/button"; +import { Skeleton } from "@bb/shared-ui/skeleton"; +import { usePierreLineSelectionActions } from "@/components/git-diff/PierreLineSelectionActions.js"; +import { usePreferredTheme } from "@/hooks/useTheme"; +import { useResolvedCodeThemePair } from "@/lib/code-theme"; +import { PierreWorkerPoolBoundary } from "@/lib/pierre-worker-pool-boundary"; +import { + usePierreWorkerPool, + useRequirePierreWorkerPool, +} from "@/lib/pierre-worker-pool-gate"; +import { usePierreStrictModeRecoveryOptions } from "@/lib/pierre-strict-mode-recovery"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { + truncateSourceCode, + type SourceCodeTruncation, +} from "./source-code-budget"; +import type { BbSourceCodeProps } from "./code-rendering"; + +/** + * BB's default source renderer: the `@pierre/diffs` `File` view plus BB's line + * selection menu, resolved code theme, worker-pool gating, virtualized + * scrolling, the large-file rendering budget, and highlighted-line scrolling. + * + * Reached only through {@link import("./SourceCodeHost").SourceCodeHost}, and + * only lazily — a plugin that replaces the renderer and never delegates never + * downloads this module or builds the worker pool. + */ + +function BbSourceCodeSkeleton() { + return ( +
+ + + + + + +
+ ); +} + +interface SourceCodeWorkerPoolStats { + managerState: "waiting" | "initializing" | "initialized"; + workersFailed: boolean; + totalWorkers: number; + busyWorkers: number; + queuedTasks: number; + activeTasks: number; + themeSubscribers: number; + fileCacheSize: number; + diffCacheSize: number; +} + +const SOURCE_LINE_HEIGHT_PX = 18; +const SOURCE_GAP_BLOCK_PX = 16; + +const SOURCE_VIEW_STYLE = { + "--diffs-font-size": "12px", + "--diffs-line-height": `${SOURCE_LINE_HEIGHT_PX}px`, + // Pierre paints its theme bg inside this gap, so the top breathing room of + // the code body lives on Pierre's bg — not on the panel's bg-background. + // Without this, the gap above Pierre would show a visible bg-color seam. + "--diffs-gap-block": `${SOURCE_GAP_BLOCK_PX}px`, +} as CSSProperties; + +// Pierre's virtualizer estimates row positions from these before it measures +// them; they mirror the CSS variables above so the first layout guess is exact +// in `scroll` overflow mode (fixed-height rows) and close in `wrap` mode. +const SOURCE_VIRTUAL_FILE_METRICS: VirtualFileMetrics = { + hunkLineCount: 50, + lineHeight: SOURCE_LINE_HEIGHT_PX, + diffHeaderHeight: 0, + spacing: SOURCE_GAP_BLOCK_PX, +}; + +function getTargetRoots(container: HTMLElement): ParentNode[] { + const roots: ParentNode[] = [container]; + // Pierre owns its rendered line elements inside an open shadow root, which + // normal descendant queries on the React wrapper cannot cross. + for (const pierreContainer of container.querySelectorAll( + DIFFS_TAG_NAME, + )) { + if (pierreContainer.shadowRoot !== null) { + roots.push(pierreContainer.shadowRoot); + } + } + return roots; +} + +function clearTargetLine(container: HTMLElement) { + for (const root of getTargetRoots(container)) { + const targetLines = root.querySelectorAll( + "[data-bb-source-code-target-line]", + ); + for (const targetLine of targetLines) { + targetLine.removeAttribute("data-bb-source-code-target-line"); + targetLine.removeAttribute("data-selected-line"); + } + } +} + +function findTargetLine( + container: HTMLElement, + lineNumber: number, +): HTMLElement | null { + const roots = getTargetRoots(container); + for (const root of roots) { + const lines = root.querySelectorAll(`[data-line="${lineNumber}"]`); + for (const line of lines) { + if (line instanceof HTMLElement && line.dataset.lineIndex !== undefined) { + return line; + } + } + } + for (const root of roots) { + const lines = root.querySelectorAll(`[data-line="${lineNumber}"]`); + for (const line of lines) { + if (line instanceof HTMLElement) { + return line; + } + } + } + return null; +} + +function findVirtualizedViewport( + container: HTMLElement, +): HTMLElement | null { + return container.querySelector( + "[data-bb-source-code-viewport]", + ); +} + +/** + * Nudge the virtualized code viewport toward `lineNumber` when that row is not + * realized yet. With rendered rows in hand the distance is measured from the + * nearest one (rows are at least one line tall, so the step never overshoots + * in `wrap` mode); with none rendered the offset is estimated from the fixed + * line metrics. Each call moves at most to the estimate; the caller retries on + * the next frame once pierre has rendered the new window. + */ +function approachVirtualizedTargetLine( + container: HTMLElement, + lineNumber: number, +) { + const viewport = findVirtualizedViewport(container); + if (viewport === null) return; + const viewportRect = viewport.getBoundingClientRect(); + const centerOffset = viewportRect.height / 2; + const renderedBounds = getRenderedLineBounds(container); + if (renderedBounds === null) { + const estimatedTop = + SOURCE_GAP_BLOCK_PX + + (lineNumber - 1) * SOURCE_LINE_HEIGHT_PX; + viewport.scrollTop = Math.max(0, estimatedTop - centerOffset); + return; + } + const { firstLineNumber, firstTop, lastLineNumber, lastBottom } = + renderedBounds; + if (lineNumber > lastLineNumber) { + const distance = + lastBottom - + viewportRect.top + + (lineNumber - lastLineNumber - 1) * SOURCE_LINE_HEIGHT_PX; + viewport.scrollTop += Math.max(0, distance - centerOffset); + } else if (lineNumber < firstLineNumber) { + const distance = + viewportRect.top - + firstTop + + (firstLineNumber - lineNumber) * SOURCE_LINE_HEIGHT_PX; + viewport.scrollTop = Math.max( + 0, + viewport.scrollTop - distance - centerOffset, + ); + } +} + +interface RenderedPreviewLineBounds { + firstLineNumber: number; + firstTop: number; + lastLineNumber: number; + lastBottom: number; +} + +function getRenderedLineBounds( + container: HTMLElement, +): RenderedPreviewLineBounds | null { + let bounds: RenderedPreviewLineBounds | null = null; + for (const root of getTargetRoots(container)) { + for (const line of root.querySelectorAll( + "[data-line][data-line-index]", + )) { + const lineNumber = Number(line.dataset.line); + if (!Number.isFinite(lineNumber)) continue; + const rect = line.getBoundingClientRect(); + if (bounds === null) { + bounds = { + firstLineNumber: lineNumber, + firstTop: rect.top, + lastLineNumber: lineNumber, + lastBottom: rect.bottom, + }; + continue; + } + if (lineNumber < bounds.firstLineNumber) { + bounds.firstLineNumber = lineNumber; + bounds.firstTop = rect.top; + } + if (lineNumber > bounds.lastLineNumber) { + bounds.lastLineNumber = lineNumber; + bounds.lastBottom = rect.bottom; + } + } + } + return bounds; +} + +function scrollTargetLine(container: HTMLElement, line: HTMLElement) { + const viewport = findVirtualizedViewport(container); + if (viewport === null) return; + + const lineRect = line.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + const lineCenter = lineRect.top + lineRect.height / 2; + const viewportCenter = viewportRect.top + viewportRect.height / 2; + // Adjust only the vertical scroll offset. `scrollIntoView()` can also move + // the horizontal axis when a long source line extends beyond the viewport. + viewport.scrollTop += lineCenter - viewportCenter; +} + +function formatLineRange(startLineNumber: number, endLineNumber: number) { + return startLineNumber === endLineNumber + ? String(startLineNumber) + : `${startLineNumber}-${endLineNumber}`; +} + +function buildLineSelectionText({ + contents, + path, + range, +}: { + contents: string; + path: string; + range: SelectedLineRange; +}): string | null { + const startLineNumber = Math.max(1, Math.min(range.start, range.end)); + const endLineNumber = Math.max( + startLineNumber, + Math.max(range.start, range.end), + ); + const lines = contents.split(/\r\n|\n|\r/); + const selectedLines = lines.slice(startLineNumber - 1, endLineNumber); + if (selectedLines.length === 0) { + return null; + } + const selectedText = selectedLines.join("\n").trimEnd(); + if (selectedText.trim().length === 0) { + return null; + } + return `${path}:${formatLineRange(startLineNumber, endLineNumber)}\n${selectedText}`; +} + +function BbSourceCode({ + content, + path, + cacheKey, + overflow, + highlightedLines, + className, + scrollToHighlightedLines = false, + onSelectionAddToChat, +}: BbSourceCodeProps) { + const fileCacheKey = cacheKey ?? path; + const file = useMemo( + () => ({ name: path, contents: content, cacheKey: fileCacheKey }), + [content, fileCacheKey, path], + ); + const preferredTheme = usePreferredTheme(); + const codeTheme = useResolvedCodeThemePair(); + const containerRef = useRef(null); + // `PierreFile` captures the worker pool when it creates its instance, so + // wait for the workspace to build the pool before the first render. + const isWorkerPoolReady = useRequirePierreWorkerPool(); + const workerPool = usePierreWorkerPool(); + const lastWorkerPoolStatsKeyRef = useRef(null); + const [workerPoolStats, setWorkerPoolStats] = + useState(null); + const [, rerenderAfterWorkerPoolChange] = useState(0); + const fileIdentity = fileCacheKey; + const truncation = useMemo( + () => truncateSourceCode(content), + [content], + ); + // Which file the user asked to see in full. Keyed by identity rather than a + // boolean so opening a different large file goes back to the capped view + // without an effect resetting state. + const [fullFileRequestedFor, setFullFileRequestedFor] = useState< + string | null + >(null); + const buildSelectionText = useCallback( + (range: SelectedLineRange) => + buildLineSelectionText({ contents: content, path, range }), + [content, path], + ); + const lineSelectionActions = usePierreLineSelectionActions({ + buildSelectionText, + containerRef, + enabled: onSelectionAddToChat !== undefined, + onSelectionAddToChat, + }); + const baseOptions = useMemo>( + () => ({ + themeType: preferredTheme, + theme: codeTheme, + overflow, + disableFileHeader: true, + enableGutterUtility: onSelectionAddToChat !== undefined, + enableLineSelection: + highlightedLines !== null || onSelectionAddToChat !== undefined, + lineHoverHighlight: + onSelectionAddToChat === undefined ? "disabled" : "number", + onGutterUtilityClick: + onSelectionAddToChat === undefined + ? undefined + : lineSelectionActions.onGutterUtilityClick, + onLineSelectionChange: lineSelectionActions.onLineSelectionChange, + onLineSelectionEnd: lineSelectionActions.onLineSelectionEnd, + onLineSelectionStart: lineSelectionActions.onLineSelectionStart, + }), + [ + codeTheme, + highlightedLines, + overflow, + lineSelectionActions.onGutterUtilityClick, + lineSelectionActions.onLineSelectionChange, + lineSelectionActions.onLineSelectionEnd, + lineSelectionActions.onLineSelectionStart, + onSelectionAddToChat, + preferredTheme, + ], + ); + const options = usePierreStrictModeRecoveryOptions(baseOptions); + const selectedLines = useMemo(() => { + if (lineSelectionActions.selectedRange !== null) { + return lineSelectionActions.selectedRange; + } + return highlightedLines === null + ? null + : { start: highlightedLines.start, end: highlightedLines.end }; + }, [highlightedLines, lineSelectionActions.selectedRange]); + const targetLineNumber = scrollToHighlightedLines + ? (selectedLines?.start ?? null) + : null; + // A deep link past the capped prefix is an implicit request for the whole + // file: the target line has to exist in the DOM to be scrolled to. + const showsFullFile = + truncation === null || + fullFileRequestedFor === fileIdentity || + (targetLineNumber !== null && + targetLineNumber > truncation.renderedLineCount); + const renderedFile = useMemo(() => { + if (showsFullFile || truncation === null) { + return file; + } + return { + ...file, + // The worker highlight cache is keyed by `cacheKey`; the capped prefix + // must not collide with the full file's entry. + cacheKey: `${fileCacheKey}:head`, + contents: truncation.contents, + }; + }, [file, fileCacheKey, showsFullFile, truncation]); + // Pierre's virtualized file instance keeps the contents it was hydrated + // with (`VirtualizedFile.render` ignores a later `file`), so a content swap + // — the capped prefix giving way to the full file, or a refetch — needs a + // fresh mount. Callers that supply a `cacheKey` already fold the content + // hash into it. + const renderedFileMountKey = + showsFullFile || truncation === null + ? fileCacheKey + : `${fileCacheKey}:head`; + // "Load full file" remounts pierre with the whole file; carry the reader's + // scroll offset across so the prefix they were looking at stays put. + const pendingViewportScrollTopRef = useRef(null); + const handleLoadFullFile = () => { + const viewport = + containerRef.current === null + ? null + : findVirtualizedViewport(containerRef.current); + pendingViewportScrollTopRef.current = viewport?.scrollTop ?? null; + setFullFileRequestedFor(fileIdentity); + }; + useLayoutEffect(() => { + const scrollTop = pendingViewportScrollTopRef.current; + if (scrollTop === null) return; + pendingViewportScrollTopRef.current = null; + const viewport = + containerRef.current === null + ? null + : findVirtualizedViewport(containerRef.current); + if (viewport === null) return; + viewport.scrollTop = scrollTop; + // The virtualizer sizes the fresh instance on its next frame; reapply once + // that height exists so the offset is not clamped away. + const frame = window.requestAnimationFrame(() => { + viewport.scrollTop = scrollTop; + }); + return () => window.cancelAnimationFrame(frame); + }, [renderedFileMountKey]); + + useEffect(() => { + if (!workerPool) { + setWorkerPoolStats(null); + return; + } + + lastWorkerPoolStatsKeyRef.current = null; + return workerPool.subscribeToStatChanges((stats) => { + setWorkerPoolStats(stats); + const statsKey = [ + stats.managerState, + stats.workersFailed, + stats.busyWorkers, + stats.queuedTasks, + stats.activeTasks, + stats.fileCacheSize, + ].join(":"); + if (lastWorkerPoolStatsKeyRef.current === statsKey) { + return; + } + lastWorkerPoolStatsKeyRef.current = statsKey; + rerenderAfterWorkerPoolChange((version) => version + 1); + }); + }, [file.contents, file.name, workerPool]); + + const shouldWaitForWorkerPool = + workerPool !== undefined && + workerPoolStats?.managerState !== "initialized" && + workerPoolStats?.workersFailed !== true; + // Pierre can mount an empty zero-height
 while its worker highlighter is
+  // still initializing, so the code view waits for pool readiness. After that
+  // a single mount is enough: pierre paints the plain-text AST first and
+  // repaints in place when the worker delivers the highlighted one. That
+  // repaint swaps the line elements, so the target-line effect below re-runs
+  // when the highlight cache entry for this file appears.
+  const workerHighlightCacheState =
+    workerPool?.getFileResultCache(renderedFile) !== undefined
+      ? "highlighted"
+      : "plain";
+
+  useEffect(() => {
+    const cleanupContainer = containerRef.current;
+    let animationFrame: number | null = null;
+    let attempts = 0;
+
+    // Retry on the next frame (the target line may not be in the DOM yet). One
+    // rAF channel only: `scrollToLine` overwrites `animationFrame` on each
+    // reschedule, so at most one callback is ever pending and cleanup cancels
+    // it — no doubling or leaked stale callbacks marking the wrong line.
+    function scheduleRetry() {
+      animationFrame = window.requestAnimationFrame(scrollToLine);
+    }
+
+    function scrollToLine() {
+      const container = containerRef.current;
+      if (!container) return;
+      clearTargetLine(container);
+      if (targetLineNumber === null) return;
+
+      const line = findTargetLine(container, targetLineNumber);
+      if (line) {
+        line.setAttribute("data-bb-source-code-target-line", "");
+        line.setAttribute("data-selected-line", "single");
+        scrollTargetLine(container, line);
+        return;
+      }
+
+      // The virtualizer only realizes rows near the scroll window, so a
+      // target outside it is not in the DOM yet. Move the viewport toward the
+      // line's estimated offset and let pierre render that window before the
+      // next attempt.
+      approachVirtualizedTargetLine(container, targetLineNumber);
+      attempts += 1;
+      if (attempts < TARGET_LINE_MAX_ATTEMPTS) {
+        scheduleRetry();
+      }
+    }
+
+    scrollToLine();
+    return () => {
+      if (cleanupContainer) {
+        clearTargetLine(cleanupContainer);
+      }
+      if (animationFrame !== null) {
+        window.cancelAnimationFrame(animationFrame);
+      }
+    };
+  }, [
+    renderedFile.contents,
+    renderedFile.name,
+    shouldWaitForWorkerPool,
+    targetLineNumber,
+    workerHighlightCacheState,
+  ]);
+
+  if (shouldWaitForWorkerPool || !isWorkerPoolReady) {
+    return ;
+  }
+
+  return (
+    
+ + + + {truncation !== null && !showsFullFile ? ( + + ) : null} + + + {lineSelectionActions.menu} +
+ ); +} + +const TARGET_LINE_MAX_ATTEMPTS = 40; + +/** + * The code view's own scroll container, registered as pierre's virtualizer + * root so `PierreFile` mounts a `VirtualizedFile` that renders only the rows + * near the viewport. This mirrors `@pierre/diffs/react`'s ``, + * inlined so the scroller carries a ref and a data marker the target-line + * scrolling can find without walking the tree by class name. + */ +function SourceCodeViewport({ children }: { children: ReactNode }) { + const [virtualizer] = useState(() => + typeof window === "undefined" ? undefined : new PierreVirtualizer(), + ); + const viewportRef = useCallback( + (node: HTMLDivElement | null) => { + if (node !== null) { + virtualizer?.setup(node); + } else { + virtualizer?.cleanUp(); + } + }, + [virtualizer], + ); + return ( + +
+
{children}
+
+
+ ); +} + +function SourceCodeTruncationNotice({ + truncation, + onLoadFullFile, +}: { + truncation: SourceCodeTruncation; + onLoadFullFile: () => void; +}) { + return ( +
+ + Showing the first {truncation.renderedLineCount.toLocaleString()} of{" "} + {truncation.totalLineCount.toLocaleString()} lines. + + +
+ ); +} + +export default BbSourceCode; diff --git a/apps/app/src/components/code/DiffHost.test.tsx b/apps/app/src/components/code/DiffHost.test.tsx new file mode 100644 index 0000000000..640f9fe896 --- /dev/null +++ b/apps/app/src/components/code/DiffHost.test.tsx @@ -0,0 +1,380 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { createStore, Provider as JotaiProvider } from "jotai"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + ExperimentalDiffFullFileContents, + PluginDiffRendererProps, +} from "@get-bb/plugin-sdk"; +import { defaultResolvedCodeTheme } from "@bb/domain"; +import { applyResolvedCodeTheme } from "@/lib/code-theme"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount"; +import { parseGitDiffFiles } from "@/components/git-diff/git-diff-parsing"; +import { PluginDiff } from "@/components/plugin/PluginDiff"; +import { + BUILT_IN_REPLACEMENT_PROVIDER, + replacementProviderKey, +} from "@/lib/plugin-replacement-preference"; +import { diffRendererProviderAtom } from "./codeRendererProvider"; +import { DiffHost } from "./DiffHost"; + +/** + * Records whether BB's default renderer chunk was ever pulled. `vi.mock` + * factories run on first import of the specifier, and `DiffHost` only reaches + * `./BbDiff` through `lazy(() => import(...))`, so a flag set here is exactly + * "the default renderer chunk loaded". + */ +const bbDiff = vi.hoisted(() => ({ + loaded: false, + lastProps: null as Record | null, +})); + +vi.mock("./BbDiff", async () => { + const React = await import("react"); + bbDiff.loaded = true; + return { + default: (props: Record) => { + bbDiff.lastProps = props; + return React.createElement( + "div", + { "data-testid": "bb-diff" }, + `bb diff ${String(props.view)}/${String(props.overflow)}`, + ); + }, + }; +}); + +const PATCH = [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,3 +1,3 @@", + " const a = 1;", + "-const b = 2;", + "+const b = 3;", + " const c = 4;", + "", +].join("\n"); + +const FULL_FILE_CONTENTS = { + old: { + path: "src/app.ts", + content: [ + "const a = 1;", + "const b = 2;", + "const c = 4;", + "const oldTail = true;", + "", + ].join("\n"), + }, + new: { + path: "src/app.ts", + content: [ + "const a = 1;", + "const b = 3;", + "const c = 4;", + "const newTail = true;", + "", + ].join("\n"), + }, +} satisfies ExperimentalDiffFullFileContents; + +function parseFixture() { + const file = parseGitDiffFiles(PATCH)[0]; + if (file === undefined) throw new Error("fixture patch did not parse"); + return file; +} + +const receivedProps: PluginDiffRendererProps[] = []; + +function registerDiffRenderer( + component: (props: PluginDiffRendererProps) => React.ReactNode, +) { + setPluginSlotRegistrations("demo", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + diffRenderers: [{ id: "diffs", title: "Demo diffs", component }], + }); +} + +beforeEach(() => { + bbDiff.loaded = false; + bbDiff.lastProps = null; + receivedProps.length = 0; + resetPluginSlotStoreForTest(); + applyResolvedCodeTheme(defaultResolvedCodeTheme); +}); + +afterEach(() => { + cleanup(); + resetAllCrashedPluginSlotsForTest(); + resetPluginSlotStoreForTest(); + vi.restoreAllMocks(); +}); + +describe("DiffHost", () => { + it("skips BB's renderer and full-file enrichment when a replacement never delegates", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + + render( + , + ); + + expect(await screen.findByTestId("plugin-diff")).toBeDefined(); + // A microtask/frame is enough for a lazy() import to settle if one were + // requested; assert after letting the queue drain. + await act(async () => { + await Promise.resolve(); + }); + expect(bbDiff.loaded).toBe(false); + expect(receivedProps.at(-1)?.experimental_fullFileContents).toBe( + FULL_FILE_CONTENTS, + ); + }); + + it("hands the replacement resolved semantic props, not BB's host-only inputs", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + + render( + {}} + />, + ); + + await screen.findByTestId("plugin-diff"); + const props = receivedProps.at(-1); + expect(props?.patch).toBe(PATCH); + expect(props?.path).toBe("src/app.ts"); + expect(props?.view).toBe("split"); + expect(props?.overflow).toBe("wrap"); + expect(props?.showLineNumbers).toBe(false); + expect(props?.experimental_fullFileContents).toBe(FULL_FILE_CONTENTS); + expect(Object.keys(props ?? {})).not.toContain("onSelectionAddToChat"); + expect(Object.keys(props ?? {})).not.toContain("file"); + }); + + it("reconstructs a complete single-file patch when the caller has no patch text", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + + render(); + + await screen.findByTestId("plugin-diff"); + const patch = receivedProps.at(-1)?.patch ?? ""; + expect(patch).toContain("diff --git a/src/app.ts b/src/app.ts"); + expect(patch).toContain("--- a/src/app.ts"); + expect(patch).toContain("+++ b/src/app.ts"); + expect(patch).toContain("-const b = 2;"); + expect(patch).toContain("+const b = 3;"); + // The reconstruction must re-parse to the same rendered file, or a + // replacement would draw something the caller never asked for. + const reparsed = parseGitDiffFiles(patch)[0]; + expect(reparsed?.name).toBe("src/app.ts"); + expect(reparsed?.hunks).toHaveLength(1); + }); + + it("loads BB's renderer only when the replacement delegates", async () => { + registerDiffRenderer(({ path, experimental_Original: Original }) => + path.endsWith(".ts") ? :
plugin diff
, + ); + + render( + , + ); + + expect(await screen.findByTestId("bb-diff")).toBeDefined(); + expect(bbDiff.loaded).toBe(true); + // Delegation must reach BB's renderer with the host-only inputs intact. + expect(bbDiff.lastProps?.file).toBeDefined(); + }); + + it("honours a pin to BB's renderer without disabling the plugin", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + const store = createStore(); + store.set(diffRendererProviderAtom, BUILT_IN_REPLACEMENT_PROVIDER); + + render( + + + , + ); + + expect(await screen.findByTestId("bb-diff")).toBeDefined(); + expect(receivedProps).toHaveLength(0); + }); + + it("keeps a pinned provider selected once another plugin sorts ahead of it", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
first plugin
; + }); + setPluginSlotRegistrations("aardvark", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + diffRenderers: [ + { + id: "diffs", + title: "Aardvark diffs", + component: () =>
aardvark
, + }, + ], + }); + const store = createStore(); + // "aardvark" sorts before "demo", so automatic would switch the user's + // renderer out from under them; an explicit pin must not. + store.set( + diffRendererProviderAtom, + replacementProviderKey({ pluginId: "demo", id: "diffs" }), + ); + + render( + + + , + ); + + expect(await screen.findByTestId("plugin-diff")).toBeDefined(); + expect(screen.queryByTestId("aardvark-diff")).toBeNull(); + }); + + it("falls back to BB's renderer when the replacement crashes", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + registerDiffRenderer(() => { + throw new Error("replacement exploded"); + }); + + render( + , + ); + + expect(await screen.findByTestId("bb-diff")).toBeDefined(); + }); + + it("uses BB's renderer with resolved presentation defaults when nothing is registered", async () => { + render(); + + await screen.findByTestId("bb-diff"); + expect(bbDiff.lastProps?.view).toBe("unified"); + expect(bbDiff.lastProps?.overflow).toBe("scroll"); + expect(bbDiff.lastProps?.showLineNumbers).toBe(true); + }); +}); + +describe("experimental_Diff", () => { + it("shares the replacement with BB's own surfaces", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + + render(); + + await screen.findByTestId("plugin-diff"); + expect(receivedProps.at(-1)?.path).toBe("src/app.ts"); + expect(receivedProps.at(-1)?.experimental_fullFileContents).toBeNull(); + expect(bbDiff.loaded).toBe(false); + }); + + it("completes a header-less patch before handing it to a replacement", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + + // The shape GitHub's REST API returns: hunks with no `diff --git` header. + render( + , + ); + + await screen.findByTestId("plugin-diff"); + const patch = receivedProps.at(-1)?.patch ?? ""; + expect(patch.startsWith("diff --git a/src/app.ts b/src/app.ts\n")).toBe( + true, + ); + expect(patch).not.toContain("\r"); + }); + + it("defers complete-file enrichment to BB's lazy renderer", async () => { + render( + , + ); + + await screen.findByTestId("bb-diff"); + const file = bbDiff.lastProps?.file as ReturnType< + typeof parseFixture + > | null; + expect(file?.isPartial).toBe(true); + expect(bbDiff.lastProps?.patchText).toBe(PATCH); + expect(bbDiff.lastProps?.fullFileContents).toBe(FULL_FILE_CONTENTS); + expect(bbDiff.lastProps).not.toHaveProperty("expansionLineCount"); + }); + + it("degrades to plain text instead of an empty diff when the patch will not parse", () => { + render(); + + expect(screen.getByText("not a patch at all")).toBeDefined(); + expect(screen.queryByTestId("bb-diff")).toBeNull(); + expect(bbDiff.loaded).toBe(false); + }); +}); diff --git a/apps/app/src/components/code/DiffHost.tsx b/apps/app/src/components/code/DiffHost.tsx new file mode 100644 index 0000000000..4de868f29a --- /dev/null +++ b/apps/app/src/components/code/DiffHost.tsx @@ -0,0 +1,109 @@ +import { Suspense, lazy, useMemo, type ReactNode } from "react"; +import type { ExperimentalDiffFullFileContents } from "@get-bb/plugin-sdk"; +import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot"; +import type { ParsedGitDiffFile } from "@/components/git-diff/git-diff-parsing"; +import { buildFileDiffPatchText } from "@/components/git-diff/git-diff-patch-text"; +import { useDiffRendererReplacement } from "./codeRendererProvider"; +import { + DEFAULT_CODE_OVERFLOW, + DEFAULT_DIFF_VIEW, + type DiffPresentation, +} from "./code-rendering"; + +/** Shared by the mount and the host's crash check. */ +const DIFF_RENDERER_SLOT_KIND = "diffRenderer"; + +const BbDiff = lazy(() => import("./BbDiff")); + +interface DiffHostProps extends Partial { + /** + * The parsed diff to render. Callers parse it anyway for their own header, + * while the built-in renderer lazily enriches it if full contents are + * available and consistent with the patch. + */ + file: ParsedGitDiffFile; + /** + * The patch text `file` was parsed from, when the caller still has it. A + * plugin replacement is handed this verbatim; without it the host + * reconstructs an equivalent single-file patch from `file`. + */ + patchText?: string; + /** Resolved semantic context forwarded to renderer replacements. */ + fullFileContents: ExperimentalDiffFullFileContents | null; + className?: string; + /** Rendered while BB's renderer chunk loads. */ + fallback?: ReactNode; + onSelectionAddToChat?: (text: string) => void; +} + +/** + * The host boundary for diff rendering (plugin design: exclusive replacement + * surfaces). Every BB surface that draws a text diff — timeline file changes, + * the environment diff panel's file bodies — and every plugin that calls + * `experimental_Diff` renders through here, so one + * `experimental_diffRenderer` registration replaces them all at once. + * Resolved full-file text is semantic input: a replacement receives the plain + * text sides, while the built-in renderer validates and parses them only if it + * actually mounts. + * + * BB's own renderer sits behind `lazy()`. A plugin replacement that never + * delegates therefore never downloads it, and `experimental_Original` costs + * nothing until it is actually rendered. + */ +export function DiffHost({ + file, + patchText, + fullFileContents, + view = DEFAULT_DIFF_VIEW, + overflow = DEFAULT_CODE_OVERFLOW, + showLineNumbers = true, + className, + fallback = null, + onSelectionAddToChat, +}: DiffHostProps) { + const replacement = useDiffRendererReplacement(); + const isReplaced = replacement.kind === "plugin"; + // Only reconstructed when a replacement will actually read it: the walk is + // proportional to the rendered hunks, and BB's own renderer never needs it. + const semanticPatch = useMemo( + () => (isReplaced ? (patchText ?? buildFileDiffPatchText(file)) : ""), + [file, isReplaced, patchText], + ); + + const original = ( + + + + ); + + return ( + + {(slot, BoundOriginal) => ( +
+ +
+ )} +
+ ); +} diff --git a/apps/app/src/components/code/SourceCodeHost.test.tsx b/apps/app/src/components/code/SourceCodeHost.test.tsx new file mode 100644 index 0000000000..2a010a2213 --- /dev/null +++ b/apps/app/src/components/code/SourceCodeHost.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginSourceCodeRendererProps } from "@get-bb/plugin-sdk"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount"; +import { PluginSourceCode } from "@/components/plugin/PluginSourceCode"; +import { SourceCodeHost } from "./SourceCodeHost"; + +const bbSourceCode = vi.hoisted(() => ({ + loaded: false, + lastProps: null as Record | null, +})); + +vi.mock("./BbSourceCode", async () => { + const React = await import("react"); + bbSourceCode.loaded = true; + return { + default: (props: Record) => { + bbSourceCode.lastProps = props; + return React.createElement( + "div", + { "data-testid": "bb-source-code" }, + "bb source", + ); + }, + }; +}); + +const CONTENT = "const a = 1;\nconst b = 2;\n"; +const received: PluginSourceCodeRendererProps[] = []; + +function registerSourceCodeRenderer( + component: (props: PluginSourceCodeRendererProps) => React.ReactNode, +) { + setPluginSlotRegistrations("demo", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + sourceCodeRenderers: [{ id: "source", title: "Demo source", component }], + }); +} + +beforeEach(() => { + bbSourceCode.loaded = false; + bbSourceCode.lastProps = null; + received.length = 0; + resetPluginSlotStoreForTest(); +}); + +afterEach(() => { + cleanup(); + resetAllCrashedPluginSlotsForTest(); + resetPluginSlotStoreForTest(); + vi.restoreAllMocks(); +}); + +describe("SourceCodeHost", () => { + it("keeps BB's renderer chunk unloaded when a replacement never delegates", async () => { + registerSourceCodeRenderer((props) => { + received.push(props); + return
plugin source
; + }); + + render(); + + await screen.findByTestId("plugin-source"); + await act(async () => { + await Promise.resolve(); + }); + expect(bbSourceCode.loaded).toBe(false); + }); + + it("hands the replacement resolved semantic props, not BB's host-only inputs", async () => { + registerSourceCodeRenderer((props) => { + received.push(props); + return
plugin source
; + }); + + render( + {}} + />, + ); + + await screen.findByTestId("plugin-source"); + const props = received.at(-1); + expect(props?.content).toBe(CONTENT); + expect(props?.path).toBe("src/app.ts"); + expect(props?.overflow).toBe("wrap"); + expect(props?.highlightedLines).toEqual({ start: 2, end: 2 }); + expect(Object.keys(props ?? {})).not.toContain("cacheKey"); + expect(Object.keys(props ?? {})).not.toContain("onSelectionAddToChat"); + expect(Object.keys(props ?? {})).not.toContain("scrollToHighlightedLines"); + }); + + it("loads BB's renderer only when the replacement delegates", async () => { + registerSourceCodeRenderer(({ path, experimental_Original: Original }) => + path.endsWith(".md") ?
plugin source
: , + ); + + render( + , + ); + + expect(await screen.findByTestId("bb-source-code")).toBeDefined(); + expect(bbSourceCode.loaded).toBe(true); + // Delegation keeps the host-only inputs BB's own file preview depends on. + expect(bbSourceCode.lastProps?.cacheKey).toBe("rev-2:src/app.ts"); + expect(bbSourceCode.lastProps?.scrollToHighlightedLines).toBe(true); + }); + + it("falls back to BB's renderer when the replacement crashes", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + registerSourceCodeRenderer(() => { + throw new Error("replacement exploded"); + }); + + render(); + + expect(await screen.findByTestId("bb-source-code")).toBeDefined(); + }); + + it("resolves presentation defaults for BB's renderer", async () => { + render(); + + await screen.findByTestId("bb-source-code"); + expect(bbSourceCode.lastProps?.overflow).toBe("scroll"); + expect(bbSourceCode.lastProps?.highlightedLines).toBeNull(); + }); +}); + +describe("experimental_SourceCode", () => { + it("shares the replacement with BB's own surfaces", async () => { + registerSourceCodeRenderer((props) => { + received.push(props); + return
plugin source
; + }); + + render(); + + await screen.findByTestId("plugin-source"); + expect(received.at(-1)?.content).toBe(CONTENT); + expect(received.at(-1)?.highlightedLines).toBeNull(); + expect(bbSourceCode.loaded).toBe(false); + }); +}); diff --git a/apps/app/src/components/code/SourceCodeHost.tsx b/apps/app/src/components/code/SourceCodeHost.tsx new file mode 100644 index 0000000000..6d195ae697 --- /dev/null +++ b/apps/app/src/components/code/SourceCodeHost.tsx @@ -0,0 +1,80 @@ +import { Suspense, lazy, type ReactNode } from "react"; +import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot"; +import { useSourceCodeRendererReplacement } from "./codeRendererProvider"; +import { + DEFAULT_CODE_OVERFLOW, + type BbSourceCodeProps, +} from "./code-rendering"; + +/** Shared by the mount and the host's crash check. */ +const SOURCE_CODE_RENDERER_SLOT_KIND = "sourceCodeRenderer"; + +const BbSourceCode = lazy(() => import("./BbSourceCode")); + +interface SourceCodeHostProps extends Omit< + BbSourceCodeProps, + "overflow" | "highlightedLines" +> { + overflow?: BbSourceCodeProps["overflow"]; + highlightedLines?: BbSourceCodeProps["highlightedLines"]; + /** Rendered while BB's renderer chunk loads. */ + fallback?: ReactNode; +} + +/** + * The host boundary for source rendering (plugin design: exclusive replacement + * surfaces). BB's native file preview and every plugin that calls + * `experimental_SourceCode` render through here, so one + * `experimental_sourceCodeRenderer` registration replaces them all at once. + * + * BB's own renderer sits behind `lazy()`; a replacement that never delegates + * never downloads it. + */ +export function SourceCodeHost({ + content, + path, + cacheKey, + overflow = DEFAULT_CODE_OVERFLOW, + highlightedLines = null, + className, + fallback = null, + scrollToHighlightedLines, + onSelectionAddToChat, +}: SourceCodeHostProps) { + const replacement = useSourceCodeRendererReplacement(); + + const original = ( + + + + ); + + return ( + + {(slot, BoundOriginal) => ( +
+ +
+ )} +
+ ); +} diff --git a/apps/app/src/components/code/code-rendering.ts b/apps/app/src/components/code/code-rendering.ts new file mode 100644 index 0000000000..532b278871 --- /dev/null +++ b/apps/app/src/components/code/code-rendering.ts @@ -0,0 +1,71 @@ +import type { + CodeOverflowMode, + DiffViewMode, + ExperimentalDiffFullFileContents, + SourceCodeLineRange, +} from "@get-bb/plugin-sdk"; +import type { ParsedGitDiffFile } from "@/components/git-diff/git-diff-parsing"; + +/** + * Internal contracts for the two host-owned code renderers. + * + * The host boundary splits every render into two halves. The *semantic* half + * (`SourceCodePresentation` / `DiffPresentation` plus the content) is what a + * plugin replacement receives — fully resolved, with no BB implementation + * types in it. The *host-only* half (pre-parsed diff files, selection-to-chat, + * layout classes) never leaves BB, so replacing a renderer can never make a + * plugin responsible for BB product behavior it cannot implement. + * + * This module is types plus two literals: importing it must never pull the + * renderer graph (`@pierre/diffs` and Shiki behind it) onto a caller's chunk. + */ + +export const DEFAULT_CODE_OVERFLOW: CodeOverflowMode = "scroll"; +export const DEFAULT_DIFF_VIEW: DiffViewMode = "unified"; + +/** Presentation the host resolved for one source render. */ +interface SourceCodePresentation { + overflow: CodeOverflowMode; + highlightedLines: SourceCodeLineRange | null; +} + +/** Presentation the host resolved for one diff render. */ +export interface DiffPresentation { + view: DiffViewMode; + overflow: CodeOverflowMode; + showLineNumbers: boolean; +} + +/** Props BB's default source renderer receives from {@link SourceCodeHost}. */ +export interface BbSourceCodeProps extends SourceCodePresentation { + content: string; + path: string; + /** + * Stable identity for the highlighter's result cache. Defaults to `path`; + * callers that re-render the same path with different bytes (a file reloaded + * at a new revision) pass their own. + */ + cacheKey?: string; + className?: string; + /** + * Scroll the first highlighted line into view once it renders. The file + * preview wants it for `?L12` deep links; an inline snippet does not. + */ + scrollToHighlightedLines?: boolean; + onSelectionAddToChat?: (text: string) => void; +} + +/** Props BB's default diff renderer receives from {@link DiffHost}. */ +export interface BbDiffProps extends DiffPresentation { + /** + * The raw parsed diff to draw. The built-in renderer enriches it lazily when + * complete file contents agree with the patch. + */ + file: ParsedGitDiffFile; + /** Original patch text, when the caller still has it. */ + patchText?: string; + /** Caller-resolved full text sides, or null when context is unavailable. */ + fullFileContents: ExperimentalDiffFullFileContents | null; + className?: string; + onSelectionAddToChat?: (text: string) => void; +} diff --git a/apps/app/src/components/code/codeRendererProvider.ts b/apps/app/src/components/code/codeRendererProvider.ts new file mode 100644 index 0000000000..1a01fa6497 --- /dev/null +++ b/apps/app/src/components/code/codeRendererProvider.ts @@ -0,0 +1,42 @@ +import { useAtomValue } from "jotai"; +import { + createReplacementPreferenceAtom, + resolvePreferredReplacement, +} from "@/lib/plugin-replacement-preference"; +import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; +import { + usePluginSlots, + type PluginDiffRendererSlot, + type PluginSourceCodeRendererSlot, +} from "@/lib/plugin-slots"; + +const SOURCE_CODE_RENDERER_STORAGE_KEY = "bb.appearance.sourceCodeRenderer"; +const DIFF_RENDERER_STORAGE_KEY = "bb.appearance.diffRenderer"; + +/** + * Automatic by default, with an explicit per-client override in Appearance — + * the same pin the sidebar thread list offers. A renderer replaces a surface + * the user cannot otherwise get back without disabling the whole plugin, so + * the pin is what keeps "installing activates it" reversible. + */ +export const sourceCodeRendererProviderAtom = createReplacementPreferenceAtom( + SOURCE_CODE_RENDERER_STORAGE_KEY, +); + +export const diffRendererProviderAtom = createReplacementPreferenceAtom( + DIFF_RENDERER_STORAGE_KEY, +); + +/** The active source renderer, or the owner when none applies. */ +export function useSourceCodeRendererReplacement(): ResolvedReplacement { + const { sourceCodeRenderers } = usePluginSlots(); + const preference = useAtomValue(sourceCodeRendererProviderAtom); + return resolvePreferredReplacement(sourceCodeRenderers, preference); +} + +/** The active diff renderer, or the owner when none applies. */ +export function useDiffRendererReplacement(): ResolvedReplacement { + const { diffRenderers } = usePluginSlots(); + const preference = useAtomValue(diffRendererProviderAtom); + return resolvePreferredReplacement(diffRenderers, preference); +} diff --git a/apps/app/src/components/code/source-code-budget.ts b/apps/app/src/components/code/source-code-budget.ts new file mode 100644 index 0000000000..57d122fa64 --- /dev/null +++ b/apps/app/src/components/code/source-code-budget.ts @@ -0,0 +1,78 @@ +/** + * Rendering budget for BB's source renderer. + * + * Tokenizing and laying out a 20k-line file is what stalls iOS Safari, so the + * renderer paints a leading prefix until the reader asks for the whole file. + * The rule lives here, apart from the renderer itself, because it is pure and + * the file preview's tests assert it directly — importing it must never pull + * the `@pierre/diffs` chunk. + */ + +export const SOURCE_CODE_MAX_LINES = 5_000; +const SOURCE_CODE_MAX_CHARS = 512 * 1024; + +export interface SourceCodeTruncation { + /** The rendered prefix, cut at a line boundary. */ + contents: string; + renderedLineCount: number; + totalLineCount: number; +} + +// FNV-1a over the contents, prefixed with the length; used to fold file +// contents into a highlight cache key. +export function hashSourceContents(contents: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < contents.length; index += 1) { + hash ^= contents.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `${contents.length}:${(hash >>> 0).toString(36)}`; +} + +function countLines(contents: string): number { + if (contents.length === 0) return 0; + let count = 1; + for (let index = contents.indexOf("\n"); index !== -1; ) { + count += 1; + index = contents.indexOf("\n", index + 1); + } + return contents.endsWith("\n") ? count - 1 : count; +} + +/** + * Decide whether a source render exceeds {@link SOURCE_CODE_MAX_LINES} or + * {@link SOURCE_CODE_MAX_CHARS} and, if so, return the leading prefix + * that fits both budgets. Returns `null` when the whole file fits. + */ +export function truncateSourceCode( + contents: string, +): SourceCodeTruncation | null { + const totalLineCount = countLines(contents); + if ( + contents.length <= SOURCE_CODE_MAX_CHARS && + totalLineCount <= SOURCE_CODE_MAX_LINES + ) { + return null; + } + let renderedLineCount = 0; + let cutIndex = 0; + for ( + let lineStart = 0; + lineStart < contents.length && + renderedLineCount < SOURCE_CODE_MAX_LINES; + ) { + const newlineIndex = contents.indexOf("\n", lineStart); + const lineEnd = newlineIndex === -1 ? contents.length : newlineIndex; + if (lineEnd > SOURCE_CODE_MAX_CHARS && renderedLineCount > 0) { + break; + } + renderedLineCount += 1; + cutIndex = lineEnd; + lineStart = lineEnd + 1; + } + return { + contents: contents.slice(0, cutIndex), + renderedLineCount, + totalLineCount, + }; +} diff --git a/apps/app/src/components/commands/AppCommandProvider.availability.test.tsx b/apps/app/src/components/commands/AppCommandProvider.availability.test.tsx new file mode 100644 index 0000000000..41a2c7452f --- /dev/null +++ b/apps/app/src/components/commands/AppCommandProvider.availability.test.tsx @@ -0,0 +1,216 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { useState, type ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + defaultAppSettings, + type AppCommandContextKey, + type AppCommandId, + type AppDefaultKeybinding, +} from "@bb/domain"; +import { + AppCommandProvider, + useAppCommandContext, + useAppCommandHandler, + useAppCommandRunner, +} from "./AppCommandProvider"; + +const MOD_P = { + key: "p", + mod: true, + meta: false, + control: false, + alt: false, + shift: false, +}; + +function defaultBinding( + command: AppCommandId, + options: { + all?: readonly AppCommandContextKey[]; + desktopOnly?: boolean; + none?: readonly AppCommandContextKey[]; + unassigned?: boolean; + } = {}, +): AppDefaultKeybinding { + return { + command, + desktopOnly: options.desktopOnly ?? false, + shortcut: options.unassigned === true ? null : MOD_P, + when: { + all: [...(options.all ?? ["mainSurface"])], + none: [...(options.none ?? [])], + }, + }; +} + +const testState = vi.hoisted(() => ({ + isDesktop: false, +})); + +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ + data: { + generalSettings: { + ...defaultAppSettings, + showKeyboardHints: false, + }, + // Empty on purpose: availability must not depend on these. + keybindings: [], + defaultKeybindings: [ + defaultBinding("thread.new", { none: ["modalOpen"] }), + defaultBinding("thread.rename", { unassigned: true }), + defaultBinding("pane.close", { all: ["mainSurface", "splitActive"] }), + defaultBinding("window.new", { desktopOnly: true }), + defaultBinding("diff.toggle", { + none: ["modalOpen", "editableFocus", "terminalFocus"], + }), + ], + }, + }), +})); + +vi.mock("@/lib/bb-desktop", () => ({ + getBbDesktopInfo: () => (testState.isDesktop ? {} : null), +})); + +function Handler({ command }: { command: AppCommandId }) { + useAppCommandHandler(command, () => true); + return null; +} + +function SplitContext() { + useAppCommandContext("splitActive", true); + return null; +} + +// Asks on click, as the palette does: reading during render would run before +// sibling handlers have registered. +function Availability({ + command, + target = null, +}: { + command: AppCommandId; + target?: EventTarget | null; +}) { + const runner = useAppCommandRunner(); + const [answer, setAnswer] = useState("unasked"); + return ( + + ); +} + +function renderProvider(children: ReactNode) { + return render( + + {children} + , + ); +} + +function availabilityOf(command: AppCommandId): string | null { + const probe = screen.getByTestId(`available-${command}`); + fireEvent.click(probe); + return probe.textContent; +} + +afterEach(() => { + cleanup(); + testState.isDesktop = false; +}); + +describe("isCommandAvailable", () => { + it("is false while no component handles the command", () => { + renderProvider(); + expect(availabilityOf("thread.new")).toBe("no"); + }); + + it("is true once a handler is mounted and the preconditions hold", () => { + renderProvider( + <> + + + , + ); + expect(availabilityOf("thread.new")).toBe("yes"); + }); + + it("is false while an `all` precondition is unmet", () => { + renderProvider( + <> + + + , + ); + expect(availabilityOf("pane.close")).toBe("no"); + }); + + it("is true once the `all` precondition's context registers", () => { + renderProvider( + <> + + + + , + ); + expect(availabilityOf("pane.close")).toBe("yes"); + }); + + it("ignores `none` guards, which exist to stop chords stealing keystrokes", () => { + // The palette is itself a modal with a focused input. + renderProvider( + <> +
+ + + + , + ); + expect(availabilityOf("diff.toggle")).toBe("yes"); + }); + + it("is true for a command the user left unbound", () => { + // Ships with a null shortcut, so it is absent from the merged bindings. + renderProvider( + <> + + + , + ); + expect(availabilityOf("thread.rename")).toBe("yes"); + }); + + it("is false on the web for a desktop-only command", () => { + renderProvider( + <> + + + , + ); + expect(availabilityOf("window.new")).toBe("no"); + }); + + it("is true on the desktop for that same command", () => { + testState.isDesktop = true; + renderProvider( + <> + + + , + ); + expect(availabilityOf("window.new")).toBe("yes"); + }); +}); diff --git a/apps/app/src/components/commands/AppCommandProvider.test.tsx b/apps/app/src/components/commands/AppCommandProvider.test.tsx index 2f7d454fda..214d28b920 100644 --- a/apps/app/src/components/commands/AppCommandProvider.test.tsx +++ b/apps/app/src/components/commands/AppCommandProvider.test.tsx @@ -216,6 +216,7 @@ vi.mock("@/lib/bb-desktop", () => ({ interface HandlerProps { command?: AppCommandId; + enabled?: boolean; name: string; priority?: number; result: boolean; @@ -223,6 +224,7 @@ interface HandlerProps { function Handler({ command = "thread.search", + enabled, name, priority, result, @@ -234,6 +236,7 @@ function Handler({ return result; }, priority, + enabled, ); return null; } @@ -525,6 +528,13 @@ describe("AppCommandProvider", () => { expect(testState.calls).toEqual([]); }); + it("does not register a disabled handler", () => { + renderProvider(); + + expect(dispatchShortcut().defaultPrevented).toBe(false); + expect(testState.calls).toEqual([]); + }); + it("lets equal-priority handlers fall through to the focus-owning instance", () => { renderProvider( <> diff --git a/apps/app/src/components/commands/AppCommandProvider.tsx b/apps/app/src/components/commands/AppCommandProvider.tsx index 9f8441c06a..46b6cde655 100644 --- a/apps/app/src/components/commands/AppCommandProvider.tsx +++ b/apps/app/src/components/commands/AppCommandProvider.tsx @@ -17,6 +17,7 @@ import { type AppCommandContext, type AppCommandContextKey, type AppCommandId, + type AppDefaultKeybindings, type AppKeybindings, type AppShortcut, } from "@bb/domain"; @@ -30,11 +31,11 @@ import { type AppShortcutPresentation, } from "@/lib/app-keybindings"; -export interface AppCommandInvocation { +interface AppCommandInvocation { target: EventTarget | null; } -export type AppCommandHandler = (invocation: AppCommandInvocation) => boolean; +type AppCommandHandler = (invocation: AppCommandInvocation) => boolean; interface AppCommandHandlerRegistration { handler: AppCommandHandler; @@ -46,6 +47,10 @@ interface AppCommandProviderValue { dispatch: (command: AppCommandId, target: EventTarget | null) => boolean; getShortcut: (command: AppCommandId) => AppShortcut | null; handleKeyboardEvent: (event: KeyboardEvent) => boolean; + isCommandAvailable: ( + command: AppCommandId, + target: EventTarget | null, + ) => boolean; registerContext: ( key: AppCommandContextKey, source: symbol, @@ -63,6 +68,7 @@ const AppCommandContextValue = createContext( const AppCommandModifierHeldContext = createContext(false); const EMPTY_KEYBINDINGS: AppKeybindings = []; +const EMPTY_DEFAULT_KEYBINDINGS: AppDefaultKeybindings = []; const SHORTCUT_HINT_HOLD_DELAY_MS = 700; const EMPTY_CONTEXT: AppCommandContext = { @@ -102,6 +108,10 @@ function hasOpenModal(): boolean { export function AppCommandProvider({ children }: { children: ReactNode }) { const systemConfig = useSystemConfig(); const keybindings = systemConfig.data?.keybindings ?? EMPTY_KEYBINDINGS; + // Merged bindings drop unassigned commands; the defaults keep an entry for + // every command, so availability reads `when` from them. + const defaultKeybindings = + systemConfig.data?.defaultKeybindings ?? EMPTY_DEFAULT_KEYBINDINGS; const showKeyboardHints = systemConfig.data?.generalSettings?.showKeyboardHints ?? defaultAppSettings.showKeyboardHints; @@ -264,6 +274,33 @@ export function AppCommandProvider({ children }: { children: ReactNode }) { [isDesktop], ); + /** + * Whether running `command` right now would do something, so the quick + * palette can drop irrelevant rows like "Close focused chat pane" with no + * split open. Only the `all` side of `when` is checked: `none` keys guard + * against chords stealing keystrokes, and the palette is itself a modal with + * a focused input. + */ + const isCommandAvailable = useCallback( + (command: AppCommandId, target: EventTarget | null): boolean => { + const registrations = handlersRef.current.get(command); + if (registrations === undefined || registrations.size === 0) return false; + const isMac = isMacKeyboardPlatform(browserPlatform()); + const applicable = defaultKeybindings.filter( + (binding) => + binding.command === command && + isAppKeybindingAvailableForClient(binding, { isDesktop, isMac }), + ); + // Desktop-only on this client, or unbound entirely. + if (applicable.length === 0) return false; + const context = currentContext(target); + return applicable.some((binding) => + binding.when.all.every((key) => context[key]), + ); + }, + [currentContext, defaultKeybindings, isDesktop], + ); + const getShortcut = useCallback( (command: AppCommandId): AppShortcut | null => { const isMac = isMacKeyboardPlatform(browserPlatform()); @@ -347,6 +384,7 @@ export function AppCommandProvider({ children }: { children: ReactNode }) { dispatch, getShortcut, handleKeyboardEvent, + isCommandAvailable, registerContext, registerHandler, }), @@ -354,6 +392,7 @@ export function AppCommandProvider({ children }: { children: ReactNode }) { dispatch, getShortcut, handleKeyboardEvent, + isCommandAvailable, registerContext, registerHandler, ], @@ -374,6 +413,7 @@ export function useAppCommandHandler( command: AppCommandId, handler: AppCommandHandler, priority = 0, + enabled = true, ): void { const registerHandler = useContext(AppCommandContextValue)?.registerHandler; const handlerRef = useRef(handler); @@ -381,18 +421,19 @@ export function useAppCommandHandler( handlerRef.current = handler; }, [handler]); useEffect(() => { - if (!registerHandler) return; + if (!registerHandler || !enabled) return; return registerHandler(command, { handler: (invocation) => handlerRef.current(invocation), priority, }); - }, [command, priority, registerHandler]); + }, [command, enabled, priority, registerHandler]); } export function useIndexedAppCommandHandlers( commands: readonly AppCommandId[], handler: (index: number, invocation: AppCommandInvocation) => boolean, priority = 0, + enabled = true, ): void { const registerHandler = useContext(AppCommandContextValue)?.registerHandler; const handlerRef = useRef(handler); @@ -400,7 +441,7 @@ export function useIndexedAppCommandHandlers( handlerRef.current = handler; }, [handler]); useEffect(() => { - if (!registerHandler) return; + if (!registerHandler || !enabled) return; const unregister = commands.map((command, index) => registerHandler(command, { handler: (invocation) => handlerRef.current(index, invocation), @@ -410,7 +451,7 @@ export function useIndexedAppCommandHandlers( return () => { unregister.forEach((dispose) => dispose()); }; - }, [commands, priority, registerHandler]); + }, [commands, enabled, priority, registerHandler]); } /** @@ -429,6 +470,28 @@ export function useAppCommandKeyDispatch(): (event: KeyboardEvent) => boolean { ); } +export interface AppCommandRunner { + /** Run a command as if its chord had been pressed with `target` focused. */ + dispatch: (command: AppCommandId, target: EventTarget | null) => boolean; + isCommandAvailable: ( + command: AppCommandId, + target: EventTarget | null, + ) => boolean; +} + +/** Run commands without owning a keybinding, for the quick palette. */ +export function useAppCommandRunner(): AppCommandRunner { + const value = useContext(AppCommandContextValue); + return useMemo( + () => ({ + dispatch: (command, target) => value?.dispatch(command, target) ?? false, + isCommandAvailable: (command, target) => + value?.isCommandAvailable(command, target) ?? false, + }), + [value], + ); +} + export function useAppCommandContext( key: AppCommandContextKey, active: boolean, diff --git a/apps/app/src/components/commands/AppCommandShortcutHint.tsx b/apps/app/src/components/commands/AppCommandShortcutHint.tsx index 6195cb72fe..e82b45a84e 100644 --- a/apps/app/src/components/commands/AppCommandShortcutHint.tsx +++ b/apps/app/src/components/commands/AppCommandShortcutHint.tsx @@ -13,7 +13,7 @@ interface AppCommandShortcutPillProps { className?: string; } -export const APP_COMMAND_SHORTCUT_HINT_CLASS = +const APP_COMMAND_SHORTCUT_HINT_CLASS = "pointer-events-none inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm bg-state-hover px-1.5 py-1 font-sans text-xs font-normal leading-none tabular-nums text-subtle-foreground opacity-60"; export function AppCommandShortcutPill({ diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx new file mode 100644 index 0000000000..3d88794e7a --- /dev/null +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -0,0 +1,324 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + defaultAppSettings, + type AppCommandId, + type AppDefaultKeybinding, + type AppKeybinding, +} from "@bb/domain"; +import { AppCommandProvider, useAppCommandHandler } from "./AppCommandProvider"; +import { + removePluginSlotRegistrations, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { CommandPalette } from "./CommandPalette"; + +const PALETTE_SHORTCUT = { + key: "p", + mod: true, + meta: false, + control: false, + alt: false, + shift: true, +}; + +const MAIN_SURFACE = { all: ["mainSurface" as const], none: [] }; + +const PALETTE_BINDING: AppKeybinding = { + command: "palette.open", + desktopOnly: false, + shortcut: PALETTE_SHORTCUT, + when: { all: ["mainSurface"], none: ["modalOpen"] }, +}; + +// A chord that declines while any modal is open, like most app bindings. +const THREAD_NEW_BINDING: AppKeybinding = { + command: "thread.new", + desktopOnly: false, + shortcut: { + key: "o", + mod: true, + meta: false, + control: false, + alt: false, + shift: true, + }, + when: { all: ["mainSurface"], none: ["modalOpen"] }, +}; + +function defaults(...commands: AppCommandId[]): AppDefaultKeybinding[] { + return commands.map((command) => ({ + command, + desktopOnly: false, + shortcut: null, + when: MAIN_SURFACE, + })); +} + +const testState = vi.hoisted(() => ({ calls: [] as string[] })); + +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ + data: { + generalSettings: { + ...defaultAppSettings, + showKeyboardHints: false, + }, + keybindings: [PALETTE_BINDING, THREAD_NEW_BINDING], + defaultKeybindings: [ + PALETTE_BINDING, + ...defaults( + "thread.new", + "thread.next", + "panel.toggle", + "terminal.open", + ), + ], + }, + }), +})); + +vi.mock("@/lib/bb-desktop", () => ({ + getBbDesktopInfo: () => null, +})); + +function Handler({ command }: { command: AppCommandId }) { + useAppCommandHandler(command, () => { + testState.calls.push(command); + return true; + }); + return null; +} + +function renderPalette() { + const result = render( + + + + + + + + + + , + ); + screen.getByTestId("origin").focus(); + return result; +} + +function openPalette(): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key: "p", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + (document.activeElement ?? window).dispatchEvent(event); + return event; +} + +const searchField = () => screen.getByRole("combobox"); +const optionTitles = () => + screen.getAllByRole("option").map((option) => option.textContent); +const selectedOption = () => + screen + .getAllByRole("option") + .find((option) => option.getAttribute("aria-selected") === "true"); + +afterEach(() => { + cleanup(); + removePluginSlotRegistrations("linear"); + testState.calls.length = 0; + window.localStorage.clear(); +}); + +describe("CommandPalette", () => { + it("opens on its chord and lists the commands that apply", async () => { + renderPalette(); + const event = openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + // Chrome maps Mod+Shift+P to print; only preventDefault stops it. + expect(event.defaultPrevented).toBe(true); + const titles = optionTitles(); + expect(titles?.[0]).toContain("New thread"); + // Every mounted handler is listed; nothing else is. + expect(titles).toHaveLength(4); + }); + + it("filters as the user types and keeps the selection on a live row", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + fireEvent.change(searchField(), { target: { value: "terminal" } }); + + await waitFor(() => expect(optionTitles()).toHaveLength(1)); + expect(selectedOption()?.textContent).toContain("Open terminal"); + }); + + it("wraps at both ends of the list", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + fireEvent.keyDown(searchField(), { key: "ArrowUp" }); + expect(selectedOption()?.textContent).toContain("Open terminal"); + + fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + expect(selectedOption()?.textContent).toContain("New thread"); + }); + + it("runs the highlighted command, closes, and restores focus", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + fireEvent.change(searchField(), { target: { value: "toggle panel" } }); + await waitFor(() => + expect(selectedOption()?.textContent).toContain("Toggle panel"), + ); + fireEvent.keyDown(searchField(), { key: "Enter" }); + + await waitFor(() => expect(testState.calls).toEqual(["panel.toggle"])); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(document.activeElement).toBe(screen.getByTestId("origin")); + }); + + it("offers the last command run first the next time it opens", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + fireEvent.change(searchField(), { target: { value: "toggle panel" } }); + await waitFor(() => + expect(selectedOption()?.textContent).toContain("Toggle panel"), + ); + fireEvent.keyDown(searchField(), { key: "Enter" }); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + expect(optionTitles()?.[0]).toContain("Toggle panel"); + }); + + it("closes on Escape without running anything", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + fireEvent.keyDown(searchField(), { key: "Escape" }); + + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + expect(testState.calls).toEqual([]); + }); + + it("suppresses app chords while open and releases them on close", async () => { + // The palette is an open modal, so `none: ["modalOpen"]` bindings must + // decline rather than fire under the search field. + renderPalette(); + const pressThreadNew = () => + fireEvent.keyDown(document.activeElement ?? window, { + key: "o", + ctrlKey: true, + shiftKey: true, + bubbles: true, + }); + + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + pressThreadNew(); + expect(testState.calls).toEqual([]); + + fireEvent.keyDown(searchField(), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + screen.getByTestId("origin").focus(); + pressThreadNew(); + await waitFor(() => expect(testState.calls).toEqual(["thread.new"])); + }); + + it("scrolls the highlighted row into view when arrowing, but not on hover", async () => { + // Focus stays in the search field, so nothing scrolls the list on its own. + const scrollIntoView = vi.spyOn( + Element.prototype, + "scrollIntoView", + ) as unknown as ReturnType; + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + scrollIntoView.mockClear(); + + fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1)); + expect(scrollIntoView.mock.instances[0]).toBe(selectedOption()); + expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" }); + + fireEvent.keyDown(searchField(), { key: "End" }); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(2)); + + // Hovering must not yank the list out from under the pointer. + scrollIntoView.mockClear(); + fireEvent.pointerMove(screen.getAllByRole("option")[0] as HTMLElement); + expect(scrollIntoView).not.toHaveBeenCalled(); + + scrollIntoView.mockRestore(); + }); + + it("lists a plugin's commandPaletteAction and runs it", async () => { + setPluginSlotRegistrations("linear", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + commandPaletteActions: [ + { + id: "open-issue", + title: "Linear: open issue", + run: () => { + testState.calls.push("plugin-ran"); + }, + }, + ], + }); + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + fireEvent.change(searchField(), { target: { value: "linear" } }); + await waitFor(() => expect(optionTitles()).toHaveLength(1)); + expect(optionTitles()?.[0]).toContain("Linear: open issue"); + fireEvent.keyDown(searchField(), { key: "Enter" }); + + await waitFor(() => expect(testState.calls).toEqual(["plugin-ran"])); + }); + + it("says so when nothing matches", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + fireEvent.change(searchField(), { target: { value: "zzzzz" } }); + + await waitFor(() => + expect(screen.getByText("No matching commands")).toBeTruthy(), + ); + fireEvent.keyDown(searchField(), { key: "Enter" }); + expect(testState.calls).toEqual([]); + }); +}); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx new file mode 100644 index 0000000000..3db58738c2 --- /dev/null +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -0,0 +1,318 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; +import { Dialog, DialogContent, DialogTitle } from "@bb/shared-ui/dialog"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { LAUNCHER_ACTION_ROW_BASE_CLASS } from "@/components/secondary-panel/launcherRow"; +import { + useAppCommandHandler, + useAppCommandRunner, + useAppCommandShortcuts, +} from "./AppCommandProvider"; +import { AppCommandShortcutPill } from "./AppCommandShortcutHint"; +import type { PaletteAction } from "@/lib/command-palette/palette-action"; +import { + buildAppCommandActions, + PALETTE_COMMAND_IDS, +} from "@/lib/command-palette/palette-app-commands"; +import { + rankPaletteActions, + type RankedPaletteAction, +} from "@/lib/command-palette/palette-ranking"; +import { + readPaletteRecents, + recordPaletteRecent, +} from "@/lib/command-palette/palette-recents"; +import { buildPluginPaletteActions } from "@/lib/command-palette/palette-plugin-actions"; +import { getPluginSlotSnapshot } from "@/lib/plugin-slots"; +import { getActiveThreadPanelOpener } from "@/components/plugin/plugin-thread-panel-navigation"; + +const PALETTE_PLACEHOLDER = "Search commands"; + +export interface CommandPaletteProps { + /** The surface's thread and project, handed to plugin rows. */ + threadId: string | null; + projectId: string | null; +} + +/** + * Type to filter the commands that apply right now, then run one with Enter. + * Mounted once by `AppLayout` and opened by `palette.open`. + */ +export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { + const runner = useAppCommandRunner(); + const shortcuts = useAppCommandShortcuts(PALETTE_COMMAND_IDS); + const listId = useId(); + const optionIdPrefix = useId(); + + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [actions, setActions] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [recents, setRecents] = useState(() => + readPaletteRecents(), + ); + // Where availability, dispatch, and focus-on-close all point. + const openTargetRef = useRef(null); + // Set when a row is chosen, read once focus has been restored. + const pendingActionRef = useRef(null); + + useAppCommandHandler("palette.open", (invocation) => { + const target = + invocation.target ?? + (typeof document === "undefined" ? null : document.activeElement); + openTargetRef.current = target; + setActions([ + ...buildAppCommandActions({ + target, + isCommandAvailable: runner.isCommandAvailable, + dispatch: runner.dispatch, + shortcuts, + }), + ...buildPluginPaletteActions({ + slots: getPluginSlotSnapshot().commandPaletteActions, + threadId, + projectId, + openThreadPanel: getActiveThreadPanelOpener(), + }), + ]); + setQuery(""); + setHighlightedIndex(0); + setOpen(true); + return true; + }); + + const ranked = useMemo( + () => rankPaletteActions({ actions, query, recentIds: recents }), + [actions, query, recents], + ); + // Typing can shrink the list under the selection. + const activeIndex = + ranked.length === 0 ? -1 : Math.min(highlightedIndex, ranked.length - 1); + + /** + * Focus stays in the search field, so nothing scrolls the highlighted row + * into view on its own. Keyboard moves only: scrolling on hover would yank + * the list out from under the pointer. + */ + const listRef = useRef(null); + const scrollOnNextHighlightRef = useRef(false); + useEffect(() => { + if (!scrollOnNextHighlightRef.current) return; + scrollOnNextHighlightRef.current = false; + listRef.current + ?.querySelector('[aria-selected="true"]') + ?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + const chooseAction = useCallback((action: PaletteAction) => { + pendingActionRef.current = action; + setRecents((current) => recordPaletteRecent(current, action.id)); + setOpen(false); + }, []); + + /** + * Restore focus before running, so a command that focuses something does not + * have it taken back by the dialog's own restoration a tick later. + */ + const handleCloseAutoFocus = useCallback((event: Event) => { + const pending = pendingActionRef.current; + pendingActionRef.current = null; + const target = openTargetRef.current; + if (target instanceof HTMLElement && target.isConnected) { + event.preventDefault(); + target.focus({ preventScroll: true }); + } + pending?.run(); + }, []); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (ranked.length === 0) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + scrollOnNextHighlightRef.current = true; + setHighlightedIndex((current) => + current + 1 >= ranked.length ? 0 : current + 1, + ); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + scrollOnNextHighlightRef.current = true; + setHighlightedIndex((current) => + current <= 0 ? ranked.length - 1 : current - 1, + ); + return; + } + if (event.key === "Home") { + event.preventDefault(); + scrollOnNextHighlightRef.current = true; + setHighlightedIndex(0); + return; + } + if (event.key === "End") { + event.preventDefault(); + scrollOnNextHighlightRef.current = true; + setHighlightedIndex(ranked.length - 1); + return; + } + if (event.key === "Enter") { + const choice = ranked[activeIndex]; + if (choice === undefined) return; + event.preventDefault(); + chooseAction(choice.action); + } + }, + [activeIndex, chooseAction, ranked], + ); + + return ( + + + Quick palette +
+ + { + setQuery(event.target.value); + setHighlightedIndex(0); + // `activeIndex` may not change, so the effect above cannot do + // this: send the scrolled container back to the first row. + if (listRef.current !== null) listRef.current.scrollTop = 0; + }} + onKeyDown={handleKeyDown} + /> +
+
+ {ranked.length === 0 ? ( +

+ No matching commands +

+ ) : ( + ranked.map((entry, index) => ( + setHighlightedIndex(index)} + onSelect={() => chooseAction(entry.action)} + /> + )) + )} +
+
+
+ ); +} + +function PaletteRow({ + entry, + id, + isActive, + onActivate, + onSelect, +}: { + entry: RankedPaletteAction; + id: string; + isActive: boolean; + onActivate: () => void; + onSelect: () => void; +}) { + return ( + // A listbox option the input points at, not a focusable control. +
+ + + + + + {entry.action.group} + + {entry.action.shortcut === null ? null : ( + + )} + +
+ ); +} + +function HighlightedTitle({ + title, + positions, +}: { + title: string; + positions: readonly number[]; +}) { + if (positions.length === 0) return <>{title}; + const emphasized = new Set(positions); + return ( + <> + {[...title].map((character, index) => + emphasized.has(index) ? ( + + {character} + + ) : ( + {character} + ), + )} + + ); +} diff --git a/apps/app/src/components/create-via-prompt-examples.test.ts b/apps/app/src/components/create-via-prompt-examples.test.ts index 33da2e5f33..0c4e7c3b0a 100644 --- a/apps/app/src/components/create-via-prompt-examples.test.ts +++ b/apps/app/src/components/create-via-prompt-examples.test.ts @@ -6,13 +6,6 @@ import { import { getCreateExamples } from "./create-via-prompt-examples"; describe("getCreateExamples", () => { - it("keeps four automation templates for the overview shelf", () => { - const { examples } = getCreateExamples("automation"); - - expect(examples).toHaveLength(4); - expect(examples.every((example) => example.prompt.length > 0)).toBe(true); - }); - it("serves the Browse archetypes as the plugin templates, one source", () => { // The New plugin menu and the Browse page must never show two divergent // example lists, so the menu templates ARE the hero archetypes. diff --git a/apps/app/src/components/create-via-prompt-examples.tsx b/apps/app/src/components/create-via-prompt-examples.tsx index 96bafad86b..881b77406f 100644 --- a/apps/app/src/components/create-via-prompt-examples.tsx +++ b/apps/app/src/components/create-via-prompt-examples.tsx @@ -10,13 +10,9 @@ import { archetypePrompt, utilityPrompt, } from "@/components/plugin/browse-hero/browse-hero-archetypes"; -import { - CREATE_AUTOMATION_PROMPT, - CREATE_PLUGIN_PROMPT, - CREATE_SKILL_PROMPT, -} from "@/lib/create-resource-prompts"; +import { CREATE_PLUGIN_PROMPT, CREATE_SKILL_PROMPT } from "@bb/client-core"; -export type CreateViaPromptKind = "skill" | "plugin" | "automation"; +type CreateViaPromptKind = "skill" | "plugin"; interface Example { label: string; @@ -29,7 +25,6 @@ interface Example { interface KindConfig { prefix: string; - explainer: string; examples: readonly Example[]; } @@ -39,8 +34,6 @@ interface KindConfig { const CONFIG: Record = { skill: { prefix: CREATE_SKILL_PROMPT, - explainer: - "Write a skill once, and every agent in bb can run it, whatever the provider.", examples: [ { label: "PR review", @@ -64,8 +57,6 @@ const CONFIG: Record = { }, plugin: { prefix: CREATE_PLUGIN_PROMPT, - explainer: - "Add app surfaces, commands, background work, or agent tools through a plugin.", // The Browse hero's use-case archetypes verbatim, so the New plugin menu // and the Browse page can never show two divergent example lists. The // one-line hook is the card text; the full brief rides in `prompt`. @@ -76,40 +67,9 @@ const CONFIG: Record = { prompt: archetypePrompt(archetype), })), }, - automation: { - prefix: CREATE_AUTOMATION_PROMPT, - explainer: - "Run scripts on a schedule and spawn agent threads only when there is real work.", - examples: [ - { - label: "CI failure triage", - icon: "AlertCircle", - description: - "runs every weekday morning, checks failed main-branch CI, and opens fixer threads only for new failures", - }, - { - label: "Dependency drift", - icon: "ElectricPlugs", - description: - "checks weekly for stale dependencies and opens an update thread when risk is low", - }, - { - label: "Release readiness", - icon: "Target", - description: - "checks the release branch hourly, summarizes blocking checks, and alerts only when the status changes", - }, - { - label: "Stale worktrees", - icon: "FolderGit", - description: - "checks daily for stale worktrees and opens cleanup threads only after they exceed the team's retention window", - }, - ], - }, }; -export interface CreateExample { +interface CreateExample { label: string; icon: IconName; description: string; @@ -118,17 +78,15 @@ export interface CreateExample { } /** - * The shared create-via-prompt content for a kind: the marketing one-liner and - * the examples with their full seeded prompts. Surfaces render it how they like - * (cards, chips) without duplicating the copy. + * The shared create-via-prompt content for a kind: the examples with their + * full seeded prompts. Surfaces render it how they like (cards, chips) without + * duplicating the copy. */ export function getCreateExamples(kind: CreateViaPromptKind): { - explainer: string; examples: CreateExample[]; } { const config = CONFIG[kind]; return { - explainer: config.explainer, examples: config.examples.map((example) => ({ label: example.label, icon: example.icon, @@ -138,7 +96,7 @@ export function getCreateExamples(kind: CreateViaPromptKind): { }; } -export interface CreateWithTemplatesButtonProps { +interface CreateWithTemplatesButtonProps { kind: CreateViaPromptKind; /** Main-button text, e.g. "New automation" or "New bb skill". */ label: string; diff --git a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx index a46334fbee..616f7ff523 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx @@ -145,10 +145,16 @@ describe("AddMachineDialog", () => { expect(command.textContent).toContain("--server https://example.getbb.app"); expect(command.textContent).toContain("--machine-code mc_test456"); expect(command.textContent).not.toContain(window.location.origin); - expect(screen.getByText(/Code expires in \d+:\d{2}/)).toBeDefined(); + expect(command.closest("[data-add-machine-command]")).not.toBeNull(); expect( - screen.getByText("Waiting for the machine to connect…"), + screen.getByText( + /It installs bb and keeps the machine connected to this server/u, + ), ).toBeDefined(); + expect(screen.getByText(/Code expires in \d+:\d{2}/)).toBeDefined(); + const waiting = screen.getByText("Waiting for the machine to connect…"); + expect(waiting).toBeDefined(); + expect(waiting.parentElement?.className).not.toContain("border-border"); fireEvent.click(screen.getByRole("button", { name: "Copy" })); await waitFor(() => { diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx index 925be5f288..966bd0132e 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.tsx @@ -322,10 +322,10 @@ function AddMachineDialogContent({ {unreachable !== null ? "Pair a machine to run projects and threads on it." - : "Run this on the machine you want to add. It pairs the machine to this server and keeps it available for your projects."} - - -
+ : "Run this command on the machine you want to add. It installs bb and keeps the machine connected to this server."} + + +
{mintJoinCode.isError || connectUnavailable ? (

@@ -351,21 +351,14 @@ function AddMachineDialogContent({ reason={unreachable.reason} /> ) : command !== null ? ( -

-
+          
+
               {command}
             
-
- +
{expired ? ( <> @@ -387,11 +380,17 @@ function AddMachineDialogContent({ Code expires in {formatCountdown(remainingMs)} ) : null} +
-

- This installs bb, enrolls the daemon, and configures it to - reconnect automatically on the other machine. -

) : (

@@ -400,7 +399,7 @@ function AddMachineDialogContent({

)} {unreachable !== null ? null : ( -
+
{connectedNewHost !== null ? ( <> diff --git a/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx b/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx index 52f88d0d97..38f3d1446a 100644 --- a/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx +++ b/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx @@ -23,7 +23,7 @@ interface EnvironmentRenameDialogProps { onRename: (environmentId: string, name: string | null) => void; } -export interface EnvironmentRenameDialogContentProps { +interface EnvironmentRenameDialogContentProps { target: EnvironmentRenameDialogTarget; pending: boolean; errorMessage?: string | null; diff --git a/apps/app/src/components/dialogs/ProjectDeleteDialog.tsx b/apps/app/src/components/dialogs/ProjectDeleteDialog.tsx index 5cacac7c5f..5e4ccf558e 100644 --- a/apps/app/src/components/dialogs/ProjectDeleteDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectDeleteDialog.tsx @@ -34,7 +34,7 @@ export function ProjectDeleteDialog({ ); } -export interface ProjectDeleteDialogContentProps { +interface ProjectDeleteDialogContentProps { target: ProjectDeleteDialogTarget; pending: boolean; onDelete: (projectId: string) => void; diff --git a/apps/app/src/components/dialogs/ProjectMachineSetupDialog.tsx b/apps/app/src/components/dialogs/ProjectMachineSetupDialog.tsx index 3f0eac161b..5fce8b9128 100644 --- a/apps/app/src/components/dialogs/ProjectMachineSetupDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectMachineSetupDialog.tsx @@ -101,7 +101,7 @@ interface ProjectMachineSetupDialogContentProps { onComplete: (completion: ProjectMachineSetupCompletion) => void; } -export function ProjectMachineSetupDialogContent({ +function ProjectMachineSetupDialogContent({ target, addSource, onOpenChange, diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.tsx index 20114ed9e7..815a69f8e3 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.tsx @@ -91,7 +91,7 @@ export function ProjectPathDialog({ ); } -export interface ProjectPathDialogContentProps { +interface ProjectPathDialogContentProps { target: ProjectPathDialogTarget; pending: boolean; platform: HostPlatform | null; diff --git a/apps/app/src/components/dialogs/ProjectRenameDialog.tsx b/apps/app/src/components/dialogs/ProjectRenameDialog.tsx index c390dce0f6..0d1835c453 100644 --- a/apps/app/src/components/dialogs/ProjectRenameDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectRenameDialog.tsx @@ -13,7 +13,7 @@ interface ProjectRenameDialogProps { onRename: (projectId: string, name: string) => void; } -export interface ProjectRenameDialogContentProps { +interface ProjectRenameDialogContentProps { target: ProjectRenameDialogTarget; pending: boolean; onRename: (projectId: string, name: string) => void; diff --git a/apps/app/src/components/dialogs/ProjectSourceDeleteDialog.tsx b/apps/app/src/components/dialogs/ProjectSourceDeleteDialog.tsx index 8aded164a9..5602dddec6 100644 --- a/apps/app/src/components/dialogs/ProjectSourceDeleteDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectSourceDeleteDialog.tsx @@ -34,7 +34,7 @@ export function ProjectSourceDeleteDialog({ ); } -export interface ProjectSourceDeleteDialogContentProps { +interface ProjectSourceDeleteDialogContentProps { target: ProjectSourceDeleteDialogTarget; pending: boolean; onDelete: (sourceId: string) => void; diff --git a/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx b/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx index e01233be5d..c5b105d0f6 100644 --- a/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx +++ b/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx @@ -20,7 +20,7 @@ interface ProviderCliInstallLogDialogProps { onClose: () => void; } -export interface ProviderCliInstallLogDialogContentProps { +interface ProviderCliInstallLogDialogContentProps { state: ProviderCliInstallLogDialogState; } diff --git a/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx b/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx index 187e0bf305..bb2becd2e5 100644 --- a/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx +++ b/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx @@ -8,7 +8,7 @@ import { waitFor, } from "@testing-library/react"; import type { HostDirectoryListing } from "@bb/server-contract"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { @@ -40,9 +40,24 @@ function listing(path: string, entries: string[]): HostDirectoryListing { }; } +/** + * jsdom has no layout, so the entry list's virtualizer would see a 0px scroll + * box and mount nothing. Give every scroll box a 224px (h-56) viewport and + * every entry row its real single-line height. + */ +const ENTRY_TEST_ROW_HEIGHT_PX = 28; +beforeEach(() => { + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation( + function (this: HTMLElement) { + return this.tagName === "LI" ? ENTRY_TEST_ROW_HEIGHT_PX : 224; + }, + ); +}); + afterEach(() => { cleanup(); vi.clearAllMocks(); + vi.restoreAllMocks(); }); describe("joinHostPath", () => { @@ -236,3 +251,42 @@ describe("RemotePathBrowser new folder", () => { expect(onDirectoryChange).not.toHaveBeenCalledWith("/home/me/existing"); }); }); + +describe("RemotePathBrowser entry list", () => { + it("mounts only the entries near the viewport for a huge directory", async () => { + const names = Array.from( + { length: 5000 }, + (_, i) => `file_${String(i).padStart(5, "0")}`, + ); + directory.mockResolvedValue(listing("/home/me/manyfiles", names)); + const { wrapper: Wrapper } = createQueryClientTestHarness(); + + const { container } = render( + + + , + ); + + await screen.findByText("file_00000"); + // 224px / 28px is 8 visible rows; with overscan the mounted set stays a + // small constant instead of one row per directory entry. + const mountedRows = container.querySelectorAll("li"); + expect(mountedRows.length).toBeLessThan(60); + expect(mountedRows.length).toBeGreaterThanOrEqual(8); + expect(screen.queryByText("file_04999")).toBeNull(); + + // Scrolling to the bottom mounts the last rows and unmounts the first. + const list = container.querySelector("ul"); + const scrollBox = list?.parentElement; + if (!(scrollBox instanceof HTMLElement)) throw new Error("no scroll box"); + scrollBox.scrollTop = 4_999 * ENTRY_TEST_ROW_HEIGHT_PX; + fireEvent.scroll(scrollBox); + expect(await screen.findByText("file_04999")).not.toBeNull(); + expect(screen.queryByText("file_00000")).toBeNull(); + expect(container.querySelectorAll("li").length).toBeLessThan(60); + }); +}); diff --git a/apps/app/src/components/dialogs/RemotePathBrowser.tsx b/apps/app/src/components/dialogs/RemotePathBrowser.tsx index 9b6cc36971..ee5a0cce9c 100644 --- a/apps/app/src/components/dialogs/RemotePathBrowser.tsx +++ b/apps/app/src/components/dialogs/RemotePathBrowser.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { normalizeProjectPathInput } from "@bb/domain"; +import type { HostDirectoryListing } from "@bb/server-contract"; import { Button } from "@bb/shared-ui/button"; import { EmptyState } from "@bb/shared-ui/empty-state"; import { Icon } from "@bb/shared-ui/icon"; @@ -64,6 +66,21 @@ export function getFolderNameValidationMessage(name: string): string | null { return null; } +/** + * Every entry row is one truncated `text-sm` line with `py-1`, so this + * estimate is exact; `measureElement` still corrects it for zoom or font + * changes. + */ +const DIRECTORY_ENTRY_ROW_HEIGHT_PX = 28; +/** + * Also covers the "new folder" form that sits above the list inside the same + * scroll box (at most ~3 rows tall), so the virtualizer can treat the list as + * starting at scroll offset 0 without a `scrollMargin` measurement. + */ +const DIRECTORY_ENTRY_OVERSCAN_ROWS = 10; + +const NO_ENTRIES: HostDirectoryListing["entries"] = []; + interface RemotePathBrowserProps { hostId: string; /** Directory to open at; null starts at the host's home directory. */ @@ -103,6 +120,20 @@ export function RemotePathBrowser({ const directory = data?.directory ?? null; const crumbs = directory ? toBreadcrumb(directory) : []; + // The daemon lists the whole directory, so a build output or home folder + // can hold thousands of entries. The list box shows ~8 rows; mounting every + // entry made that box cost one DOM row per file (#1615). Mount only the rows + // near the viewport instead. + const entries = data?.entries ?? NO_ENTRIES; + const scrollRef = useRef(null); + const entryVirtualizer = useVirtualizer({ + count: entries.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => DIRECTORY_ENTRY_ROW_HEIGHT_PX, + getItemKey: (index) => entries[index]?.path ?? index, + overscan: DIRECTORY_ENTRY_OVERSCAN_ROWS, + }); + const startCreatingFolder = () => { if ( !allowCreateFolder || @@ -234,17 +265,25 @@ export function RemotePathBrowser({ className="px-2 py-3" /> ); - } else if (data.entries.length === 0) { + } else if (entries.length === 0) { body = ; } else { body = ( -
    - {data.entries.map((entry) => { +
      + {entryVirtualizer.getVirtualItems().map((virtualItem) => { + const entry = entries[virtualItem.index]; + if (!entry) return null; if (entry.kind === "file") { return (
    • @@ -254,7 +293,13 @@ export function RemotePathBrowser({ ); } return ( -
    • +
    • - ) : agent.status === "not_installed" && - agent.canInstall && - !isInstalling ? ( - - ) : null} -
-
- {expanded && agent.loginCommand !== null ? ( - /* bb deliberately does not drive another tool's login: it shows - the agent's own command and re-checks, so credentials never - pass through bb. */ -
-

- Run this in a terminal, then come back: -

-
- - {agent.loginCommand} - - - -
-
- ) : null} -
- ); - })} - - ); -} - -export function OnboardingFlow({ - onAddProjects, - onClose, - onEvent, - onInstallAgent, - installing, -}: OnboardingFlowProps) { - const [step, setStep] = useState<0 | 1>(0); - const [selected, setSelected] = useState>(new Set()); - const [expandedSignIn, setExpandedSignIn] = useState(null); - /** Folders the user picked by hand, shown and checked alongside the scan. */ - const [addedRepos, setAddedRepos] = useState([]); - const [addError, setAddError] = useState(null); - const [adding, setAdding] = useState(false); - const [startedReported, setStartedReported] = useState(false); - - // Poll only while the agents step is visible; the projects step has no use - // for it and each read is several host round-trips. - const agentsQuery = useOnboardingAgents({ poll: step === 0 }); - // Re-read after a terminal sign-in rather than waiting out the poll. Using the - // query's own refetch keeps cache writes inside the query layer. - const recheck = useCallback(() => { - void agentsQuery.refetch(); - }, [agentsQuery]); - const reposQuery = useOnboardingRepos({ enabled: step === 1 }); - - const agents = useMemo( - () => agentsQuery.data?.agents ?? [], - [agentsQuery.data], - ); - const agentState = agentStateOf(agents); - const scanningAgents = agentsQuery.isPending; - - // Only a machine with nothing installed is asked to install something. - const nothingInstalled = - !scanningAgents && - agents.length > 0 && - agents.every((agent) => agent.status === "not_installed"); - const canContinue = - !scanningAgents && agents.some((agent) => agent.status === "connected"); - - useEffect(() => { - if (startedReported || scanningAgents) return; - // A failed probe is not evidence of an empty machine; reporting it would - // inflate `agent_state: none`, the metric this event exists to answer. - if (agentsQuery.isError || agentsQuery.data === undefined) return; - setStartedReported(true); - onEvent?.({ - name: "started", - agentState, - agentCount: agents.filter((agent) => agent.status !== "not_installed") - .length, - }); - }, [ - agentState, - agents, - agentsQuery.data, - agentsQuery.isError, - onEvent, - scanningAgents, - startedReported, - ]); - - const repos = useMemo(() => { - const discovered = reposQuery.data?.repos ?? []; - const seen = new Set(discovered.map((repo) => repo.path)); - // Hand-picked folders lead: the user just chose them. - return [ - ...addedRepos.filter((repo) => !seen.has(repo.path)), - ...discovered, - ]; - }, [addedRepos, reposQuery.data]); - - // Same path-entry surface the rest of the app uses (native picker on a - // single-machine desktop, in-app browser otherwise). Onboarding's submit adds - // the folder to this step's list instead of creating a project immediately, - // so one "Add projects" click still creates everything at once. - const pathPicker = useLocalPathPicker({ - isPending: false, - submit: ({ path, closeDialog }) => { - const name = path.split("/").filter(Boolean).pop() ?? path; - setAddedRepos((current) => - current.some((repo) => repo.path === path) - ? current - : [ - ...current, - { - path, - name, - lastActivityAt: new Date().toISOString(), - originUrl: null, - agentSeen: false, - agentSeenAt: null, - }, - ], - ); - setSelected((current) => new Set(current).add(path)); - closeDialog(); - }, - }); - - // Pre-check repos an agent has already worked in — that is the strongest - // signal the user wants them in bb — and fall back to the most recent. - useEffect(() => { - if (reposQuery.data === undefined) return; - setSelected((current) => { - if (current.size > 0) return current; - const seen = repos.filter((repo) => repo.agentSeen).map((r) => r.path); - return new Set( - seen.length > 0 ? seen : repos.slice(0, 2).map((r) => r.path), - ); - }); - }, [repos, reposQuery.data]); - - const finish = useCallback( - (completed: boolean, atStep: "agents" | "projects", added: number) => { - onClose({ completed, step: atStep, projectsAdded: added, agentState }); - }, - [agentState, onClose], - ); - - const toggleRepo = useCallback((path: string) => { - setSelected((current) => { - const next = new Set(current); - if (next.has(path)) next.delete(path); - else next.add(path); - return next; - }); - }, []); - - const addProjects = useCallback(async () => { - const chosen = repos.filter((repo) => selected.has(repo.path)); - setAdding(true); - setAddError(null); - try { - await onAddProjects(chosen); - onEvent?.({ name: "step_completed", step: "projects" }); - finish(true, "projects", chosen.length); - } catch (error) { - // Keep the dialog open and say so, rather than stranding the user with a - // half-added set and no explanation. - setAddError( - error instanceof Error - ? error.message - : "Could not add every project. Try again.", - ); - } finally { - setAdding(false); - } - }, [finish, onAddProjects, onEvent, repos, selected]); - - const title = - step === 0 - ? nothingInstalled - ? "Install a coding agent" - : "bb uses your existing coding agents" - : "Add your projects"; - - const description = - step === 0 - ? nothingInstalled - ? "bb has no inference of its own. It runs coding agent CLIs on your computer and bills usage to their plans. Install one to get started." - : "It runs the agents below locally, so inference is billed to their plans." - : "bb works inside your code. Add the folders you want it to work in. You can add more any time."; - - return ( - { - if (next) return; - finish(false, step === 0 ? "agents" : "projects", 0); - }} - > - event.preventDefault()} - > - - {title} - {description} - - -
- {step === 0 ? ( - scanningAgents ? ( -
- - Checking which coding agents are installed… -
- ) : ( - agent.canInstall) - : agents.filter((agent) => agent.status !== "not_installed") - } - expandedSignIn={expandedSignIn} - installing={installing} - onInstall={onInstallAgent} - onRecheck={recheck} - onToggleSignIn={setExpandedSignIn} - /> - ) - ) : ( -
-

- {reposQuery.isPending - ? "Searching ~ for git repos…" - : `Found ${reposQuery.data?.repos.length ?? 0} repos you edited in the last 30 days`} -

- {reposQuery.isPending ? null : ( - - {repos.map((repo) => ( -
toggleRepo(repo.path)} - className={cn( - "flex h-14 cursor-pointer items-center gap-3 border-b border-border-hairline px-4 last:border-b-0", - // Hover only applies to unselected rows. `state-hover` - // replaces the background rather than layering over it, - // so applying both made a hovered selected row read - // *lighter* than its selected neighbours. - selected.has(repo.path) - ? "bg-surface-selected" - : "hover:bg-state-hover", - )} - > - - -
-
{repo.name}
-
- {repo.path} -
-
- - {new Date(repo.lastActivityAt).toLocaleDateString()} - -
- ))} -
- )} - -
pathPicker.openPathEntry({ kind: "create" })} - className="flex h-14 cursor-pointer items-center gap-3 px-4 hover:bg-state-hover" - > - -
-
Add a folder
-
- Choose a project outside your home directory -
-
- - Browse… - -
-
- {addError === null ? null : ( -

{addError}

- )} -
- )} -
- - -
- - - {step === 0 - ? canContinue - ? "" - : nothingInstalled - ? "Install an agent to continue" - : "Sign in to at least one agent to continue" - : selected.size > 0 - ? `${selected.size} project${selected.size > 1 ? "s" : ""} selected` - : "You can add projects any time"} - -
-
- {step === 0 ? ( - <> - {canContinue ? null : ( - - )} - - - ) : ( - <> - - - - )} -
-
-
- - {/* Rendered inside the onboarding dialog's tree so it portals above it. */} - -
- ); -} diff --git a/apps/app/src/components/onboarding/OnboardingHost.test.tsx b/apps/app/src/components/onboarding/OnboardingHost.test.tsx deleted file mode 100644 index b77f2d34e5..0000000000 --- a/apps/app/src/components/onboarding/OnboardingHost.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; -import { defaultAppSettings, defaultExperiments } from "@bb/domain"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { OnboardingHost } from "./OnboardingHost"; - -const mocks = vi.hoisted(() => ({ - useCreateProject: vi.fn(), - useHostProviderCliStatus: vi.fn(), - usePrimaryHost: vi.fn(), - useProviderCliInstallRunner: vi.fn(), - useSidebarNavigation: vi.fn(), - useSystemConfig: vi.fn(), - useUpdateGeneralSettings: vi.fn(), -})); - -vi.mock("@/hooks/queries/system-queries", () => ({ - useHostProviderCliStatus: mocks.useHostProviderCliStatus, - useSystemConfig: mocks.useSystemConfig, -})); -vi.mock("@/hooks/mutations/settings-mutations", () => ({ - useUpdateGeneralSettings: mocks.useUpdateGeneralSettings, -})); -vi.mock("@/hooks/mutations/project-mutations", () => ({ - useCreateProject: mocks.useCreateProject, -})); -vi.mock("@/hooks/queries/host-queries", () => ({ - usePrimaryHost: mocks.usePrimaryHost, -})); -vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ - useSidebarNavigation: mocks.useSidebarNavigation, -})); -vi.mock("@/components/provider-cli/provider-cli-install", () => ({ - buildProviderCliIssue: vi.fn(), - hasProviderCliAction: vi.fn(), - providerCliEntries: vi.fn(() => []), - useProviderCliInstallRunner: mocks.useProviderCliInstallRunner, -})); -vi.mock("@/components/provider-cli/provider-cli-install-store", () => ({ - providerCliJobKey: vi.fn(() => "job"), -})); -vi.mock("./OnboardingFlow", () => ({ - OnboardingFlow: () =>
Onboarding flow
, -})); - -beforeEach(() => { - mocks.useCreateProject.mockReturnValue({ mutateAsync: vi.fn() }); - mocks.useHostProviderCliStatus.mockReturnValue({ data: undefined }); - mocks.usePrimaryHost.mockReturnValue({ id: "host-1" }); - mocks.useProviderCliInstallRunner.mockReturnValue({ - failuresByJobKey: new Map(), - queuedJobKeys: new Set(), - runningJobKey: null, - startInstall: vi.fn(), - }); - mocks.useSidebarNavigation.mockReturnValue({ data: { projects: [] } }); - mocks.useUpdateGeneralSettings.mockReturnValue({ mutate: vi.fn() }); -}); - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("OnboardingHost", () => { - it("does not show or run provider checks while the experiment is off", () => { - mocks.useSystemConfig.mockReturnValue({ - data: { - experiments: defaultExperiments, - generalSettings: defaultAppSettings, - }, - }); - - render(); - - expect(screen.queryByText("Onboarding flow")).toBeNull(); - expect(mocks.useHostProviderCliStatus).toHaveBeenCalledWith({ - enabled: false, - hostId: "host-1", - }); - }); - - it("shows onboarding when the experiment is on and setup is incomplete", () => { - mocks.useSystemConfig.mockReturnValue({ - data: { - experiments: { ...defaultExperiments, newOnboarding: true }, - generalSettings: defaultAppSettings, - }, - }); - - render(); - - expect(screen.getByText("Onboarding flow")).toBeTruthy(); - expect(mocks.useHostProviderCliStatus).toHaveBeenCalledWith({ - enabled: true, - hostId: "host-1", - }); - }); -}); diff --git a/apps/app/src/components/onboarding/OnboardingHost.tsx b/apps/app/src/components/onboarding/OnboardingHost.tsx deleted file mode 100644 index aae054bfda..0000000000 --- a/apps/app/src/components/onboarding/OnboardingHost.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import type { DiscoveredRepo } from "@bb/host-daemon-contract"; -import { useSystemConfig } from "@/hooks/queries/system-queries"; -import { useUpdateGeneralSettings } from "@/hooks/mutations/settings-mutations"; -import { useCreateProject } from "@/hooks/mutations/project-mutations"; -import { usePrimaryHost } from "@/hooks/queries/host-queries"; -import { useHostProviderCliStatus } from "@/hooks/queries/system-queries"; -import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; -import { - buildProviderCliIssue, - hasProviderCliAction, - providerCliEntries, - useProviderCliInstallRunner, -} from "@/components/provider-cli/provider-cli-install"; -import { providerCliJobKey } from "@/components/provider-cli/provider-cli-install-store"; -import { sdk } from "@/lib/sdk"; - -/** - * Collapse the two spellings of one remote so SSH and HTTPS clones of the same - * repository compare equal. A repo with no remote returns null and is never - * matched — path is not available on a project, so those are left to the - * server's own duplicate handling. - */ -function normalizeRemote(url: string | null): string | null { - if (url === null) return null; - const trimmed = url.trim(); - if (trimmed === "") return null; - return trimmed - .replace(/\.git$/u, "") - .replace(/^git@([^:]+):/u, "https://$1/") - .replace(/^ssh:\/\/git@/u, "https://") - .replace(/\/+$/u, "") - .toLowerCase(); -} - -/** Maps an onboarding provider id back to its managed-CLI key. */ -const CLI_KEY_BY_PROVIDER: Record = { - codex: "codex", - "claude-code": "claudeCode", - "acp-cursor": "cursor", -}; -import { - OnboardingFlow, - type OnboardingAgentState, - type OnboardingUiEvent, -} from "./OnboardingFlow"; - -/** - * Decides whether first-run onboarding is showing, and owns its side effects: - * creating the chosen projects, persisting the completion timestamp, and - * reporting the funnel to the server's telemetry. - * - * Mounted once by the app shell. The new-onboarding experiment and the - * `onboardingCompletedAt` timestamp gate the flow. Whether an agent is actually - * usable is answered live by the agents query, so dismissing onboarding never - * claims the machine is configured. - */ -export function OnboardingHost() { - const configQuery = useSystemConfig(); - const updateSettings = useUpdateGeneralSettings(); - const createProject = useCreateProject(); - const primaryHost = usePrimaryHost(); - const navigationQuery = useSidebarNavigation(); - const installRunner = useProviderCliInstallRunner(); - // Stamped in an effect rather than during render: `Date.now()` in a render - // body is impure and would drift on every re-render. - const startedAt = useRef(null); - - const settings = configQuery.data?.generalSettings; - const newOnboardingEnabled = - configQuery.data?.experiments.newOnboarding ?? false; - const primaryHostId = primaryHost?.id ?? null; - // Migration 0085 stamps existing installs as already onboarded, so a null - // timestamp means exactly one thing here: the flow remains incomplete. That - // is what lets Settings re-trigger it by clearing the column. - const neverOnboarded = - settings !== undefined && settings.onboardingCompletedAt === null; - const shouldShow = - newOnboardingEnabled && neverOnboarded && primaryHostId !== null; - const cliStatusQuery = useHostProviderCliStatus({ - hostId: primaryHostId, - // Only needed to build an install job, and only while the flow is open. - // Left ungated this runs provider CLI and package-registry checks on every - // app start, forever, for users who finished onboarding long ago. - enabled: shouldShow, - }); - - const projects = navigationQuery.data?.projects; - - const installingProviders = new Set( - Object.entries(CLI_KEY_BY_PROVIDER) - .filter(([, cliKey]) => { - if (primaryHostId === null) return false; - const jobKey = providerCliJobKey(primaryHostId, cliKey); - return ( - installRunner.runningJobKey === jobKey || - installRunner.queuedJobKeys.has(jobKey) - ); - }) - .map(([providerId]) => providerId), - ); - - const installAgent = useCallback( - (agent: { providerId: string }) => { - const cliKey = CLI_KEY_BY_PROVIDER[agent.providerId]; - if (cliKey === undefined || primaryHostId === null) return; - const status = cliStatusQuery.data; - if (status === undefined) return; - const issue = providerCliEntries(status) - .filter((entry) => entry.provider === cliKey) - .map(buildProviderCliIssue) - .find((candidate) => candidate !== null); - if (!issue || !hasProviderCliAction(issue)) return; - installRunner.startInstall({ hostId: primaryHostId, issue }); - }, - [cliStatusQuery.data, installRunner, primaryHostId], - ); - - // Stamp when the flow actually opens, so a re-trigger hours into a session - // does not report the whole session as its duration. - useEffect(() => { - if (shouldShow) startedAt.current ??= Date.now(); - else startedAt.current = null; - }, [shouldShow]); - - const addProjects = useCallback( - async (repos: readonly DiscoveredRepo[]) => { - if (primaryHostId === null) return; - // Guard against re-adding a repo bb already tracks on replay. Projects - // expose their remote, not their path, so the remote is the join key — - // normalized, because `git@host:o/r.git` and `https://host/o/r` are the - // same repository. - const existingRemotes = new Set( - (projects ?? []) - .map((project) => normalizeRemote(project.gitRemoteUrl)) - .filter((remote): remote is string => remote !== null), - ); - // Sequential: project creation touches the host workspace, and a burst of - // parallel creates would race on the same daemon. - for (const repo of repos) { - const remote = normalizeRemote(repo.originUrl); - if (remote !== null && existingRemotes.has(remote)) continue; - await createProject.mutateAsync({ - name: repo.name, - source: { - type: "local_path", - hostId: primaryHostId, - path: repo.path, - }, - }); - } - }, - [createProject, primaryHostId, projects], - ); - - const report = useCallback((event: OnboardingUiEvent) => { - void sdk.system - .onboardingEvent( - event.name === "started" - ? { - name: "onboarding_started", - agentState: event.agentState, - detectedAgentCount: event.agentCount, - } - : event.name === "step_skipped" - ? { name: "onboarding_step_skipped", step: event.step } - : { name: "onboarding_step_completed", step: event.step }, - ) - .catch(() => { - // Telemetry is analytics, not workflow state. - }); - }, []); - - const close = useCallback( - (outcome: { - completed: boolean; - step: "agents" | "projects"; - projectsAdded: number; - agentState: OnboardingAgentState; - }) => { - if (settings === undefined) return; - updateSettings.mutate({ - ...settings, - onboardingCompletedAt: new Date().toISOString(), - }); - void sdk.system - .onboardingEvent( - outcome.completed - ? { - name: "onboarding_completed", - agentState: outcome.agentState, - projectsAdded: outcome.projectsAdded, - durationMs: Date.now() - (startedAt.current ?? Date.now()), - } - : { name: "onboarding_dismissed", step: outcome.step }, - ) - .catch(() => {}); - }, - [settings, updateSettings], - ); - - if (!shouldShow) return null; - - return ( - - ); -} diff --git a/apps/app/src/components/pickers/BranchPicker.stories.tsx b/apps/app/src/components/pickers/BranchPicker.stories.tsx index aeb6d26cc1..ea63bb3ad2 100644 --- a/apps/app/src/components/pickers/BranchPicker.stories.tsx +++ b/apps/app/src/components/pickers/BranchPicker.stories.tsx @@ -28,7 +28,7 @@ const noop = () => {}; type BranchPickerStoryConfig = Omit< BranchPickerProps, "onChange" | "options" | "variant" ->; +> & { currentBranch?: string | null }; interface BranchPickerStoryRowProps { label: string; diff --git a/apps/app/src/components/pickers/BranchPicker.test.ts b/apps/app/src/components/pickers/BranchPicker.test.ts index b7baa55e88..2e97f611bc 100644 --- a/apps/app/src/components/pickers/BranchPicker.test.ts +++ b/apps/app/src/components/pickers/BranchPicker.test.ts @@ -19,7 +19,7 @@ describe("buildBranchPickerOptionGroups", () => { }); describe("orderBranchPickerOptions", () => { - it("pins the selected branch before default and origin default refs", () => { + it("pins the selected branch before the remaining options", () => { expect( orderBranchPickerOptions({ options: [ @@ -29,25 +29,14 @@ describe("orderBranchPickerOptions", () => { "origin/main", "origin/feature/login", ], - priorityOptions: ["main", "origin/main"], selectedValue: "origin/feature/login", }), ).toEqual([ "origin/feature/login", - "main", - "origin/main", "develop", + "main", "feature/login", + "origin/main", ]); }); - - it("keeps default refs near the top when no branch is selected", () => { - expect( - orderBranchPickerOptions({ - options: ["develop", "origin/release", "main", "origin/main"], - priorityOptions: ["main", "origin/main"], - selectedValue: null, - }), - ).toEqual(["main", "origin/main", "develop", "origin/release"]); - }); }); diff --git a/apps/app/src/components/pickers/BranchPicker.tsx b/apps/app/src/components/pickers/BranchPicker.tsx index d6a8e16524..1c5d1c1f85 100644 --- a/apps/app/src/components/pickers/BranchPicker.tsx +++ b/apps/app/src/components/pickers/BranchPicker.tsx @@ -36,7 +36,7 @@ import { OPTION_INTERACTIVE_CLASS_NAME, OPTION_MUTED_CLASS_NAME, OPTION_TRIGGER_CONTENT_CLASS_NAME, -} from "./OptionPicker"; +} from "@bb/shared-ui/option-display"; import { cn } from "@bb/shared-ui/lib/utils"; import type { GitBranchRefClassification } from "@bb/domain"; @@ -196,7 +196,6 @@ interface BranchPickerRowButtonProps { title?: string; selected: boolean; disabled?: boolean; - emphasizeLabel?: boolean; onSelect: () => void; onPointerEnter?: PointerEventHandler; onKeyDown?: KeyboardEventHandler; @@ -233,7 +232,6 @@ interface FilterBranchOptionsArgs { interface OrderBranchPickerOptionsArgs { options: readonly string[]; - priorityOptions: readonly string[]; selectedValue: string | null; } @@ -471,7 +469,6 @@ function BranchPickerRowButton({ title, selected, disabled = false, - emphasizeLabel = false, onSelect, onPointerEnter: callerPointerEnter, onKeyDown: callerKeyDown, @@ -502,12 +499,7 @@ function BranchPickerRowButton({ COARSE_POINTER_COMPACT_ICON_SIZE_SHRINK_CLASS, )} /> - + orderBranchPickerOptions({ options: filteredLocalBranchOptions, - priorityOptions, selectedValue: value, }), - [filteredLocalBranchOptions, priorityOptions, value], + [filteredLocalBranchOptions, value], ); const filteredBranchOptions = useMemo( () => orderBranchPickerOptions({ options: filteredCombinedBranchOptions, - priorityOptions, selectedValue: value, }), - [filteredCombinedBranchOptions, priorityOptions, value], + [filteredCombinedBranchOptions, value], ); const activeEnterOptions = isCheckoutMenu && activeCheckoutIntent === "checkout" diff --git a/apps/app/src/components/pickers/EnvironmentPicker.test.tsx b/apps/app/src/components/pickers/EnvironmentPicker.test.tsx index 0a11899e6d..3ecc6be9db 100644 --- a/apps/app/src/components/pickers/EnvironmentPicker.test.tsx +++ b/apps/app/src/components/pickers/EnvironmentPicker.test.tsx @@ -265,10 +265,18 @@ describe("EnvironmentPickerUI multi-machine menu", () => { expect(placeholder.getAttribute("aria-disabled")).toBe("true"); }); - it("names a non-primary machine in the trigger label", () => { + it("names the primary machine in the trigger label when multiple machines exist", () => { + renderMachineMenu({ value: `host:${thisMachine.id}:worktree` }); + + expect(screen.getByText("MacBook Pro · New worktree")).toBeTruthy(); + expect(screen.getByText("Worktree")).toBeTruthy(); + }); + + it("names another selected machine in the trigger label", () => { renderMachineMenu({ value: `host:${studio.id}:worktree` }); expect(screen.getByText("Mac Studio · New worktree")).toBeTruthy(); + expect(screen.getByText("Worktree")).toBeTruthy(); }); it("keeps the single-host menu when only one host exists", () => { diff --git a/apps/app/src/components/pickers/EnvironmentPicker.tsx b/apps/app/src/components/pickers/EnvironmentPicker.tsx index 795f97f433..f5c2823297 100644 --- a/apps/app/src/components/pickers/EnvironmentPicker.tsx +++ b/apps/app/src/components/pickers/EnvironmentPicker.tsx @@ -19,7 +19,6 @@ import { } from "@bb/shared-ui/coarse-pointer-sizing"; import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; -import { selectPrimaryHost } from "@/hooks/queries/host-queries"; import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display"; import { formatRelativeTime } from "@/lib/relative-time"; import { formatHostUpdateStatus } from "@/lib/host-update-status"; @@ -30,7 +29,7 @@ import { OPTION_MENU_CONTENT_CLASS_NAME, OPTION_MUTED_CLASS_NAME, OPTION_TRIGGER_CONTENT_CLASS_NAME, -} from "./OptionPicker"; +} from "@bb/shared-ui/option-display"; import { encodeHostValue, parseEnvironmentValue, @@ -145,16 +144,11 @@ export function EnvironmentPickerUI({ const parsed = useMemo(() => parseEnvironmentValue(value), [value]); - // Mockup A: the composer chip names the machine whenever the selection - // isn't on the primary host ("Mac Studio · New worktree"). + // When the server knows multiple machines, name the selected one in the + // full composer chip ("Mac Studio · New worktree"). Single-machine and + // compact layouts use the shorter mode-only label. const selectedMachineName = useMemo(() => { if (!isMachineMenu || !machines || parsed?.type !== "host") return null; - if ( - parsed.hostId === - selectPrimaryHost(machines.hosts, machines.primaryHostId)?.id - ) { - return null; - } return ( machines.hosts.find((machineHost) => machineHost.id === parsed.hostId) ?.name ?? null @@ -202,7 +196,14 @@ export function EnvironmentPickerUI({ compactModeLabel, icon, }; - }, [parsed, localLabel, isLocal, hostUnavailableReason, host, selectedMachineName]); + }, [ + parsed, + localLabel, + isLocal, + hostUnavailableReason, + host, + selectedMachineName, + ]); return ( @@ -589,9 +590,7 @@ function EnvironmentMenuItem({ )} /> - - {label} - + {label} {description ? ( {description} diff --git a/apps/app/src/components/pickers/MachinePicker.tsx b/apps/app/src/components/pickers/MachinePicker.tsx index ed32a75be3..8cd8bb0bb1 100644 --- a/apps/app/src/components/pickers/MachinePicker.tsx +++ b/apps/app/src/components/pickers/MachinePicker.tsx @@ -25,12 +25,12 @@ import { OPTION_MENU_CONTENT_CLASS_NAME, OPTION_MUTED_CLASS_NAME, OPTION_TRIGGER_CONTENT_CLASS_NAME, -} from "./OptionPicker"; +} from "@bb/shared-ui/option-display"; const MACHINE_BADGE_CLASS_NAME = "shrink-0 rounded-sm border border-border bg-muted/40 px-1.5 py-0.5 text-2xs leading-none text-subtle-foreground"; -export interface MachinePickerUIProps { +interface MachinePickerUIProps { /** All hosts known to the server, in server order. */ hosts: readonly Host[]; /** Host id of the daemon running on this browser's machine, if reachable — diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx index 0750608c40..ab9cdfe7e2 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx @@ -27,6 +27,7 @@ import { ModelReasoningPicker, } from "./ModelReasoningPicker"; import type { PickerOption } from "./OptionPicker"; +import type { ProviderPickerOption } from "./model-brand-prefix"; import type { ModelPickerOption } from "./model-picker-option"; type CapturedCommandHandler = (invocation: { @@ -61,9 +62,11 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({ useIsAppCommandModifierHeld: () => false, })); -const providerOptions: readonly PickerOption[] = [ - { value: "codex", label: "Codex" }, - { value: "claude-code", label: "Claude Code" }, +// The brand prefix comes from each provider's declared strings; the picker +// strips it from model labels under that provider's tab. +const providerOptions: readonly ProviderPickerOption[] = [ + { value: "codex", label: "Codex", brandPrefix: "GPT-" }, + { value: "claude-code", label: "Claude Code", brandPrefix: "Claude " }, ]; const codexModels: readonly PickerOption[] = [ @@ -164,7 +167,7 @@ function renderPicker({ pickerReasoningOptions?: readonly PickerOption[]; reasoningValue?: ReasoningLevel; moreModelOptions?: readonly ModelPickerOption[]; - pickerProviderOptions?: readonly PickerOption[]; + pickerProviderOptions?: readonly ProviderPickerOption[]; alternateProviderModels?: AvailableModel[]; providerRouting?: SystemProvidersQuery; selectedProviderId?: string; @@ -475,6 +478,39 @@ describe("ModelReasoningPicker", () => { ).toBe(""); }); + it("keeps the desktop menu scrollable within the available viewport height", () => { + renderPicker({ modelOptions: manyCodexModels }); + + fireEvent.click( + screen.getByRole("button", { name: "Provider, model and reasoning" }), + ); + + const menu = screen.getByRole("dialog"); + expect(menu.className).toContain( + "max-h-[var(--radix-popover-content-available-height)]", + ); + expect(menu.className).toContain("overflow-y-auto"); + expect(menu.className).toContain("overscroll-contain"); + expect( + screen.getByRole("listbox", { name: "Models" }).className, + ).toContain("shrink-0"); + }); + + it("leaves compact drawer height and scrolling to the responsive shell", async () => { + renderPicker({ compact: true, modelOptions: manyCodexModels }); + + fireEvent.click( + screen.getByRole("button", { name: "Provider, model and reasoning" }), + ); + + expect(screen.getByRole("dialog").className).not.toContain( + "max-h-[var(--radix-popover-content-available-height)]", + ); + expect( + (await screen.findByRole("listbox", { name: "Models" })).className, + ).not.toContain("shrink-0"); + }); + it("commits a provider tab immediately and keeps its models selectable", async () => { const { onSelectedProviderChange, onModelChange } = renderPicker(); diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index 1301d4644a..fd9888a765 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -13,9 +13,11 @@ import type { SystemExecutionOptionsModelLoadError, SystemProvidersQuery, } from "@bb/server-contract"; -import { type ReasoningLevel } from "@bb/domain"; -import { stripModelBrandPrefix } from "./model-brand-prefix"; -import { REASONING_LABELS } from "@/lib/reasoning-labels"; +import type { ReasoningLevel } from "@bb/domain"; +import { + stripModelBrandPrefix, + type ProviderPickerOption, +} from "./model-brand-prefix"; import { Button } from "@bb/shared-ui/button"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { Input } from "@bb/shared-ui/input"; @@ -41,6 +43,7 @@ import { } from "@bb/shared-ui/menu-item-hover"; import { cn } from "@bb/shared-ui/lib/utils"; import { useSystemExecutionOptions } from "@/hooks/queries/system-queries"; +import { resolveModelCatalogSelection } from "@/hooks/thread-creation-options/model-catalog-selection"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { @@ -48,8 +51,8 @@ import { OPTION_INTERACTIVE_CLASS_NAME, OPTION_MUTED_CLASS_NAME, OPTION_TRIGGER_CONTENT_CLASS_NAME, - type PickerOption, -} from "./OptionPicker"; +} from "@bb/shared-ui/option-display"; +import { type PickerOption } from "./OptionPicker"; import type { ModelPickerOption } from "./model-picker-option"; import { formatModelLoadErrorText, @@ -80,7 +83,16 @@ interface ModelLabelParts { tag: string | null; } +interface ResolvedProviderPreview { + providerId: string; + model: string; + reasoningLevel: ReasoningLevel; + supportsServiceTier: boolean; +} + const FAILED_TO_LOAD_MODELS_LABEL = "Failed to load models"; +const EMPTY_MODEL_OPTIONS: readonly ModelPickerOption[] = []; +const preserveModelLabel = (displayName: string): string => displayName; const MODEL_CYCLE_COMMANDS = [ "modelPicker.cycleModel", "modelPicker.cycleModelBackward", @@ -143,9 +155,9 @@ function fuzzyFilter( // stripped from the rendered row, surprising the user. function modelSearchText( option: ModelPickerOption, - providerId: string, + brandPrefix: string | undefined, ): string { - return `${stripModelBrandPrefix(option.label, providerId)} ${option.routeProviderId ?? ""} ${option.value}`; + return `${stripModelBrandPrefix(option.label, brandPrefix)} ${option.routeProviderId ?? ""} ${option.value}`; } /** @@ -156,7 +168,7 @@ function modelSearchText( * pointer/native-focus driven); during an active search its filtered options are * flattened inline instead, keeping every match reachable from the keyboard. */ -export type ModelNavRow = +type ModelNavRow = | { kind: "model"; option: ModelPickerOption } | { kind: "more-toggle" }; @@ -203,10 +215,14 @@ export function buildModelNavRows({ interface ModelReasoningPickerProps { // Provider state providerRouting?: SystemProvidersQuery; - providerOptions: readonly PickerOption[]; + providerOptions: readonly ProviderPickerOption[]; selectedProviderId: string; /** Omit to render the provider as locked (tabs hidden, can't switch). */ onSelectedProviderChange?: (value: string) => void; + /** Reports a provider only after its live catalog resolves a coherent default. */ + onProviderPreviewResolved?: (value: ResolvedProviderPreview) => void; + /** Prevent preview selection until the provider catalog is authoritative. */ + requireVerifiedProviderPreview?: boolean; hasMultipleProviders: boolean; // Model state modelValue: string; @@ -233,6 +249,8 @@ interface ModelReasoningPickerProps { fastModeEnabled: boolean; onFastModeChange: (enabled: boolean) => void; showFastModeToggle: boolean; + /** Whether composer model-picker commands and hints apply. Defaults to true. */ + commandShortcutsEnabled?: boolean; serviceTierSupportByProvider?: Record; className?: string; /** Render with the dim, hover-to-foreground treatment used inside the prompt box. */ @@ -241,6 +259,8 @@ interface ModelReasoningPickerProps { defaultOpen?: boolean; /** Whether the popover blocks page interaction. Defaults to true. */ modal?: boolean; + /** Horizontal popover alignment. Defaults to "start". */ + align?: "start" | "center" | "end"; /** * Render the trigger as a non-interactive, dimmed label showing the same * model/reasoning summary — the popover never opens. Used by read-only @@ -264,6 +284,8 @@ export function ModelReasoningPicker({ providerRouting, selectedProviderId, onSelectedProviderChange, + onProviderPreviewResolved, + requireVerifiedProviderPreview = false, hasMultipleProviders, modelValue, modelOptions, @@ -279,11 +301,13 @@ export function ModelReasoningPicker({ fastModeEnabled, onFastModeChange, showFastModeToggle, + commandShortcutsEnabled = true, serviceTierSupportByProvider, className, muted, defaultOpen = false, modal = true, + align = "start", disabled, footerAction, }: ModelReasoningPickerProps) { @@ -291,7 +315,10 @@ export function ModelReasoningPicker({ const isPointerCoarse = usePointerCoarse(); const [open, setOpen] = useState(defaultOpen); const triggerRef = useRef(null); - const toggleShortcut = useAppCommandShortcut("modelPicker.toggle"); + const registeredToggleShortcut = useAppCommandShortcut("modelPicker.toggle"); + const toggleShortcut = commandShortcutsEnabled + ? registeredToggleShortcut + : null; const [searchQuery, setSearchQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(-1); const searchInputRef = useRef(null); @@ -312,6 +339,20 @@ export function ModelReasoningPicker({ // "More models" expansion is per-open: it resets when the popover closes. const [showMoreModels, setShowMoreModels] = useState(false); const [moreModelsOpen, setMoreModelsOpen] = useState(false); + const [trackedSelectedProviderId, setTrackedSelectedProviderId] = + useState(selectedProviderId); + + // A controlled provider change commits any pending preview. Reset during + // render so an external rehydration cannot paint an old provider's browse + // state for a frame. This deliberately leaves `open` untouched. + if (trackedSelectedProviderId !== selectedProviderId) { + setTrackedSelectedProviderId(selectedProviderId); + setPreviewProviderId(null); + setShowMoreModels(false); + setMoreModelsOpen(false); + setSearchQuery(""); + setActiveIndex(-1); + } const activeProviderId = previewProviderId ?? selectedProviderId; @@ -347,7 +388,7 @@ export function ModelReasoningPicker({ const triggerModelLabel = modelIsLoading ? "Loading models..." : hasSelectedModel - ? stripModelBrandPrefix(selectedModelLabel, selectedProviderId) + ? stripModelBrandPrefix(selectedModelLabel, selectedProvider?.brandPrefix) : selectedModelLoadFailed ? hasAlternateSelectionPath ? "Select model" @@ -378,66 +419,70 @@ export function ModelReasoningPicker({ ...providerRouting, providerId: isPreviewing ? previewProviderId : undefined, }); - - const previewModelOptions = useMemo((): readonly ModelPickerOption[] => { - if (!isPreviewing) return modelOptions; - const models = previewQuery.data?.models; - if (!models || models.length === 0) return []; - return models.map((model) => ({ - value: model.model, - label: formatModelLabel - ? formatModelLabel(model.displayName || model.model) - : model.displayName || model.model, - ...(model.routeProviderId - ? { routeProviderId: model.routeProviderId } - : {}), - })); - }, [isPreviewing, modelOptions, previewQuery.data?.models, formatModelLabel]); - const previewMoreModelOptions = useMemo((): readonly ModelPickerOption[] => { - if (!isPreviewing) return moreModelOptions; - const models = previewQuery.data?.selectedOnlyModels; - if (!models || models.length === 0) return []; - return models.map((model) => ({ - value: model.model, - label: formatModelLabel - ? formatModelLabel(model.displayName || model.model) - : model.displayName || model.model, - ...(model.routeProviderId - ? { routeProviderId: model.routeProviderId } - : {}), - })); + const previewSelectionBlocked = + requireVerifiedProviderPreview && + isPreviewing && + (previewQuery.data === undefined || + previewQuery.isPlaceholderData || + previewQuery.isError || + previewQuery.data.modelLoadError !== null); + const previewCatalogIsVerified = + isPreviewing && + previewQuery.data !== undefined && + !previewQuery.isPlaceholderData && + !previewQuery.isError && + previewQuery.data.modelLoadError === null; + + const previewSelection = useMemo( + () => + isPreviewing + ? resolveModelCatalogSelection({ + models: previewQuery.data?.models ?? [], + selectedOnlyModels: previewQuery.data?.selectedOnlyModels ?? [], + selectedModel: "", + preferredReasoningLevel: reasoningValue, + catalogIsVerified: previewCatalogIsVerified, + formatModelLabel: formatModelLabel ?? preserveModelLabel, + }) + : null, + [ + formatModelLabel, + isPreviewing, + previewCatalogIsVerified, + previewQuery.data?.models, + previewQuery.data?.selectedOnlyModels, + reasoningValue, + ], + ); + const previewModelOptions = previewSelection?.modelOptions ?? modelOptions; + const previewMoreModelOptions = + previewSelection?.moreModelOptions ?? moreModelOptions; + useEffect(() => { + if ( + !previewCatalogIsVerified || + !previewProviderId || + !previewSelection?.selectedModel + ) { + return; + } + const provider = previewQuery.data?.providers.find( + (candidate) => candidate.id === previewProviderId, + ); + onProviderPreviewResolved?.({ + providerId: previewProviderId, + model: previewSelection.selectedModel, + reasoningLevel: previewSelection.reasoningLevel, + supportsServiceTier: provider?.capabilities.supportsServiceTier ?? false, + }); }, [ - isPreviewing, - moreModelOptions, - previewQuery.data?.selectedOnlyModels, - formatModelLabel, + onProviderPreviewResolved, + previewCatalogIsVerified, + previewProviderId, + previewQuery.data?.providers, + previewSelection, ]); - // While previewing, the reasoning levels belong to the previewed provider's - // default model (each provider exposes its own set), so the section reflects - // the tab on screen rather than the committed model. - const previewDefaultModel = useMemo(() => { - if (!isPreviewing) return undefined; - const models = previewQuery.data?.models; - if (!models || models.length === 0) return undefined; - return models.find((model) => model.isDefault) ?? models[0]; - }, [isPreviewing, previewQuery.data?.models]); - const previewReasoningOptions = - useMemo((): readonly PickerOption[] => { - if (!previewDefaultModel) return []; - const seen = new Set(); - const options: PickerOption[] = []; - for (const effort of previewDefaultModel.supportedReasoningEfforts) { - if (seen.has(effort.reasoningEffort)) continue; - seen.add(effort.reasoningEffort); - options.push({ - value: effort.reasoningEffort, - label: REASONING_LABELS[effort.reasoningEffort], - }); - } - return options; - }, [previewDefaultModel]); const activeReasoningOptions = isPreviewing - ? previewReasoningOptions + ? (previewSelection?.reasoningOptions ?? []) : reasoningOptions; const activeModelLoadError = isPreviewing ? (previewQuery.data?.modelLoadError ?? null) @@ -464,7 +509,9 @@ export function ModelReasoningPicker({ const activeModelFailureMessage = activeModelLoadErrorMessage ?? "Could not load models."; const activeModelOptions = previewModelOptions; - const activeMoreModelOptions = previewMoreModelOptions; + const activeMoreModelOptions = previewSelectionBlocked + ? EMPTY_MODEL_OPTIONS + : previewMoreModelOptions; const hasActiveModelOptions = activeModelOptions.length > 0; const activeModelErrorIsProviderSpecific = activeModelLoadErrorMatches && activeModelLoadError !== null; @@ -479,17 +526,18 @@ export function ModelReasoningPicker({ // Filtered model lists (client-side fuzzy search scoped to the active // provider). Matching uses the rendered (brand-stripped) label plus the model // id so search reflects what the user sees. + const activeBrandPrefix = activeProvider?.brandPrefix; const filteredModelOptions = useMemo(() => { return fuzzyFilter(activeModelOptions, normalizedQuery, (option) => - modelSearchText(option, activeProviderId), + modelSearchText(option, activeBrandPrefix), ); - }, [activeModelOptions, normalizedQuery, activeProviderId]); + }, [activeModelOptions, normalizedQuery, activeBrandPrefix]); const filteredMoreModelOptions = useMemo(() => { return fuzzyFilter(activeMoreModelOptions, normalizedQuery, (option) => - modelSearchText(option, activeProviderId), + modelSearchText(option, activeBrandPrefix), ); - }, [activeMoreModelOptions, normalizedQuery, activeProviderId]); + }, [activeMoreModelOptions, normalizedQuery, activeBrandPrefix]); // The single navigable-row model that drives arrow keys, Enter, active // highlighting, and active-descendant ids. @@ -559,19 +607,20 @@ export function ModelReasoningPicker({ const handleModelSelect = useCallback( (model: string) => { + if (previewSelectionBlocked) return; onModelChange(model); setMoreModelsOpen(false); setPreviewProviderId(null); }, - [onModelChange], + [onModelChange, previewSelectionBlocked], ); const handleProviderSelect = useCallback( (providerId: string) => { onSelectedProviderChange?.(providerId); - setPreviewProviderId( - open && providerId !== selectedProviderId ? providerId : null, - ); + const nextPreviewProviderId = + open && providerId !== selectedProviderId ? providerId : null; + setPreviewProviderId(nextPreviewProviderId); // Every provider owns a different model list. Never carry a filter or // keyboard highlight across that boundary. setSearchQuery(""); @@ -621,12 +670,17 @@ export function ModelReasoningPicker({ }, [disabled, isFocusedPane, isSplitPane], ); - useAppCommandContext("modelPickerOpen", open && !disabled); + useAppCommandContext( + "modelPickerOpen", + commandShortcutsEnabled && open && !disabled, + ); const ownsCycleChord = (target: EventTarget | null): boolean => + commandShortcutsEnabled && ownsModelPickerCycleChord({ open, ...resolveCommandScope(target) }); useAppCommandHandler( "modelPicker.toggle", ({ target }) => { + if (!commandShortcutsEnabled) return false; const action = resolveModelPickerToggle({ open, ...resolveCommandScope(target), @@ -636,6 +690,7 @@ export function ModelReasoningPicker({ return true; }, 50, + commandShortcutsEnabled, ); // The cycle chords rotate the COMMITTED provider and its lists, never a // previewed tab's, so the shortcut means the same thing whether the popover is @@ -661,6 +716,7 @@ export function ModelReasoningPicker({ return true; }, 50, + commandShortcutsEnabled, ); useIndexedAppCommandHandlers( PROVIDER_CYCLE_COMMANDS, @@ -678,6 +734,7 @@ export function ModelReasoningPicker({ return true; }, 50, + commandShortcutsEnabled, ); useIndexedAppCommandHandlers( REASONING_CYCLE_COMMANDS, @@ -695,14 +752,16 @@ export function ModelReasoningPicker({ return true; }, 50, + commandShortcutsEnabled, ); const handleReasoningSelect = useCallback( (level: ReasoningLevel) => { + if (previewSelectionBlocked) return; // A controlled parent that has not rendered the provider change yet // still needs a concrete model when the user immediately picks one of // the new provider's reasoning levels. - if (isPreviewing && previewDefaultModel) { - onModelChange(previewDefaultModel.model); + if (isPreviewing && previewSelection?.selectedModel) { + onModelChange(previewSelection.selectedModel); } onReasoningChange(level); // Keep the combined picker open so the model and reasoning effort can be @@ -710,7 +769,13 @@ export function ModelReasoningPicker({ setPreviewProviderId(null); setMoreModelsOpen(false); }, - [isPreviewing, previewDefaultModel, onModelChange, onReasoningChange], + [ + isPreviewing, + previewSelection, + onModelChange, + onReasoningChange, + previewSelectionBlocked, + ], ); const handleFooterActionClick = useCallback(() => { @@ -804,7 +869,6 @@ export function ModelReasoningPicker({ triggerReasoningLabel ? ` · ${triggerReasoningLabel} reasoning` : "", showSelectedFastMode ? " (Fast mode)" : "", ].join(""); - // The trigger renders identically whether interactive or disabled — the only // difference is the `disabled` button state and a dropped chevron — so fully // read-only surfaces show the same model label in the same position as their @@ -917,13 +981,15 @@ export function ModelReasoningPicker({ {trigger} @@ -1004,7 +1070,10 @@ export function ModelReasoningPicker({ className={cn( "overflow-y-auto px-1 pb-1 pt-0", !isCompactViewport && - "max-h-[min(250px,var(--radix-popover-content-available-height,250px)-80px)]", + // Preserve the model scroller's intended height when the outer + // menu is capped; otherwise this flex item collapses before the + // full desktop menu can scroll to the reasoning controls. + "shrink-0 max-h-[min(250px,var(--radix-popover-content-available-height,250px)-80px)]", )} > {isShowingModelError ? null : ( @@ -1038,14 +1107,15 @@ export function ModelReasoningPicker({ role={showSearchInput ? "option" : undefined} isActive={active} // The menu always reflects the provider whose models it - // lists (committed or previewed) — strip with - // `activeProviderId`. + // lists (committed or previewed) — strip with the + // active provider's declared prefix. label={stripModelBrandPrefix( option.label, - activeProviderId, + activeBrandPrefix, )} qualifier={option.routeProviderId} selected={!isPreviewing && option.value === modelValue} + disabled={previewSelectionBlocked} onClick={() => handleModelSelect(option.value)} /> ); @@ -1061,7 +1131,7 @@ export function ModelReasoningPicker({ open={moreModelsOpen} onOpenChange={setMoreModelsOpen} openSub={openSub} - activeProviderId={activeProviderId} + activeBrandPrefix={activeBrandPrefix} isPreviewing={isPreviewing} modelValue={modelValue} options={filteredMoreModelOptions} @@ -1091,6 +1161,9 @@ export function ModelReasoningPicker({ ) : activeModelLoadFailed ? ( activeModelFailureMessage @@ -1114,6 +1187,7 @@ export function ModelReasoningPicker({ key={option.value} label={option.label} selected={!isPreviewing && option.value === reasoningValue} + disabled={previewSelectionBlocked} onClick={() => handleReasoningSelect(option.value)} /> ))} @@ -1257,7 +1331,7 @@ function MoreModelsSubmenu({ open, onOpenChange, openSub, - activeProviderId, + activeBrandPrefix, isPreviewing, modelValue, options, @@ -1266,7 +1340,7 @@ function MoreModelsSubmenu({ open: boolean; onOpenChange: (open: boolean) => void; openSub: () => void; - activeProviderId: string; + activeBrandPrefix: string | undefined; isPreviewing: boolean; modelValue: string; options: readonly ModelPickerOption[]; @@ -1357,7 +1431,7 @@ function MoreModelsSubmenu({ {options.map((option) => ( onSelect(option.value)} @@ -1387,6 +1461,7 @@ function MenuRowButton({ label, qualifier, selected, + disabled = false, onClick, isActive, id, @@ -1397,6 +1472,7 @@ function MenuRowButton({ label: string; qualifier?: string; selected: boolean; + disabled?: boolean; onClick: () => void; isActive?: boolean; id?: string; @@ -1415,6 +1491,7 @@ function MenuRowButton({ type="button" id={id} role={role} + disabled={disabled} // In the searchable listbox the active row is the combobox's // aria-activedescendant, so it carries aria-selected; reasoning/submenu // rows keep default button semantics. @@ -1425,6 +1502,7 @@ function MenuRowButton({ LIST_HOVER_TRANSITION, MENU_ITEM_LAST_HOVERED_CLASS, isActive && "bg-state-active", + disabled && "cursor-not-allowed opacity-60", isCompactViewport ? "py-2" : "py-[0.3125rem]", )} {...hoverProps} diff --git a/apps/app/src/components/pickers/OptionPicker.tsx b/apps/app/src/components/pickers/OptionPicker.tsx index 3c1c24be7d..2e220ac9d8 100644 --- a/apps/app/src/components/pickers/OptionPicker.tsx +++ b/apps/app/src/components/pickers/OptionPicker.tsx @@ -18,15 +18,6 @@ import { OPTION_MUTED_CLASS_NAME, OPTION_TRIGGER_CONTENT_CLASS_NAME, } from "@bb/shared-ui/option-display"; -export { - OptionDisplay, - OPTION_BASE_CLASS_NAME, - OPTION_CONTENT_CLASS_NAME, - OPTION_INTERACTIVE_CLASS_NAME, - OPTION_MENU_CONTENT_CLASS_NAME, - OPTION_MUTED_CLASS_NAME, - OPTION_TRIGGER_CONTENT_CLASS_NAME, -} from "@bb/shared-ui/option-display"; // Inline picker triggers keep flat resting chrome (no border/background/shadow // so they sit inline with surrounding text) but use the ghost button variant's @@ -78,7 +69,6 @@ interface OptionPickerProps { compactLabel?: string; description?: string; title?: string; - tone?: "default" | "warning"; }; /** * Render the trigger as a non-interactive, dimmed label showing the same @@ -107,9 +97,7 @@ export function OptionPicker({ showChevronWhenDisabled, }: OptionPickerProps) { const selectedOption = options.find((option) => option.value === value); - const selectedTone = displayOverride - ? (displayOverride.tone ?? "default") - : selectedOption?.tone; + const selectedTone = displayOverride ? "default" : selectedOption?.tone; const selectedIsWarning = selectedTone === "warning"; const SelectedIcon = selectedOption?.icon; const selectedLabel = @@ -215,7 +203,7 @@ export function OptionPicker({ ) : null} {option.label} diff --git a/apps/app/src/components/pickers/PermissionModePicker.tsx b/apps/app/src/components/pickers/PermissionModePicker.tsx index 30c387bb4a..a3237677de 100644 --- a/apps/app/src/components/pickers/PermissionModePicker.tsx +++ b/apps/app/src/components/pickers/PermissionModePicker.tsx @@ -39,6 +39,8 @@ export interface PermissionModePickerProps { defaultOpen?: boolean; /** Whether the menu blocks page interaction. Defaults to Radix's true; pass false in stories. */ modal?: boolean; + /** Horizontal menu alignment. Defaults to "end". */ + align?: "start" | "center" | "end"; /** Temporary effective mode display; does not change the stored permission value. */ displayOverride?: { label: string; @@ -53,6 +55,8 @@ export interface PermissionModePickerProps { disabled?: boolean; /** Keep the chevron visible while disabled, used for plan-mode permission locks. */ showChevronWhenDisabled?: boolean; + /** Show a locked summary when the provider exposes only one mode. */ + showWhenSingleOption?: boolean; } /** @@ -71,15 +75,21 @@ export function PermissionModePicker({ muted = true, defaultOpen, modal, + align = "end", displayOverride, disabled, showChevronWhenDisabled, + showWhenSingleOption = false, }: PermissionModePickerProps) { const compactOptions = useMemo( () => addPermissionModeCompactLabels(options), [options], ); - if (!supported || value === undefined || options.length <= 1) { + if ( + !supported || + value === undefined || + (!showWhenSingleOption && options.length <= 1) + ) { return null; } return ( @@ -93,9 +103,9 @@ export function PermissionModePicker({ muted={muted} defaultOpen={defaultOpen} modal={modal} - align="end" + align={align} displayOverride={displayOverride} - disabled={disabled} + disabled={disabled || options.length <= 1} showChevronWhenDisabled={showChevronWhenDisabled} /> ); diff --git a/apps/app/src/components/pickers/ProjectSelector.tsx b/apps/app/src/components/pickers/ProjectSelector.tsx index dadbb17c09..0b70d8fb0d 100644 --- a/apps/app/src/components/pickers/ProjectSelector.tsx +++ b/apps/app/src/components/pickers/ProjectSelector.tsx @@ -14,7 +14,7 @@ import { OPTION_INTERACTIVE_CLASS_NAME, OPTION_MUTED_CLASS_NAME, OPTION_TRIGGER_CONTENT_CLASS_NAME, -} from "./OptionPicker"; +} from "@bb/shared-ui/option-display"; export interface ProjectSelectorOption { id: string; @@ -27,7 +27,7 @@ export interface ProjectSelectorCreateProjectConfig { isCreating?: boolean; } -export interface ProjectSelectorProps { +interface ProjectSelectorProps { projects: readonly ProjectSelectorOption[]; /** * Selected project id, or `null` for the no-project case. Only emit/accept diff --git a/apps/app/src/components/pickers/WorktreePicker.tsx b/apps/app/src/components/pickers/WorktreePicker.tsx index 1b8f807802..a06ffccd84 100644 --- a/apps/app/src/components/pickers/WorktreePicker.tsx +++ b/apps/app/src/components/pickers/WorktreePicker.tsx @@ -22,7 +22,7 @@ import { OPTION_MENU_CONTENT_CLASS_NAME, OPTION_MUTED_CLASS_NAME, OPTION_TRIGGER_CONTENT_CLASS_NAME, -} from "./OptionPicker"; +} from "@bb/shared-ui/option-display"; const REUSE_THREAD_PREVIEW_LIMIT = 2; @@ -40,7 +40,7 @@ export interface ReuseThreadOption { threads: ReadonlyArray<{ id: string; title: string }>; } -export interface WorktreePickerProps { +interface WorktreePickerProps { options: readonly ReuseThreadOption[]; /** Currently-selected env id, or null when reuse mode is active but no * worktree has been chosen yet. */ diff --git a/apps/app/src/components/pickers/environment-picker-value.ts b/apps/app/src/components/pickers/environment-picker-value.ts index df1112796d..3a6c7ce39b 100644 --- a/apps/app/src/components/pickers/environment-picker-value.ts +++ b/apps/app/src/components/pickers/environment-picker-value.ts @@ -1,12 +1,12 @@ -export type EnvironmentHostMode = "local" | "worktree"; +type EnvironmentHostMode = "local" | "worktree"; -export interface ParsedHostEnvironmentValue { +interface ParsedHostEnvironmentValue { type: "host"; hostId: string; mode: EnvironmentHostMode; } -export interface ParsedReuseEnvironmentValue { +interface ParsedReuseEnvironmentValue { type: "reuse"; /** Null when the user has picked Reuse mode but hasn't chosen a specific * worktree yet. Submit is gated on a non-null id by the resolver. */ diff --git a/apps/app/src/components/pickers/model-brand-prefix.ts b/apps/app/src/components/pickers/model-brand-prefix.ts index d4575cde8f..9c85891922 100644 --- a/apps/app/src/components/pickers/model-brand-prefix.ts +++ b/apps/app/src/components/pickers/model-brand-prefix.ts @@ -1,23 +1,36 @@ +import type { PickerOption } from "./OptionPicker"; + /** - * Drops the brand prefix from a model label once provider context is - * unambiguous (the trigger shows the provider icon; the menu shows provider - * tabs above the model list). "Sonnet 4.6" / "5.5" reads cleaner than - * "Claude Sonnet 4.6" / "GPT-5.5". - * - * Lives at the picker's render site rather than in `formatModelLabel` so - * stories — which hand picker labels in directly — see the same trigger and - * menu output as production paths that go through the formatter. + * A provider tab in the model picker: the generic picker option plus the + * provider's declared copy the composer renders — `strings.brandPrefix`, so + * the rows under it can drop the brand once provider context is unambiguous + * (the trigger shows the provider icon; the menu shows provider tabs above + * the model list; "Sonnet 4.6" / "5.5" reads cleaner than "Claude Sonnet + * 4.6" / "GPT-5.5"), `strings.planModeCopy`, which marks a provider whose + * plan mode changes the permission display, and `strings.installUrl`. + */ +export interface ProviderPickerOption extends PickerOption { + brandPrefix?: string; + planModeCopy?: string; + /** Where to install the provider's CLI, for the missing-executable hint. */ + installUrl?: string; +} + +/** + * Drops the provider's declared brand prefix from a model label. A provider + * that declares none keeps its labels whole. Lives at the picker's render + * site rather than in `formatModelLabel` so stories — which hand picker + * labels in directly — see the same trigger and menu output as production + * paths that go through the formatter. */ export function stripModelBrandPrefix( label: string, - providerId: string, + brandPrefix: string | undefined, ): string { - switch (providerId) { - case "claude-code": - return label.replace(/^Claude\s+/i, ""); - case "codex": - return label.replace(/^GPT-/i, ""); - default: - return label; + if (brandPrefix === undefined || brandPrefix.length === 0) { + return label; } + return label.toLowerCase().startsWith(brandPrefix.toLowerCase()) + ? label.slice(brandPrefix.length).trimStart() + : label; } diff --git a/apps/app/src/components/pickers/model-load-error-message.tsx b/apps/app/src/components/pickers/model-load-error-message.tsx index 03a2aafc7e..ed4b910fda 100644 --- a/apps/app/src/components/pickers/model-load-error-message.tsx +++ b/apps/app/src/components/pickers/model-load-error-message.tsx @@ -5,6 +5,8 @@ import { useUrlAnchorClickHandler } from "@/lib/url-open-routing"; interface ModelLoadErrorMessageProps { error: SystemExecutionOptionsModelLoadError; providerLabel: string; + /** The provider's declared `strings.installUrl`, when it declares one. */ + installUrl?: string; } interface FormatModelLoadErrorTextArgs { @@ -12,28 +14,6 @@ interface FormatModelLoadErrorTextArgs { providerLabel: string; } -const PROVIDER_CLI_HELP_LINKS: Partial< - Record -> = { - codex: { - label: "Codex CLI", - url: "https://developers.openai.com/codex/cli", - }, - "acp-cursor": { - label: "Cursor CLI", - url: "https://cursor.com/docs/cli/installation", - }, -}; - -function providerCliLabel({ - error, - providerLabel, -}: FormatModelLoadErrorTextArgs): string { - return ( - PROVIDER_CLI_HELP_LINKS[error.providerId]?.label ?? `${providerLabel} CLI` - ); -} - export function formatModelLoadErrorText({ error, providerLabel, @@ -47,7 +27,7 @@ export function formatModelLoadErrorText({ } if (error.code === "missing_executable") { - return `Could not load models for ${providerLabel}. Please make sure the ${providerCliLabel({ error, providerLabel })} is installed.`; + return `Could not load models for ${providerLabel}. Please make sure the ${providerLabel} CLI is installed.`; } if (error.code === "auth_required") { @@ -60,28 +40,27 @@ export function formatModelLoadErrorText({ export function ModelLoadErrorMessage({ error, providerLabel, + installUrl, }: ModelLoadErrorMessageProps): ReactNode { - const helpLink = - error.code === "missing_executable" - ? PROVIDER_CLI_HELP_LINKS[error.providerId] - : undefined; - const handleHelpLinkClick = useUrlAnchorClickHandler(helpLink?.url); + const helpUrl = + error.code === "missing_executable" ? installUrl : undefined; + const handleHelpLinkClick = useUrlAnchorClickHandler(helpUrl); if (error.code === "missing_executable") { - if (!helpLink) { + if (helpUrl === undefined) { return formatModelLoadErrorText({ error, providerLabel }); } return ( <> Could not load models for {providerLabel}. Please make sure the{" "} - {helpLink.label} + {providerLabel} CLI {" "} is installed. diff --git a/apps/app/src/components/pickers/modelPickerToggle.ts b/apps/app/src/components/pickers/modelPickerToggle.ts index 5ed6cbf59f..f6fd0c608b 100644 --- a/apps/app/src/components/pickers/modelPickerToggle.ts +++ b/apps/app/src/components/pickers/modelPickerToggle.ts @@ -1,5 +1,5 @@ /** Outcome of a Cmd+Shift+M dispatch for a single model picker instance. */ -export type ModelPickerToggleAction = "open" | "close" | "ignore"; +type ModelPickerToggleAction = "open" | "close" | "ignore"; /** * Which mounted picker a model-picker chord addresses. Every composer registers diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx new file mode 100644 index 0000000000..d7954b9df0 --- /dev/null +++ b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx @@ -0,0 +1,59 @@ +import { useEffect, useRef } from "react"; +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import { appToast } from "@/components/ui/app-toast"; +import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; +import { useResolvedLiveFileTarget } from "@/hooks/useResolvedLiveFileTarget"; +import { getExperimentalFileLocationStart } from "@/lib/live-file-navigation"; + +/** Lazily loaded only after an external-file intent has been accepted. */ +export function AppFileExternalNavigationDispatcher({ + intent, + onSettled, +}: { + intent: ExperimentalFileOpenOptions; + onSettled: () => void; +}) { + const didSettleRef = useRef(false); + const resolvedTarget = useResolvedLiveFileTarget(intent.target, { + enabled: true, + }); + const { isLoading: areLocalTargetsLoading, openPathInPreferredFileTarget } = + useLocalOpenTargets({ + enabled: resolvedTarget.status === "available", + ...(resolvedTarget.status === "available" + ? { openContext: resolvedTarget.openContext } + : {}), + }); + + useEffect(() => { + if ( + didSettleRef.current || + resolvedTarget.status === "loading" || + areLocalTargetsLoading + ) { + return; + } + didSettleRef.current = true; + onSettled(); + if (resolvedTarget.status === "unavailable") { + appToast.error("Failed to open file externally", { + description: "The file target is not available on its declared host.", + }); + return; + } + const location = getExperimentalFileLocationStart(intent.location); + void openPathInPreferredFileTarget({ + columnNumber: location.columnNumber, + lineNumber: location.lineNumber, + path: resolvedTarget.absolutePath, + }); + }, [ + intent.location, + areLocalTargetsLoading, + openPathInPreferredFileTarget, + onSettled, + resolvedTarget, + ]); + + return null; +} diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx new file mode 100644 index 0000000000..6f120d58fb --- /dev/null +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { AppFileExternalNavigationHost } from "./AppFileExternalNavigationHost"; + +const openPreferred = vi.hoisted(() => vi.fn()); +const recordAccepted = vi.fn(); + +vi.mock("@/hooks/useResolvedLiveFileTarget", () => ({ + useResolvedLiveFileTarget: (target: { path: string }) => ({ + status: "available", + absolutePath: `/workspace/${target.path}`, + hostId: "host_1", + openContext: { kind: "local" }, + }), +})); + +vi.mock("@/hooks/useLocalOpenTargets", () => ({ + useLocalOpenTargets: () => ({ + isLoading: false, + openPathInPreferredFileTarget: openPreferred, + }), +})); + +function Probe() { + const navigation = useAppNavigationHost(); + return ( + <> + + + + ); +} + +afterEach(() => { + cleanup(); + openPreferred.mockReset(); + openPreferred.mockResolvedValue(true); + recordAccepted.mockReset(); +}); + +describe("AppFileExternalNavigationHost", () => { + it("resolves and dispatches an accepted intent through the preferred target", async () => { + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Open external" })); + await waitFor( + () => + expect(openPreferred).toHaveBeenCalledWith({ + columnNumber: 3, + lineNumber: 12, + path: "/workspace/src/example.ts", + }), + { timeout: 5_000 }, + ); + }); + + it("dispatches queued intents once each in FIFO order", async () => { + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Open two external" })); + + expect(recordAccepted).toHaveBeenCalledWith(true, true); + await waitFor(() => expect(openPreferred).toHaveBeenCalledTimes(2), { + timeout: 5_000, + }); + expect(openPreferred).toHaveBeenNthCalledWith(1, { + columnNumber: 2, + lineNumber: 10, + path: "/workspace/src/first.ts", + }); + expect(openPreferred).toHaveBeenNthCalledWith(2, { + columnNumber: 4, + lineNumber: 20, + path: "/workspace/src/second.ts", + }); + }); +}); diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx new file mode 100644 index 0000000000..390ede239d --- /dev/null +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx @@ -0,0 +1,77 @@ +import { + lazy, + Suspense, + useCallback, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; + +const MAX_PENDING_EXTERNAL_FILE_INTENTS = 32; +const LazyAppFileExternalNavigationDispatcher = lazy(() => + import("./AppFileExternalNavigationDispatcher").then( + ({ AppFileExternalNavigationDispatcher }) => ({ + default: AppFileExternalNavigationDispatcher, + }), + ), +); + +interface ExternalFileIntentRequest { + id: number; + intent: ExperimentalFileOpenOptions; +} + +/** App-wide preferred-external file dispatcher; discovery starts on activation. */ +export function AppFileExternalNavigationHost({ + children, +}: { + children: ReactNode; +}) { + const [queue, setQueue] = useState([]); + const queueRef = useRef(queue); + const nextRequestIdRef = useRef(0); + const replaceQueue = useCallback((next: ExternalFileIntentRequest[]) => { + queueRef.current = next; + setQueue(next); + }, []); + const openFileExternally = useCallback( + (intent: ExperimentalFileOpenOptions): boolean => { + if (queueRef.current.length >= MAX_PENDING_EXTERNAL_FILE_INTENTS) { + return false; + } + // Public SDK callers are parsed by useBbNavigate before capabilities are + // invoked; this host only queues that already-normalized internal value. + const request = { id: nextRequestIdRef.current, intent }; + nextRequestIdRef.current += 1; + replaceQueue([...queueRef.current, request]); + return true; + }, + [replaceQueue], + ); + const current = queue[0] ?? null; + const settleCurrent = useCallback(() => { + replaceQueue(queueRef.current.slice(1)); + }, [replaceQueue]); + + const capabilities = useMemo( + () => ({ openFileExternally }), + [openFileExternally], + ); + return ( + + {children} + {current === null ? null : ( + + + + )} + + ); +} diff --git a/apps/app/src/components/plugin/ComposerExtensionHost.tsx b/apps/app/src/components/plugin/ComposerExtensionHost.tsx index a3a3766982..33b32dfe06 100644 --- a/apps/app/src/components/plugin/ComposerExtensionHost.tsx +++ b/apps/app/src/components/plugin/ComposerExtensionHost.tsx @@ -16,7 +16,7 @@ import { * extension host keeps the active renderer bound to the same plugin API, * reactive view, and keyboard-command behavior. */ -export interface ComposerExtensionController { +interface ComposerExtensionController { host: PluginComposerHost | null; view: ComposerView; focus(): boolean; diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx new file mode 100644 index 0000000000..18b9b71a49 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; +import { ExperimentalFileLink } from "./ExperimentalFileLink"; + +afterEach(cleanup); + +const target = { + kind: "workspace" as const, + environmentId: "env_1", + path: "src/example.ts", +}; + +describe("ExperimentalFileLink", () => { + it("sends ordinary activation to the shared preview host", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + + example.ts:12 + + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "example.ts:12" })); + expect(openFilePreview).toHaveBeenCalledWith({ + target, + location: { kind: "line", line: 12, column: 4 }, + }); + }); + + it("leaves modifier clicks native", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + example.ts + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "example.ts" }), { + metaKey: true, + }); + expect(openFilePreview).not.toHaveBeenCalled(); + }); + + it("uses a scheme-safe href for a valid scheme-like file name", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + vscode:foo + + + , + ); + const link = screen.getByRole("link", { name: "vscode:foo" }); + expect(link.getAttribute("href")).toBe("./vscode%3Afoo"); + + fireEvent.click(link); + expect(openFilePreview).toHaveBeenCalledWith({ + target: { ...target, path: "vscode:foo" }, + location: null, + }); + }); + + it("renders a malformed target supplied across a JavaScript boundary as inert", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + invalid + + + , + ); + const invalid = screen.getByText("invalid"); + expect(screen.queryByRole("link", { name: "invalid" })).toBeNull(); + expect(invalid.getAttribute("href")).toBeNull(); + fireEvent.click(invalid); + expect(openFilePreview).not.toHaveBeenCalled(); + }); + + it("renders a path with an unpaired UTF-16 surrogate as inert", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + invalid Unicode + + + , + ); + const invalid = screen.getByText("invalid Unicode"); + expect(screen.queryByRole("link", { name: "invalid Unicode" })).toBeNull(); + expect(invalid.getAttribute("href")).toBeNull(); + fireEvent.click(invalid); + expect(openFilePreview).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.tsx new file mode 100644 index 0000000000..e01f56aab1 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalFileLink.tsx @@ -0,0 +1,85 @@ +import { + lazy, + Suspense, + useCallback, + useMemo, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; +import type { ExperimentalFileLinkProps } from "@get-bb/plugin-sdk"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@bb/shared-ui/context-menu"; +import { RouteAnchor } from "@/components/ui/app-route-anchor"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { normalizeExperimentalFileOpenOptions } from "@/lib/live-file-navigation"; + +const LazyExperimentalFileLinkMenu = lazy(() => + import("./ExperimentalFileLinkMenu").then(({ ExperimentalFileLinkMenu }) => ({ + default: ExperimentalFileLinkMenu, + })), +); + +function shouldHandleFileClick( + event: ReactMouseEvent, +): boolean { + return !( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.currentTarget.hasAttribute("download") + ); +} + +/** Host-rendered live-file anchor shared by plugins and BB-owned surfaces. */ +export function ExperimentalFileLink({ + target, + location = null, + onClick, + ...anchorProps +}: ExperimentalFileLinkProps) { + const navigation = useAppNavigationHost(); + const [isMenuOpen, setMenuOpen] = useState(false); + const intent = useMemo( + () => normalizeExperimentalFileOpenOptions({ target, location }), + [location, target], + ); + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onClick?.(event); + if (intent === null || !shouldHandleFileClick(event)) { + return; + } + event.preventDefault(); + navigation.openFilePreview(intent); + }, + [intent, navigation, onClick], + ); + const href = + intent === null ? undefined : `./${encodeURIComponent(intent.target.path)}`; + const anchor = ( + + ); + + if (intent === null) return anchor; + return ( + + {anchor} + + {isMenuOpen ? ( + Loading…} + > + + + ) : null} + + + ); +} diff --git a/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx b/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx new file mode 100644 index 0000000000..8a2bb66f45 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx @@ -0,0 +1,148 @@ +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@bb/shared-ui/context-menu"; +import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; +import { useResolvedLiveFileTarget } from "@/hooks/useResolvedLiveFileTarget"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { copyToClipboardWithToast } from "@/lib/clipboard"; +import { getExperimentalFileLocationStart } from "@/lib/live-file-navigation"; +import { usePluginSlots } from "@/lib/plugin-slots"; + +function getFileBasename(path: string): string { + const normalizedPath = path.replace(/[\\/]+$/u, ""); + return normalizedPath.split(/[\\/]/u).at(-1) ?? path; +} + +function getFileExtension(path: string): string | null { + const name = getFileBasename(path); + const dotIndex = name.lastIndexOf("."); + return dotIndex > 0 && dotIndex < name.length - 1 + ? name.slice(dotIndex + 1).toLowerCase() + : null; +} + +/** Lazily mounted destination discovery for `experimental_FileLink`. */ +export function ExperimentalFileLinkMenu({ + intent, +}: { + intent: ExperimentalFileOpenOptions; +}) { + const navigation = useAppNavigationHost(); + const resolved = useResolvedLiveFileTarget(intent.target, { enabled: true }); + const localTargets = useLocalOpenTargets({ + enabled: resolved.status === "available", + ...(resolved.status === "available" + ? { openContext: resolved.openContext } + : {}), + }); + const { fileOpeners } = usePluginSlots(); + const extension = getFileExtension(intent.target.path); + const matchingOpeners = + extension === null + ? [] + : fileOpeners.filter((opener) => opener.extensions.includes(extension)); + const location = getExperimentalFileLocationStart(intent.location); + + return ( + <> + navigation.openFilePreview(intent)}> + Open preview + + {matchingOpeners.length > 0 ? ( + + Open with + + + navigation.openFilePreview({ ...intent, viewer: "builtin" }) + } + > + BB preview + + {matchingOpeners.map((opener) => ( + + navigation.openFilePreview({ + ...intent, + viewer: { + pluginId: opener.pluginId, + openerId: opener.id, + }, + }) + } + > + {opener.title} + + ))} + + + ) : null} + navigation.openFileExternally(intent)} + > + Open externally + + {resolved.status === "available" && + localTargets.fileOpenTargets.length > 0 ? ( + + Open in + + {localTargets.fileOpenTargets.map((target) => ( + { + void localTargets.openPathInFileTarget({ + columnNumber: location.columnNumber, + lineNumber: location.lineNumber, + path: resolved.absolutePath, + rememberTarget: false, + targetId: target.id, + }); + }} + > + {target.label} + + ))} + + + ) : null} + + { + void copyToClipboardWithToast( + resolved.status === "available" + ? resolved.absolutePath + : intent.target.path, + { + successMessage: "File path copied", + errorMessage: "Failed to copy file path", + }, + ); + }} + > + Copy file path + + { + void copyToClipboardWithToast(getFileBasename(intent.target.path), { + successMessage: "File name copied", + errorMessage: "Failed to copy file name", + }); + }} + > + Copy file name + + + ); +} diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx new file mode 100644 index 0000000000..5b530a7d21 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; +import { ExperimentalUrlLink } from "./ExperimentalUrlLink"; + +afterEach(cleanup); + +describe("ExperimentalUrlLink", () => { + it("sends an ordinary web activation to the navigation host", () => { + const openUrl = vi.fn(() => true); + render( + + + + + Example + + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Example" })); + expect(openUrl).toHaveBeenCalledWith({ url: "https://example.com" }); + }); + + it("leaves modifier clicks native", () => { + const openUrl = vi.fn(() => true); + render( + + + + Example + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Example" }), { + metaKey: true, + }); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it.each(["_blank", "preview-pane"])( + "leaves the explicit %s target native when the URL host would accept it", + (target) => { + const openUrl = vi.fn(() => true); + render( + + + + Example + + + , + ); + const link = screen.getByRole("link", { name: "Example" }); + expect(link.getAttribute("target")).toBe(target); + expect(link.getAttribute("rel")).toBe("noopener noreferrer"); + expect(fireEvent.click(link)).toBe(true); + expect(openUrl).not.toHaveBeenCalled(); + }, + ); + + it("preserves an explicit rel for a named target", () => { + render( + + + Example + + , + ); + expect( + screen.getByRole("link", { name: "Example" }).getAttribute("rel"), + ).toBe("opener"); + }); + + it("preserves rel tokens without sacrificing named-target isolation", () => { + render( + + + Example + + , + ); + expect( + screen.getByRole("link", { name: "Example" }).getAttribute("rel"), + ).toBe("nofollow noopener noreferrer"); + }); + + it("does not add new-context rel tokens to a same-context target", () => { + render( + + + Example + + , + ); + expect( + screen.getByRole("link", { name: "Example" }).getAttribute("rel"), + ).toBeNull(); + }); + + it("routes internal links through browser history before URL preferences", () => { + const openUrl = vi.fn(() => true); + render( + + + + Settings + + Settings route
} /> + + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Settings" })); + expect(screen.getByText("Settings route")).toBeTruthy(); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it("keeps an explicit target on an internal route native", () => { + const openUrl = vi.fn(() => true); + render( + + + + + Settings in new context + + + Settings route
} /> + + + + , + ); + const link = screen.getByRole("link", { + name: "Settings in new context", + }); + expect(link.getAttribute("target")).toBe("_blank"); + expect(fireEvent.click(link)).toBe(true); + expect(screen.queryByText("Settings route")).toBeNull(); + expect(openUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx new file mode 100644 index 0000000000..a2564f5937 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx @@ -0,0 +1,92 @@ +import { useCallback, type MouseEvent as ReactMouseEvent } from "react"; +import type { ExperimentalUrlLinkProps } from "@get-bb/plugin-sdk"; +import { RouteAnchor } from "@/components/ui/app-route-anchor"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { resolveRouteHref } from "@/lib/route-paths"; + +function shouldHandleUrlClick( + event: ReactMouseEvent, +): boolean { + if ( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.currentTarget.hasAttribute("download") || + event.currentTarget.hasAttribute("target") + ) { + return false; + } + return true; +} + +function isCurrentAppRoute(href: string): boolean { + return ( + typeof window !== "undefined" && + resolveRouteHref({ currentOrigin: window.location.origin, href }) !== null + ); +} + +/** Host-rendered URL link shared by plugins and first-party app surfaces. */ +export function ExperimentalUrlLink({ + href, + onClick, + rel, + target, + ...anchorProps +}: ExperimentalUrlLinkProps) { + const navigation = useAppNavigationHost(); + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onClick?.(event); + if ( + !shouldHandleUrlClick(event) || + isCurrentAppRoute(href) || + !navigation.openUrl({ url: href }) + ) { + return; + } + event.preventDefault(); + }, + [href, navigation, onClick], + ); + const normalizedTarget = target?.toLowerCase(); + const opensNewBrowsingContext = + normalizedTarget !== undefined && + normalizedTarget !== "" && + normalizedTarget !== "_self" && + normalizedTarget !== "_parent" && + normalizedTarget !== "_top" && + normalizedTarget !== "_unfencedtop"; + const relTokens = rel?.split(/\s+/u).filter(Boolean) ?? []; + const normalizedRelTokens = relTokens.map((token) => token.toLowerCase()); + const resolvedRel = + opensNewBrowsingContext && !normalizedRelTokens.includes("opener") + ? [ + ...relTokens, + ...(normalizedRelTokens.includes("noopener") ? [] : ["noopener"]), + ...(normalizedRelTokens.includes("noreferrer") ? [] : ["noreferrer"]), + ].join(" ") + : rel; + if (target !== undefined) { + return ( + + ); + } + return ( + + ); +} diff --git a/apps/app/src/components/plugin/PluginComposerActions.stories.tsx b/apps/app/src/components/plugin/PluginComposerActions.stories.tsx index 9cc3d43907..e77ee0850b 100644 --- a/apps/app/src/components/plugin/PluginComposerActions.stories.tsx +++ b/apps/app/src/components/plugin/PluginComposerActions.stories.tsx @@ -28,7 +28,7 @@ import { type PluginRegistrationSet, } from "@/lib/plugin-slots"; import { setPluginThreadRowStatus } from "@/lib/plugin-thread-row-status"; -import type { PromptDraftState } from "@/lib/prompt-draft"; +import type { PromptDraftState } from "@bb/client-core"; import { ThreadRow, type ThreadRowOptions, diff --git a/apps/app/src/components/plugin/PluginComposerActions.tsx b/apps/app/src/components/plugin/PluginComposerActions.tsx index 9aa959fa60..5bafd009d5 100644 --- a/apps/app/src/components/plugin/PluginComposerActions.tsx +++ b/apps/app/src/components/plugin/PluginComposerActions.tsx @@ -28,7 +28,7 @@ import { useOptionalPluginComposerView, } from "./plugin-composer-host"; -export const PLUGIN_COMPOSER_INLINE_PLUGIN_LIMIT = 3; +const PLUGIN_COMPOSER_INLINE_PLUGIN_LIMIT = 3; type PluginComposerActionContribution = ResolvedComposerAction; diff --git a/apps/app/src/components/plugin/PluginDiff.tsx b/apps/app/src/components/plugin/PluginDiff.tsx new file mode 100644 index 0000000000..8061eb8cb9 --- /dev/null +++ b/apps/app/src/components/plugin/PluginDiff.tsx @@ -0,0 +1,52 @@ +import { useMemo } from "react"; +import type { DiffProps } from "@get-bb/plugin-sdk"; +import { DiffHost } from "@/components/code/DiffHost"; +import { normalizeFilePatch } from "@/components/git-diff/git-diff-parsing"; +import { cn } from "@bb/shared-ui/lib/utils"; + +/** + * The public `experimental_Diff` component. It normalizes whatever patch shape + * the caller has (a `git diff` patch, a GitHub REST patch, a single `@@` hunk) + * into one the renderer understands, then hands it to the host boundary. + * Content that does not parse as a patch degrades to plain monospace text + * rather than to an empty diff. Full-file enrichment stays behind the lazy + * built-in renderer so a replacement that never delegates pays none of its + * parsing cost. + */ +export function PluginDiff({ + patch, + path, + view, + overflow, + showLineNumbers, + experimental_fullFileContents: fullFileContents, + className, +}: DiffProps) { + const normalized = useMemo( + () => normalizeFilePatch({ patch, path }), + [patch, path], + ); + if (normalized === null) { + return ( +
+        {patch}
+      
+ ); + } + return ( + + ); +} diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx index cdab71532c..e8d060c072 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx @@ -80,7 +80,7 @@ const BUILTIN_NAV_ROW_PLUGIN_ID = "__builtin__"; * "tools" so an order or hidden list saved under the row's old name keeps * naming the same row. */ -export const TOOLS_NAV_ROW_KEY = getPluginNavPanelKey({ +const TOOLS_NAV_ROW_KEY = getPluginNavPanelKey({ pluginId: BUILTIN_NAV_ROW_PLUGIN_ID, id: "tools", }); diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index 7790119d7d..9c79e421d4 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -38,7 +38,7 @@ import { } from "@/components/pickers/environment-picker-value"; import { useRootComposeReuseEnvironment } from "@/lib/root-compose-selection"; import { getPromptDraftAccessor } from "@/hooks/usePromptDraftStorage"; -import { buildThreadHandoffLocationState } from "@/lib/thread-handoff-request"; +import { buildThreadHandoffLocationState } from "@bb/client-core"; import { makeThreadListEntry } from "@/test/fixtures/thread-list-entries"; import { RootComposeView } from "@/views/RootComposeView"; import { PluginNewThreadComposer } from "./PluginNewThreadComposer"; @@ -141,7 +141,7 @@ vi.mock("@/hooks/queries/host-queries", () => ({ })); vi.mock("@/hooks/queries/system-queries", () => ({ - useOnboardingAgents: () => ({ data: undefined, isPending: false }), + useSystemProviderStates: () => ({ data: undefined, isPending: false }), useHostProviderCliStatus: () => ({ data: undefined }), useSystemConfig: () => ({ data: { primaryHostId: "host_1" } }), useSystemExecutionOptions: () => ({ @@ -327,9 +327,7 @@ function ForkSeedSurface({ composer }: { composer: NewThreadComposerState }) { useEffect(() => { seedEnvironmentSelectionValue(encodeReuseValue("env-source")); }, [seedEnvironmentSelectionValue]); - return composer.renderPromptBox({ - zenModeStorageKey: "bb.promptbox.zen-mode.test-root-fork", - }); + return composer.renderPromptBox({}); } function composerElement( diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.tsx index 4bbdae7297..9e6f0ffb17 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.tsx @@ -35,9 +35,8 @@ export function PluginNewThreadComposer({ const [pickedProjectId, setPickedProjectId] = useState( defaultProjectId ?? null, ); - const [seededDefaultProjectId, setSeededDefaultProjectId] = useState( - defaultProjectId, - ); + const [seededDefaultProjectId, setSeededDefaultProjectId] = + useState(defaultProjectId); if (seededDefaultProjectId !== defaultProjectId) { setSeededDefaultProjectId(defaultProjectId); setPickedProjectId(defaultProjectId ?? null); @@ -80,7 +79,6 @@ export function PluginNewThreadComposer({ {renderPromptBox({ placeholder, allowNoProject: true, - zenModeStorageKey: `bb.promptbox.zen-mode.plugin-new-thread.${composerKey}`, })}
)} diff --git a/apps/app/src/components/plugin/PluginPanelActions.tsx b/apps/app/src/components/plugin/PluginPanelActions.tsx index bef6ad20e7..43e06073b7 100644 --- a/apps/app/src/components/plugin/PluginPanelActions.tsx +++ b/apps/app/src/components/plugin/PluginPanelActions.tsx @@ -35,7 +35,7 @@ export interface OpenPluginPanelArgs { paramsJson: string | null; } -export type OpenPluginPanelHandler = (args: OpenPluginPanelArgs) => void; +type OpenPluginPanelHandler = (args: OpenPluginPanelArgs) => void; /** One launcher row for a plugin action, ready to render + invoke. */ export interface PluginPanelActionEntry { @@ -213,7 +213,7 @@ export function usePluginNewThreadPanelActions({ ); } -export type PluginPanelSurfaceContext = +type PluginPanelSurfaceContext = | { kind: "thread"; threadId: string } | { kind: "new-thread"; projectId: string | null }; @@ -383,7 +383,11 @@ function FileOpenerTabContent({ > {(opener, BoundOriginal) => (
ReactNode; + experimental_target?: { + validate(value: import("@get-bb/plugin-sdk").JsonValue): boolean; + }; + layout?: "padded" | "flush"; +} + +interface TestFileOpenerRegistration { + id: string; + title: string; + extensions: string[]; + component: () => ReactNode; + pluginId: string; + generation: number; +} + +interface TestNewThreadPanelActionRegistration { + id: string; + title: string; + component: (props: { + projectId: string | null; + params: import("@get-bb/plugin-sdk").JsonValue | null; + }) => ReactNode; layout?: "padded" | "flush"; + pluginId: string; + generation: number; } const browserState = vi.hoisted(() => ({ available: false })); +const viewportState = vi.hoisted(() => ({ isCompactViewport: false })); const createTerminal = vi.hoisted(() => vi.fn()); const threadTabsApi = vi.hoisted(() => ({ get: vi.fn(), @@ -75,6 +107,8 @@ const terminalQueryState = vi.hoisted(() => ({ const fixedTabState = vi.hoisted(() => ({ panelRegistered: true, registrations: [] as TestFixedTabRegistration[], + fileOpeners: [] as TestFileOpenerRegistration[], + newThreadPanelActions: [] as TestNewThreadPanelActionRegistration[], })); const hostState = vi.hoisted(() => ({ hosts: [ @@ -83,6 +117,15 @@ const hostState = vi.hoisted(() => ({ ], primaryHostId: "host-1", })); +const secondaryPanelState = vi.hoisted(() => ({ + fixedTabs: [] as Array<{ + contentFillsRegion: boolean; + hasRenderer: boolean; + title: string; + }>, + splitPanelStateId: undefined as string | undefined, + tabKinds: [] as string[], +})); vi.mock("@/lib/sdk", async (importOriginal) => { const actual = await importOriginal(); @@ -99,7 +142,7 @@ vi.mock("@/lib/sdk", async (importOriginal) => { }); vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ - useIsCompactViewport: () => false, + useIsCompactViewport: () => viewportState.isCompactViewport, })); vi.mock("@/components/commands/AppCommandProvider", () => ({ @@ -109,7 +152,8 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({ vi.mock("@/lib/plugin-slots", () => ({ usePluginSlots: () => ({ - fileOpeners: [], + fileOpeners: fixedTabState.fileOpeners, + newThreadPanelActions: fixedTabState.newThreadPanelActions, navPanels: fixedTabState.panelRegistered ? [ { @@ -235,63 +279,99 @@ vi.mock("@/components/secondary-panel/SecondaryPanelLayout", () => ({ vi.mock("@/components/secondary-panel/ThreadSecondaryPanel", () => ({ ThreadSecondaryPanel: ({ - browserDeck, - fileTabs, - fileTabContent, + activeTab, + tabs, fixedTabs, - fixedTabContent, onClose, onOpenNewTab, - topChromeSurface, + renderBrowserDeck, + splitPanelStateId, }: { - browserDeck: ReactNode; - fileTabs: Array<{ - id: string; - filename: string; + activeTab: { id: string } | null; + tabs: Array<{ + contentFillsRegion?: boolean; + label: string; onClose: () => void; onSelect: () => void; + renderContent: (pane: { + isFocused: boolean; + onFocusPane: () => void; + }) => ReactNode; + tab: { id: string; kind: string }; }>; - fileTabContent: ReactNode; fixedTabs: Array<{ tab: { id: string }; title: string; onSelect: () => void; + contentFillsRegion?: boolean; + renderContent?: (pane: { + isFocused: boolean; + onFocusPane: () => void; + }) => ReactNode; }>; - fixedTabContent: ReactNode; onClose: () => void; onOpenNewTab: () => void; - topChromeSurface?: "panel" | "page"; - }) => ( -
- ))} - {fixedTabs.map((tab) => ( - - ))} - - + + + + + + ); +} + function renderHost(panelPath = "board", subPath = "", store = createStore()) { const panelStateId = getPluginPagePanelStateId({ panelPath, @@ -373,6 +584,7 @@ function renderHost(panelPath = "board", subPath = "", store = createStore()) { subPath={subPath} >
Plugin page
+ @@ -383,6 +595,7 @@ function renderHost(panelPath = "board", subPath = "", store = createStore()) { describe("PluginPanelRightPanelHost", () => { beforeEach(() => { browserState.available = false; + viewportState.isCompactViewport = false; createTerminal.mockReset(); createTerminal.mockResolvedValue({ id: "terminal-1" }); threadTabsApi.get.mockReset(); @@ -391,6 +604,11 @@ describe("PluginPanelRightPanelHost", () => { threadTabsApi.update.mockResolvedValue({ revision: 5, tabs: [] }); fixedTabState.panelRegistered = true; fixedTabState.registrations = []; + fixedTabState.fileOpeners = []; + fixedTabState.newThreadPanelActions = []; + secondaryPanelState.fixedTabs = []; + secondaryPanelState.splitPanelStateId = undefined; + secondaryPanelState.tabKinds = []; localStorage.clear(); // Clearing storage is not enough on its own: the per-thread atoms cache // whatever storage held when they were first created. @@ -401,6 +619,29 @@ describe("PluginPanelRightPanelHost", () => { cleanup(); }); + // The host's own trigger is portaled into the page header, so it does not + // inherit the glyph the thread header resolves. A compact viewport opens + // this panel as a bottom drawer (SecondaryPanelLayout), and the trigger has + // to disclose that edge. + it("shows the drawer glyph on the trigger for a compact viewport", async () => { + viewportState.isCompactViewport = true; + renderHost(); + + const showButton = await screen.findByRole("button", { + name: "Show right panel", + }); + expect(showButton.querySelector('[data-icon="PanelBottom"]')).toBeTruthy(); + }); + + it("shows the side-panel glyph on the trigger for a wide viewport", async () => { + renderHost(); + + const showButton = await screen.findByRole("button", { + name: "Show right panel", + }); + expect(showButton.querySelector('[data-icon="PanelRight"]')).toBeTruthy(); + }); + it("keeps one panel toggle and mounts the collapsed panel before opening", async () => { renderHost(); @@ -408,7 +649,6 @@ describe("PluginPanelRightPanelHost", () => { const collapsedPanel = await screen.findByTestId( "shared-thread-secondary-panel", ); - expect(collapsedPanel.dataset.topChromeSurface).toBe("panel"); await waitFor(() => expect( screen @@ -461,12 +701,14 @@ describe("PluginPanelRightPanelHost", () => { } fixedTabState.registrations = [ { + panelId: "board", id: "navigation", title: "Navigation", icon: "PanelRight", component: Navigation, }, { + panelId: "board", id: "details", title: "Details", icon: "Info", @@ -477,6 +719,21 @@ describe("PluginPanelRightPanelHost", () => { renderHost("board", "task/123"); + expect(secondaryPanelState.splitPanelStateId).toBe( + getPluginPagePanelStateId({ + panelPath: "board", + pluginId: "demo", + }), + ); + expect(secondaryPanelState.fixedTabs).toEqual([ + { + contentFillsRegion: false, + hasRenderer: true, + title: "Navigation", + }, + { contentFillsRegion: true, hasRenderer: true, title: "Details" }, + ]); + expect( screen .getByTestId("shared-secondary-panel-region") @@ -505,9 +762,216 @@ describe("PluginPanelRightPanelHost", () => { ).toBe(false); }); + it("retains a validated fixed-tab target across panel and route remounts for the app session", async () => { + function Details() { + const targetState = useAppFixedTabTarget( + getPluginFixedTabOwnerId("demo", "board"), + "details", + ); + return ( +
+ Details + {targetState === null ? null : ( + <> + {JSON.stringify(targetState.target)} + + + )} +
+ ); + } + fixedTabState.registrations = [ + { + panelId: "board", + id: "navigation", + title: "Navigation", + icon: "PanelRight", + component: () =>
Navigation
, + }, + { + panelId: "board", + id: "details", + title: "Details", + icon: "Info", + component: Details, + experimental_target: { + validate: (value) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + value.kind === "record" && + typeof value.recordId === "string", + }, + }, + ]; + browserState.available = true; + + const store = createStore(); + const initialRender = renderHost("board", "", store); + expect(await screen.findByTestId("navigation-content")).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: "Open invalid fixed tab target" }), + ); + expect(screen.getByTestId("navigation-content")).toBeTruthy(); + expect(screen.queryByTestId("targeted-details-content")).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Open targeted fixed tab" }), + ); + expect(await screen.findByTestId("targeted-details-content")).toBeTruthy(); + expect( + screen.getByText('{"kind":"record","recordId":"issue-42"}'), + ).toBeTruthy(); + + fireEvent.click(screen.getByText("Add tab")); + expect(await screen.findByTestId("plugin-page-new-tab")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Open browser" })); + expect(await screen.findByTestId("plugin-page-browser")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Details" })); + expect( + await screen.findByText('{"kind":"record","recordId":"issue-42"}'), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Hide right panel" })); + expect(screen.queryByTestId("targeted-details-content")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show right panel" })); + expect( + await screen.findByText('{"kind":"record","recordId":"issue-42"}'), + ).toBeTruthy(); + + initialRender.unmount(); + const routeRemount = renderHost("board", "", store); + expect( + await screen.findByText('{"kind":"record","recordId":"issue-42"}'), + ).toBeTruthy(); + + routeRemount.unmount(); + renderHost(); + expect(await screen.findByTestId("navigation-content")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Details" })); + expect(await screen.findByTestId("targeted-details-content")).toBeTruthy(); + expect(screen.queryByText(/issue-42/)).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Open targeted fixed tab" }), + ); + expect( + await screen.findByText('{"kind":"record","recordId":"issue-42"}'), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Clear target" })); + expect(screen.queryByRole("button", { name: "Clear target" })).toBeNull(); + const persistedValues = Array.from( + { length: localStorage.length }, + (_, index) => localStorage.getItem(localStorage.key(index) ?? "") ?? "", + ).join("\n"); + expect(persistedValues).not.toContain("issue-42"); + }); + + it("opens every explicit live-file identity through the shared panel host", async () => { + renderHost(); + + fireEvent.click( + screen.getByRole("button", { name: "Open workspace file" }), + ); + expect( + await screen.findByText("workspace:env-explicit:src/example.ts"), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Open host file" })); + expect( + await screen.findByText("host:host-explicit:/tmp/example.log"), + ).toBeTruthy(); + expect( + screen.getByTestId("host-scoped-file-preview").dataset.panelOpen, + ).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "Hide right panel" })); + await waitFor(() => { + expect( + screen.getByTestId("host-scoped-file-preview").dataset.panelOpen, + ).toBe("false"); + }); + + fireEvent.click(screen.getByRole("button", { name: "Open storage file" })); + expect( + await screen.findByText("storage:thr-explicit:reports/result.md"), + ).toBeTruthy(); + }); + + it("gives plugin-page file openers the full content region", async () => { + fixedTabState.fileOpeners = [ + { + id: "editor", + title: "Demo editor", + extensions: ["ts"], + component: () =>
Plugin file editor
, + pluginId: "demo", + generation: 1, + }, + ]; + renderHost(); + + fireEvent.click( + screen.getByRole("button", { name: "Open workspace file" }), + ); + + expect(await screen.findByText("Plugin file editor")).toBeTruthy(); + expect( + screen.getByTestId("shared-thread-secondary-panel").dataset + .fileTabContentFillsRegion, + ).toBe("true"); + }); + + it("lets a restored padded action own its single padded scroll frame", async () => { + fixedTabState.newThreadPanelActions = [ + { + id: "canvas", + title: "Canvas", + component: () =>
Plugin canvas
, + layout: "padded", + pluginId: "demo", + generation: 1, + }, + ]; + const panelStateId = getPluginPagePanelStateId({ + panelPath: "board", + pluginId: "demo", + }); + const actionTab = createPluginPanelFixedPanelTab({ + actionId: "canvas", + paramsJson: null, + pluginId: "demo", + title: "Canvas", + }); + localStorage.setItem( + getFixedPanelTabsStateStorageKey({ threadId: panelStateId }), + serializeFixedPanelTabsState({ + state: createEmptyFixedPanelTabsState({ + lastUsedAt: Date.now(), + secondary: { + activeTabId: actionTab.id, + isOpen: true, + tabs: [actionTab], + }, + }), + }), + ); + + renderHost(); + + expect(await screen.findByText("Plugin canvas")).toBeTruthy(); + expect( + screen.getByTestId("shared-thread-secondary-panel").dataset + .fileTabContentFillsRegion, + ).toBe("true"); + }); + it("does not reopen fixed tabs after navigating away and back", async () => { fixedTabState.registrations = [ { + panelId: "board", id: "navigation", title: "Navigation", icon: "PanelRight", @@ -555,6 +1019,7 @@ describe("PluginPanelRightPanelHost", () => { it("preserves a closed fixed tab while its plugin registration is loading", async () => { fixedTabState.registrations = [ { + panelId: "board", id: "navigation", title: "Navigation", icon: "PanelRight", @@ -606,6 +1071,7 @@ describe("PluginPanelRightPanelHost", () => { ); expect(await screen.findByTestId("plugin-page-browser")).toBeTruthy(); + expect(secondaryPanelState.tabKinds).toContain("browser"); fireEvent.click(screen.getByRole("button", { name: "Close Browser" })); expect( await screen.findByRole("button", { name: "Show right panel" }), @@ -669,6 +1135,7 @@ describe("PluginPanelRightPanelHost", () => { }), ); expect(await screen.findByTestId("plugin-page-terminal")).toBeTruthy(); + expect(secondaryPanelState.tabKinds).toContain("terminal"); }); it("keeps a restored thread-targeted terminal out of thread tab sync", async () => { diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx index 0c7a127d7a..a6ed1fc404 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx @@ -7,9 +7,11 @@ import { type ReactNode, } from "react"; import { createPortal } from "react-dom"; -import { atom, useAtom } from "jotai"; +import { atom, useAtom, useAtomValue, useStore } from "jotai"; import { atomFamily } from "jotai-family"; -import type { Host } from "@bb/domain"; +import type { Host, JsonValue } from "@bb/domain"; +import { jsonValueSchema } from "@bb/domain"; +import type { ExperimentalPluginFixedTabDeclaration } from "@get-bb/plugin-sdk"; import { Button } from "@bb/shared-ui/button"; import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { COARSE_POINTER_HEADER_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; @@ -19,17 +21,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { useAppCommandHandler } from "@/components/commands/AppCommandProvider"; import { PluginIcon } from "@/components/plugin/PluginIcon"; import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; +import { getRightPanelToggleIconName } from "@/components/secondary-panel/panelToggleControlState"; import { SecondaryPanelLayout } from "@/components/secondary-panel/SecondaryPanelLayout"; import { LazyBrowserTabDeck, + LazyHostScopedFilePreviewTabContent, LazyNewTabPage, LazyThreadSecondaryPanel, + LazyThreadStorageFilePreviewTabContent, LazyThreadTerminalPanel, + LazyWorkspaceFilePreviewTabContent, } from "@/components/secondary-panel/lazySecondaryPanelComponents"; -import type { SecondaryPanelFixedTab } from "@/components/secondary-panel/ThreadSecondaryPanel"; -import type { SecondaryPanelFileTab } from "@/components/secondary-panel/secondaryPanelFileTab"; +import type { + SecondaryPanelFixedTab, + SecondaryPanelRenderableTab, +} from "@/components/secondary-panel/ThreadSecondaryPanel"; import { useThreadFileTabs } from "@/components/secondary-panel/useThreadFileTabs"; -import { terminalStatusLabel } from "@/components/thread/terminal/useThreadTerminalController"; import { useCloseFixedSecondaryPanel, useReconciledFixedPanelTabsState, @@ -40,9 +47,11 @@ import { createPluginPageFixedPanelTab, createTerminalFixedPanelTab, type PluginPageFixedPanelTab, + type SecondaryFileFixedPanelTab, type TerminalFixedPanelTab, } from "@/lib/fixed-panel-tabs-state"; -import { activateSecondaryPanelTabInState } from "@/components/secondary-panel/secondaryPanelTabState"; +import { createFileOpenerOriginalTab } from "./file-opener-tabs"; +import { activateSecondaryPanelTabInState } from "@bb/client-core"; import { useCloseTerminal, useCreateTerminal, @@ -56,13 +65,30 @@ import { } from "@/lib/bb-desktop"; import { getBrowserUrlHost } from "@/lib/browser-url"; import { isRoutePath } from "@/lib/route-paths"; +import { UrlOpenRoutingProvider } from "@/lib/url-open-routing"; import { usePluginSlots } from "@/lib/plugin-slots"; +import { + AppNavigationHostProvider, + type AppFilePreviewIntent, +} from "@/lib/app-navigation-host"; +import { + AppFixedTabTargetProvider, + getPluginFixedTabOwnerId, + openAppFixedTabFromDestinations, + type AppFixedTabDestination, + type AppFixedTabTargetState, +} from "@/lib/app-fixed-tab-navigation"; +import { + normalizeExperimentalFileOpenOptions, + toFilePreviewLineRange, +} from "@/lib/live-file-navigation"; import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; import { resolveTerminalHost, TerminalHostSelector, } from "@/components/secondary-panel/TerminalHostSelector"; import { getPluginPagePanelStateId } from "./plugin-page-panel-state"; +import { PluginPanelTabContent } from "./PluginPanelActions"; const TERMINAL_COLS = 100; const TERMINAL_ROWS = 30; @@ -72,6 +98,74 @@ const RIGHT_PANEL_TOGGLE_CLASS = `${COARSE_POINTER_HEADER_ICON_BUTTON_CLASS} ${C const compactDrawerOpenAtomFamily = atomFamily((_panelStateId: string) => atom(false), ); +interface FixedTabSessionTarget { + sequence: number; + target: JsonValue; +} +const fixedTabTargetAtomFamily = atomFamily((_targetId: string) => + atom(null), +); + +function PluginFixedTabContent({ + fixedTabOwnerId, + isOpen, + panelGeneration, + panelId, + panelStateId, + pluginId, + registration, + subPath, +}: { + fixedTabOwnerId: string; + isOpen: boolean; + panelGeneration: number; + panelId: string; + panelStateId: string; + pluginId: string; + registration: ExperimentalPluginFixedTabDeclaration; + subPath: string; +}) { + const targetStore = useStore(); + const targetAtom = fixedTabTargetAtomFamily( + `${panelStateId}\0${registration.id}`, + ); + const targetSnapshot = useAtomValue(targetAtom); + const targetState = useMemo(() => { + if (targetSnapshot === null) return null; + const { sequence } = targetSnapshot; + return { + ...targetSnapshot, + ownerId: fixedTabOwnerId, + tabId: registration.id, + clear: () => { + targetStore.set(targetAtom, (current) => + current?.sequence === sequence ? null : current, + ); + }, + }; + }, [ + fixedTabOwnerId, + registration.id, + targetAtom, + targetSnapshot, + targetStore, + ]); + if (!isOpen) return null; + const FixedTabComponent = registration.component; + return ( + + + + + + ); +} function findPluginRightPanelTogglePortal( panelStateId: string, @@ -182,15 +276,16 @@ export function PluginPanelRightPanelHost({ activeBrowserTab, browserTabs, closeTab, - isNewTabActive, openTab, orderedSecondaryFileTabs, - reorderFileTab, + reorderTab, updateBrowserTab, } = useThreadFileTabs({ panelStateId, syncThreadId: null, environmentId: null, + fileOwnerThreadId: null, + preserveWorkspaceTabsAcrossContexts: true, storageFiles: undefined, terminalSessions: undefined, }); @@ -246,6 +341,122 @@ export function PluginPanelRightPanelHost({ secondary: { ...state.secondary, isOpen: true }, })); }, [isCompactViewport, setCompactDrawerOpen, updatePanelState]); + const targetStore = useStore(); + const fixedTabOwnerId = getPluginFixedTabOwnerId( + pluginId, + panel?.id ?? panelPath, + ); + const fixedTabDestinations = useMemo( + () => + (panel?.experimental_fixedTabs ?? []).flatMap((registration) => { + const tab = fixedViewTabs.find( + (candidate) => candidate.fixedTabId === registration.id, + ); + if (tab === undefined) return []; + return [ + { + tab: { + ownerId: fixedTabOwnerId, + tabId: registration.id, + }, + open: (target) => { + if (target !== undefined) { + const result = jsonValueSchema.safeParse(target); + if ( + !result.success || + registration.experimental_target === undefined + ) { + return false; + } + try { + if (!registration.experimental_target.validate(result.data)) { + return false; + } + } catch { + return false; + } + targetStore.set( + fixedTabTargetAtomFamily( + `${panelStateId}\0${registration.id}`, + ), + (current) => ({ + sequence: (current?.sequence ?? 0) + 1, + target: result.data, + }), + ); + } + updatePanelState((state) => + activateSecondaryPanelTabInState(state, tab.id), + ); + revealPanel(); + return true; + }, + }, + ]; + }), + [ + fixedViewTabs, + fixedTabOwnerId, + panelStateId, + panel?.experimental_fixedTabs, + revealPanel, + targetStore, + updatePanelState, + ], + ); + const openFixedTab = useCallback( + (intent: Parameters[1]) => + openAppFixedTabFromDestinations(fixedTabDestinations, intent), + [fixedTabDestinations], + ); + const openFilePreview = useCallback( + (intent: AppFilePreviewIntent) => { + const normalized = normalizeExperimentalFileOpenOptions(intent); + if (normalized === null || panel === null) return false; + const lineRange = toFilePreviewLineRange(normalized.location); + const { target } = normalized; + const tab = + target.kind === "workspace" + ? openTab( + { + kind: "workspace-file-preview", + environmentId: target.environmentId, + tab: { + lineRange, + path: target.path, + source: { kind: "working-tree" }, + statusLabel: null, + }, + }, + { viewer: intent.viewer }, + ) + : target.kind === "host" + ? openTab( + { + kind: "host-file-preview", + hostId: target.hostId, + tab: { lineRange, path: target.path }, + }, + { viewer: intent.viewer }, + ) + : openTab( + { + kind: "thread-storage-file-preview", + threadId: target.threadId, + tab: { lineRange, path: target.path }, + }, + { viewer: intent.viewer }, + ); + if (tab === null) return false; + revealPanel(); + return true; + }, + [openTab, panel, revealPanel], + ); + const navigationCapabilities = useMemo( + () => ({ openFilePreview, openFixedTab }), + [openFilePreview, openFixedTab], + ); const hidePanel = useCallback(() => { if (isCompactViewport) { setCompactDrawerOpen(false); @@ -310,7 +521,7 @@ export function PluginPanelRightPanelHost({ }, [activeBrowserTab, browserTabIds, isFocused, openBrowser]); const startTerminal = useCallback( - (target: TerminalCreateTarget) => { + (target: TerminalCreateTarget, replaceNewTabId?: string) => { if (createTerminal.isPending) return; void createTerminal .mutateAsync({ @@ -326,7 +537,8 @@ export function PluginPanelRightPanelHost({ updatePanelState((state) => { const tabs = state.secondary.tabs.filter( (candidate) => - candidate.id !== state.secondary.activeTabId || + candidate.id !== + (replaceNewTabId ?? state.secondary.activeTabId) || candidate.kind !== "new-tab", ); return { @@ -345,14 +557,20 @@ export function PluginPanelRightPanelHost({ }, [createTerminal, isCompactViewport, revealPanel, updatePanelState], ); - const startSelectedTerminal = useCallback(() => { - if (selectedTerminalHost?.status !== "connected") return; - startTerminal({ - kind: "host_path", - hostId: selectedTerminalHost.id, - cwd: null, - }); - }, [selectedTerminalHost, startTerminal]); + const startSelectedTerminal = useCallback( + (replaceNewTabId?: string) => { + if (selectedTerminalHost?.status !== "connected") return; + startTerminal( + { + kind: "host_path", + hostId: selectedTerminalHost.id, + cwd: null, + }, + replaceNewTabId, + ); + }, + [selectedTerminalHost, startTerminal], + ); useAppCommandHandler("terminal.open", () => { if ( @@ -386,6 +604,7 @@ export function PluginPanelRightPanelHost({ return [ { ariaLabel: registration.title, + contentFillsRegion: registration.layout === "flush", label: registration.title, leadingVisual: ( ), onSelect: () => { - updatePanelState((state) => - activateSecondaryPanelTabInState(state, tab.id), - ); - revealPanel(); + openFixedTab({ + surface: { kind: "current" }, + tab: { + ownerId: fixedTabOwnerId, + tabId: registration.id, + }, + }); }, + renderContent: () => + panel === null ? null : ( + + ), tab, title: registration.title, }, @@ -407,41 +642,139 @@ export function PluginPanelRightPanelHost({ }), [ fixedViewTabs, - panel?.experimental_fixedTabs, + fixedTabOwnerId, + isOpen, + openFixedTab, + panel, + panelStateId, pluginId, - revealPanel, - updatePanelState, + subPath, ], ); - const activeFixedTabRegistration = - activeTab?.kind === "plugin-page-fixed" && - activeTab.pluginId === pluginId && - activeTab.pageId === panel?.id - ? (panel.experimental_fixedTabs?.find( - (registration) => registration.id === activeTab.fixedTabId, - ) ?? null) - : null; - const fixedTabContent = useMemo(() => { - if (panel === null || activeFixedTabRegistration === null || !isOpen) { - return null; - } - const FixedTabComponent = activeFixedTabRegistration.component; - return ( - - - - ); - }, [activeFixedTabRegistration, isOpen, panel, pluginId, subPath]); - const fileTabs = useMemo( + const renderPanelTabContent = useCallback( + function renderTabContent(tab: SecondaryFileFixedPanelTab): ReactNode { + switch (tab.kind) { + case "browser": + return null; + case "terminal": + if (tab.target === undefined) return null; + return ( + + ); + case "new-tab": + return ( + undefined} + onSelect={() => undefined} + onOpenBrowser={ + isDesktopBrowserAvailable() + ? () => { + activateTab(tab.id); + openBrowser(); + } + : undefined + } + onStartTerminal={() => { + activateTab(tab.id); + startSelectedTerminal(tab.id); + }} + showFileSearch={false} + startTerminalDisabled={ + createTerminal.isPending || + selectedTerminalHost?.status !== "connected" + } + startTerminalTrailing={ + + } + /> + ); + case "workspace-file-preview": + return tab.environmentId === null ? null : ( + + ); + case "host-file-preview": + return tab.hostId === null ? null : ( + + ); + case "thread-storage-file-preview": + return tab.threadId === null ? null : ( + + ); + case "plugin-panel": { + const originalTab = createFileOpenerOriginalTab(tab); + return ( + + ); + } + } + }, + [ + activateTab, + createTerminal.isPending, + hostsQuery.isLoading, + isOpen, + openBrowser, + panelState.secondary.isOpen, + panelStateId, + selectedTerminalHost, + startSelectedTerminal, + terminalHosts, + ], + ); + const panelTabs = useMemo( () => - orderedSecondaryFileTabs.flatMap((tab) => { + orderedSecondaryFileTabs.flatMap((tab): SecondaryPanelRenderableTab[] => { + const shared = { + onSelect: () => { + activateTab(tab.id); + revealPanel(); + }, + renderContent: () => renderPanelTabContent(tab), + tab, + }; switch (tab.kind) { case "browser": { const label = @@ -449,15 +782,10 @@ export function PluginPanelRightPanelHost({ (tab.url.length > 0 ? getBrowserUrlHost(tab.url) : ""); return [ { - id: tab.id, - filename: label || "Browser", - isActive: tab.id === activeTab?.id, + ...shared, + label: label || "Browser", leadingVisual: , statusLabel: null, - onSelect: () => { - activateTab(tab.id); - revealPanel(); - }, onClose: () => closeTab(tab.id), }, ]; @@ -467,18 +795,14 @@ export function PluginPanelRightPanelHost({ const session = terminalsById.get(tab.terminalId); return [ { - id: tab.id, - filename: session?.title ?? "Terminal", - isActive: tab.id === activeTab?.id, + ...shared, + contentFillsRegion: true, + label: session?.title ?? "Terminal", leadingVisual: , statusLabel: session === undefined || session.status === "running" ? null - : terminalStatusLabel(session), - onSelect: () => { - activateTab(tab.id); - revealPanel(); - }, + : session.status, onClose: () => closeTerminalTab(tab), }, ]; @@ -486,90 +810,66 @@ export function PluginPanelRightPanelHost({ case "new-tab": return [ { - id: tab.id, - filename: "New tab", - isActive: tab.id === activeTab?.id, + ...shared, + label: "New tab", leadingVisual: , statusLabel: null, - onSelect: () => { - activateTab(tab.id); - revealPanel(); - }, onClose: () => closeTab(tab.id), }, ]; - default: - return []; + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + return [ + { + ...shared, + isPinned: + tab.kind === "thread-storage-file-preview" && tab.isPinned, + label: tab.path.split(/[\\/]/u).at(-1) ?? tab.path, + leadingVisual: , + statusLabel: + tab.kind === "workspace-file-preview" + ? tab.statusLabel + : null, + onClose: () => closeTab(tab.id), + }, + ]; + case "plugin-panel": + return [ + { + ...shared, + // PluginPanelTabContent owns the complete body frame for every + // plugin tab: padded actions provide their own padded scroll + // container, while flush actions and file openers provide their + // own full-bleed layout. Letting the file-preview shell frame a + // padded action adds a second scroll container and an extra + // bottom gutter. + contentFillsRegion: true, + label: tab.title, + leadingVisual: ( + + ), + statusLabel: null, + onClose: () => closeTab(tab.id), + }, + ]; } }), [ activateTab, - activeTab?.id, closeTab, closeTerminalTab, orderedSecondaryFileTabs, + renderPanelTabContent, revealPanel, terminalsById, ], ); - const activeContent = useMemo( - () => - activeTerminalTab ? ( - - ) : isNewTabActive ? ( - undefined} - onSelect={() => undefined} - onOpenBrowser={ - isDesktopBrowserAvailable() ? () => openBrowser() : undefined - } - onStartTerminal={startSelectedTerminal} - showFileSearch={false} - startTerminalDisabled={ - createTerminal.isPending || - selectedTerminalHost?.status !== "connected" - } - startTerminalTrailing={ - - } - /> - ) : null, - [ - activeTerminalTab, - activeTerminalTarget, - createTerminal.isPending, - hostsQuery.isLoading, - isNewTabActive, - isOpen, - openBrowser, - panelState.secondary.isOpen, - panelStateId, - selectedTerminalHost, - startSelectedTerminal, - terminalHosts, - ], - ); - const renderPanel = useCallback( ({ presentation, @@ -582,35 +882,45 @@ export function PluginPanelRightPanelHost({ onToggleMainCollapse: () => void; resizablePanelId?: string; }) => { - const deck = + const renderDeck = ( + activeBrowserTabId: string | null, + canHandleBrowserCommands: boolean, + onNativeFocus?: () => void, + ) => browserTabs.length === 0 ? null : ( ); + const drawerFallback = renderDeck( + activeBrowserTab?.id ?? null, + canShowNativeBrowserView, + ); return ( + renderDeck( + activeBrowserTabId, + canShowNativeBrowserView && pane.isFocused, + pane.onFocusPane, + ) + } isOpen={isOpen} fixedTabs={fixedTabs} - fixedTabContent={fixedTabContent} - fixedTabContentFillsRegion={ - activeFixedTabRegistration?.layout === "flush" - } showConversationCollapseControl={false} showNewTabButton onPanelFocus={() => undefined} @@ -626,24 +936,21 @@ export function PluginPanelRightPanelHost({ }, [ activeBrowserTab, - activeContent, - activeFixedTabRegistration?.layout, activeTab, - activeTerminalTab, browserTabs, - fileTabs, - fixedTabContent, fixedTabs, hidePanel, isOpen, openNewTab, + panelTabs, panelStateId, - reorderFileTab, + reorderTab, updateBrowserTab, ], ); const toggleLabel = isOpen ? "Hide right panel" : "Show right panel"; + const toggleIconName = getRightPanelToggleIconName(isCompactViewport); const page = (
- {panel !== null && - togglePortalTarget !== null && - !isOpen && - !isHostedBySplitWorkspace - ? createPortal( - - - - - {toggleLabel} - , - togglePortalTarget, - ) - : null} - {page} - + + + {panel !== null && + togglePortalTarget !== null && + !isOpen && + !isHostedBySplitWorkspace + ? createPortal( + + + + + {toggleLabel} + , + togglePortalTarget, + ) + : null} + {page} + + ); } diff --git a/apps/app/src/components/plugin/PluginPermissionModePicker.test.tsx b/apps/app/src/components/plugin/PluginPermissionModePicker.test.tsx new file mode 100644 index 0000000000..e407e03512 --- /dev/null +++ b/apps/app/src/components/plugin/PluginPermissionModePicker.test.tsx @@ -0,0 +1,273 @@ +// @vitest-environment jsdom + +import { useState } from "react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { PermissionMode, ProviderInfo } from "@bb/domain"; +import type { SystemExecutionOptionsResponse } from "@bb/server-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { systemExecutionOptionsQueryKey } from "@/hooks/queries/query-keys"; +import { + modelCatalogCacheKey, + writeCachedModelCatalog, +} from "@/lib/model-catalog-cache"; +import { + providerListCacheKey, + writeCachedProviderList, +} from "@/lib/provider-list-cache"; +import { sdk } from "@/lib/sdk"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { PluginPermissionModePicker } from "./PluginPermissionModePicker"; + +vi.mock("@/lib/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sdk: { + ...actual.sdk, + hosts: { list: vi.fn().mockResolvedValue([]) }, + system: { + ...actual.sdk.system, + config: vi.fn().mockResolvedValue({ primaryHostId: null }), + executionOptions: vi.fn(), + }, + }, + }; +}); + +function provider( + id: string, + permissionModes: ProviderInfo["capabilities"]["permissionModes"], +): ProviderInfo { + return { + id, + displayName: id, + logoUrl: null, + available: true, + experimental_providerHealth: true, + experimental_providerUsage: true, + experimental_providerInstallation: false, + strings: { + signInHint: "Sign in", + expiredHint: "Sign in again", + installUrl: "https://example.com/install", + }, + composerActions: [], + capabilities: { + supportsThreadArchive: true, + supportsThreadRename: true, + supportsServiceTier: false, + supportsNativeUserQuestion: true, + supportsFork: true, + supportsSessionRewind: true, + permissionModes, + }, + }; +} + +const providers = [ + provider("codex", ["accept-edits", "auto", "full"]), + provider("claude", ["auto", "full"]), + provider("fixed", ["full"]), +]; + +function executionOptions( + permissionCeiling: SystemExecutionOptionsResponse["permissionCeiling"], +): SystemExecutionOptionsResponse { + return { + providers, + models: [], + selectedOnlyModels: [], + permissionCeiling, + modelLoadError: null, + }; +} + +function cacheOptions( + queryClient: ReturnType["queryClient"], + providerId: string, + permissionCeiling: SystemExecutionOptionsResponse["permissionCeiling"], + environmentId: string | null = null, +) { + queryClient.setQueryData( + systemExecutionOptionsQueryKey({ + environmentId, + hostId: null, + providerId, + }), + executionOptions(permissionCeiling), + ); +} + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe("PluginPermissionModePicker", () => { + it("normalizes against provider capabilities and the routed machine ceiling", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + cacheOptions(queryClient, "codex", "auto"); + const onChange = vi.fn(); + + function ControlledPicker() { + const [value, setValue] = useState<"accept-edits" | "auto" | "full">( + "full", + ); + return ( + { + onChange(next); + setValue(next); + }} + /> + ); + } + + render(, { wrapper }); + await waitFor(() => expect(onChange).toHaveBeenCalledWith("auto")); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Permission mode" }), + { button: 0 }, + ); + const full = screen.getByRole("menuitem", { name: /Full Access/ }); + expect(full.getAttribute("data-disabled")).not.toBeNull(); + expect(full.textContent).toContain("selected machine's permission limit"); + }); + + it("reacts to provider and environment capability changes without plugin logic", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + cacheOptions(queryClient, "codex", "full", "env-wide"); + cacheOptions(queryClient, "codex", "accept-edits", "env-capped"); + cacheOptions(queryClient, "claude", "full", "env-wide"); + const onChange = vi.fn(); + + function RehydratingPicker() { + const [state, setState] = useState<{ + providerId: string; + value: PermissionMode; + environmentId: string; + }>({ + providerId: "codex", + value: "full", + environmentId: "env-wide", + }); + return ( + <> + { + onChange(value); + setState((current) => ({ ...current, value })); + }} + /> + + + + ); + } + + render(, { wrapper }); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Change provider" })); + await waitFor(() => expect(onChange).toHaveBeenLastCalledWith("auto")); + + fireEvent.click(screen.getByRole("button", { name: "Change environment" })); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith("accept-edits"), + ); + }); + + it("shows a locked single supported mode", () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + cacheOptions(queryClient, "fixed", "full"); + + render( + , + { wrapper }, + ); + + const trigger = screen.getByRole("button", { name: "Permission mode" }); + expect(trigger.textContent).toContain("Full Access"); + expect(trigger.hasAttribute("disabled")).toBe(true); + }); + + it("does not normalize from provisional or failed routing data", async () => { + const { wrapper } = createQueryClientTestHarness(); + writeCachedProviderList( + providerListCacheKey({ environmentId: null, hostId: null }), + providers, + ); + writeCachedModelCatalog( + modelCatalogCacheKey({ + environmentId: null, + hostId: null, + providerId: "claude", + }), + { models: [], selectedOnlyModels: [] }, + ); + vi.mocked(sdk.system.executionOptions).mockRejectedValue( + new Error("offline"), + ); + const onChange = vi.fn(); + + render( + , + { wrapper }, + ); + + const trigger = await screen.findByRole("button", { + name: "Permission mode", + }); + expect(trigger.hasAttribute("disabled")).toBe(true); + await waitFor(() => + expect(sdk.system.executionOptions).toHaveBeenCalledTimes(2), + ); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/plugin/PluginPermissionModePicker.tsx b/apps/app/src/components/plugin/PluginPermissionModePicker.tsx new file mode 100644 index 0000000000..16e603c76b --- /dev/null +++ b/apps/app/src/components/plugin/PluginPermissionModePicker.tsx @@ -0,0 +1,62 @@ +import { useEffect, useMemo } from "react"; +import type { ExperimentalPermissionModePickerProps } from "@get-bb/plugin-sdk"; +import { PermissionModePicker } from "@/components/pickers/PermissionModePicker"; +import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions"; +import { resolvePluginExecutionRouting } from "./plugin-execution-routing"; + +/** Controlled SDK adapter over BB's permission capability and ceiling policy. */ +export function PluginPermissionModePicker({ + providerId, + value, + onChange, + routing, + align = "end", + disabled, + className, +}: ExperimentalPermissionModePickerProps) { + const resolvedRouting = useMemo( + () => resolvePluginExecutionRouting(routing), + [routing], + ); + const controlledKey = `${resolvedRouting.key}\0${providerId}\0${value}`; + const controller = useThreadCreationOptions({ + scope: "component-local", + initialProviderId: providerId, + initialPermissionMode: value, + resetKey: controlledKey, + resolveProviderRouting: () => resolvedRouting.query, + }); + const providerMatches = + providerId.length > 0 && controller.selectedProviderId === providerId; + + useEffect(() => { + if ( + providerMatches && + controller.permissionModeIsVerified && + controller.permissionMode !== value + ) { + onChange(controller.permissionMode); + } + }, [ + controller.permissionMode, + controller.permissionModeIsVerified, + onChange, + providerMatches, + value, + ]); + + if (!providerMatches) return null; + + return ( + 0} + showWhenSingleOption + align={align} + disabled={disabled || !controller.permissionModeIsVerified} + className={className} + /> + ); +} diff --git a/apps/app/src/components/plugin/PluginProviderModelPicker.test.tsx b/apps/app/src/components/plugin/PluginProviderModelPicker.test.tsx new file mode 100644 index 0000000000..1965655d01 --- /dev/null +++ b/apps/app/src/components/plugin/PluginProviderModelPicker.test.tsx @@ -0,0 +1,536 @@ +// @vitest-environment jsdom + +import { useState } from "react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { AvailableModel, ProviderInfo, ReasoningLevel } from "@bb/domain"; +import type { SystemExecutionOptionsResponse } from "@bb/server-contract"; +import type { ExperimentalProviderModelPickerValue } from "@get-bb/plugin-sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { systemExecutionOptionsQueryKey } from "@/hooks/queries/query-keys"; +import { + modelCatalogCacheKey, + writeCachedModelCatalog, +} from "@/lib/model-catalog-cache"; +import { + providerListCacheKey, + writeCachedProviderList, +} from "@/lib/provider-list-cache"; +import { sdk } from "@/lib/sdk"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { PluginProviderModelPicker } from "./PluginProviderModelPicker"; + +vi.mock("@/lib/sdk", () => ({ + sdk: { + hosts: { list: vi.fn().mockResolvedValue([]) }, + system: { + config: vi.fn().mockResolvedValue({ primaryHostId: null }), + executionOptions: vi.fn(), + }, + }, +})); + +const providers: ProviderInfo[] = [ + provider("codex", "Codex", "OpenAI", true), + provider("cursor", "Cursor", "Cursor", true), + provider("claude-code", "Claude Code", "Claude", false), +]; + +function provider( + id: string, + displayName: string, + brandPrefix: string, + supportsServiceTier: boolean, +): ProviderInfo { + return { + id, + displayName, + logoUrl: null, + available: true, + experimental_providerHealth: true, + experimental_providerUsage: true, + experimental_providerInstallation: false, + strings: { + signInHint: "Sign in", + expiredHint: "Sign in again", + installUrl: "https://example.com/install", + brandPrefix, + }, + composerActions: [], + capabilities: { + supportsThreadArchive: true, + supportsThreadRename: true, + supportsServiceTier, + supportsNativeUserQuestion: true, + supportsFork: true, + supportsSessionRewind: true, + permissionModes: ["auto"], + }, + }; +} + +function model( + id: string, + displayName: string, + reasoning: readonly ReasoningLevel[], + isDefault = false, +): AvailableModel { + return { + id, + model: id, + displayName, + description: "", + supportedReasoningEfforts: reasoning.map((reasoningEffort) => ({ + reasoningEffort, + description: reasoningEffort, + })), + defaultReasoningEffort: reasoning[0] ?? "medium", + isDefault, + }; +} + +function executionOptions( + models: AvailableModel[], + options?: { + selectedOnlyModels?: AvailableModel[]; + modelLoadError?: SystemExecutionOptionsResponse["modelLoadError"]; + }, +): SystemExecutionOptionsResponse { + return { + providers, + models, + selectedOnlyModels: options?.selectedOnlyModels ?? [], + permissionCeiling: "full", + modelLoadError: options?.modelLoadError ?? null, + }; +} + +function cacheCatalog( + queryClient: ReturnType["queryClient"], + providerId: string, + response: SystemExecutionOptionsResponse, + hostId: string | null = null, +) { + queryClient.setQueryData( + systemExecutionOptionsQueryKey({ + environmentId: null, + hostId, + providerId, + }), + response, + ); +} + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe("PluginProviderModelPicker", () => { + it("resolves provider, default model, reasoning, and service tier atomically", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const hostId = "host-remote"; + cacheCatalog( + queryClient, + "codex", + executionOptions([ + model("gpt-5.5", "OpenAI GPT-5.5", ["medium", "high"], true), + ]), + hostId, + ); + cacheCatalog( + queryClient, + "cursor", + executionOptions([ + model("cursor-agent", "Cursor Agent", ["medium", "high"], true), + ]), + hostId, + ); + const onChange = vi.fn(); + + function ControlledPicker() { + const [value, setValue] = useState({ + providerId: "codex", + model: "gpt-5.5", + reasoningLevel: "high", + serviceTier: "fast", + }); + return ( + { + onChange(next); + setValue(next); + }} + /> + ); + } + + render(, { wrapper }); + const trigger = screen.getByRole("button", { + name: "Provider, model and reasoning", + }); + expect(trigger.classList.contains("plugin-picker")).toBe(true); + expect(trigger.getAttribute("aria-keyshortcuts")).toBeNull(); + fireEvent.click(trigger); + fireEvent.click(screen.getByTitle("Cursor")); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith({ + providerId: "cursor", + model: "cursor-agent", + reasoningLevel: "high", + serviceTier: "fast", + }); + }); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByRole("button", { name: "Agent" })).toBeDefined(); + }); + + it("reconciles model capabilities and drops unsupported service tiers", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + cacheCatalog( + queryClient, + "codex", + executionOptions([ + model("gpt-5.5", "OpenAI GPT-5.5", ["medium", "high"], true), + model("gpt-light", "OpenAI GPT Light", ["low"]), + ]), + ); + cacheCatalog( + queryClient, + "claude-code", + executionOptions([model("claude-opus", "Claude Opus", ["xhigh"], true)]), + ); + const onChange = vi.fn(); + + function ControlledPicker() { + const [value, setValue] = useState({ + providerId: "codex", + model: "gpt-5.5", + reasoningLevel: "high", + serviceTier: "fast", + }); + return ( + { + onChange(next); + setValue(next); + }} + /> + ); + } + + render(, { wrapper }); + fireEvent.click( + screen.getByRole("button", { name: "Provider, model and reasoning" }), + ); + fireEvent.click(screen.getByRole("button", { name: "GPT Light" })); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + providerId: "codex", + model: "gpt-light", + reasoningLevel: "low", + serviceTier: "fast", + }), + ); + + const fastMode = screen.getByRole("switch", { name: "Fast mode" }); + expect(fastMode.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(fastMode); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + providerId: "codex", + model: "gpt-light", + reasoningLevel: "low", + serviceTier: "default", + }), + ); + + fireEvent.click(screen.getByTitle("Claude Code")); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + providerId: "claude-code", + model: "claude-opus", + reasoningLevel: "xhigh", + }), + ); + }); + + it("normalizes a stale controlled selection after the catalog is verified", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + cacheCatalog( + queryClient, + "codex", + executionOptions([ + model("gpt-current", "OpenAI GPT Current", ["medium", "high"], true), + ]), + ); + const onChange = vi.fn(); + + render( + , + { wrapper }, + ); + + await waitFor(() => + expect(onChange).toHaveBeenCalledWith({ + providerId: "codex", + model: "gpt-current", + reasoningLevel: "high", + serviceTier: "fast", + }), + ); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("preserves a controlled retired model from the selected-only catalog", () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + cacheCatalog( + queryClient, + "codex", + executionOptions( + [model("gpt-current", "OpenAI GPT Current", ["medium"], true)], + { + selectedOnlyModels: [ + model("gpt-retired", "OpenAI GPT Retired", ["high", "xhigh"]), + ], + }, + ), + ); + const onChange = vi.fn(); + + render( + , + { wrapper }, + ); + + const trigger = screen.getByRole("button", { + name: "Provider, model and reasoning", + }); + expect(trigger.textContent).toContain("GPT Retired"); + expect(trigger.querySelector("[title]")?.getAttribute("title")).toContain( + "Extra High reasoning", + ); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("does not commit loading, placeholder, or failed provider catalogs", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + cacheCatalog( + queryClient, + "codex", + executionOptions([model("gpt-5.5", "OpenAI GPT-5.5", ["medium"], true)]), + ); + writeCachedModelCatalog( + modelCatalogCacheKey({ + environmentId: null, + hostId: null, + providerId: "claude-code", + }), + { + models: [model("claude-stale", "Claude Stale", ["high"], true)], + selectedOnlyModels: [], + }, + ); + writeCachedProviderList( + providerListCacheKey({ environmentId: null, hostId: null }), + providers, + ); + let resolveCatalog: ( + value: SystemExecutionOptionsResponse, + ) => void = () => {}; + vi.mocked(sdk.system.executionOptions).mockImplementation( + () => + new Promise((resolve) => { + resolveCatalog = resolve; + }), + ); + const onChange = vi.fn(); + + render( + , + { wrapper }, + ); + fireEvent.click( + screen.getByRole("button", { name: "Provider, model and reasoning" }), + ); + fireEvent.click(screen.getByTitle("Claude Code")); + await waitFor(() => + expect(sdk.system.executionOptions).toHaveBeenCalledWith( + expect.objectContaining({ providerId: "claude-code" }), + ), + ); + const staleModel = screen.getByRole("button", { name: "Stale" }); + expect(staleModel.hasAttribute("disabled")).toBe(true); + fireEvent.click(staleModel); + expect(onChange).not.toHaveBeenCalled(); + + resolveCatalog( + executionOptions([], { + modelLoadError: { + providerId: "claude-code", + code: "failed", + }, + }), + ); + await waitFor(() => + expect( + screen.getByText(/could not load models for claude code/i), + ).toBeTruthy(), + ); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("rehydrates every controlled field without emitting a change", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + cacheCatalog( + queryClient, + "codex", + executionOptions([model("gpt-5.5", "OpenAI GPT-5.5", ["medium"], true)]), + ); + cacheCatalog( + queryClient, + "cursor", + executionOptions([model("cursor-agent", "Cursor Agent", ["high"], true)]), + ); + const onChange = vi.fn(); + + function RehydratingPicker() { + const [value, setValue] = useState({ + providerId: "codex", + model: "gpt-5.5", + reasoningLevel: "medium", + serviceTier: "default", + }); + return ( + <> + { + onChange(next); + setValue(next); + }} + /> + + + ); + } + + render(, { wrapper }); + const trigger = screen.getByRole("button", { + name: "Provider, model and reasoning", + }); + const rehydrate = screen.getByRole("button", { name: "Rehydrate" }); + fireEvent.click(trigger); + fireEvent.click(screen.getByTitle("Cursor")); + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + providerId: "cursor", + model: "cursor-agent", + reasoningLevel: "high", + serviceTier: "default", + }); + expect(trigger.textContent).toContain("Agent"); + expect(trigger.querySelector("[title]")?.getAttribute("title")).toBe( + "Cursor: Agent · High reasoning", + ); + }); + onChange.mockClear(); + + fireEvent.click(rehydrate); + await waitFor(() => { + expect(trigger.textContent).toContain("GPT-5.5"); + expect(trigger.querySelector("[title]")?.getAttribute("title")).toBe( + "Codex: GPT-5.5 · Medium reasoning", + ); + }); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByRole("button", { name: "GPT-5.5" })).toBeDefined(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("keeps model controls editable while provider changes are locked", () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + cacheCatalog( + queryClient, + "codex", + executionOptions([ + model("gpt-5.5", "OpenAI GPT-5.5", ["medium"], true), + model("gpt-light", "OpenAI GPT Light", ["low"]), + ]), + ); + const onChange = vi.fn(); + + render( + , + { wrapper }, + ); + + fireEvent.click( + screen.getByRole("button", { name: "Provider, model and reasoning" }), + ); + expect(screen.queryByTitle("Cursor")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "GPT Light" })); + + expect(onChange).toHaveBeenCalledWith({ + providerId: "codex", + model: "gpt-light", + reasoningLevel: "low", + }); + }); +}); diff --git a/apps/app/src/components/plugin/PluginProviderModelPicker.tsx b/apps/app/src/components/plugin/PluginProviderModelPicker.tsx new file mode 100644 index 0000000000..f772167c08 --- /dev/null +++ b/apps/app/src/components/plugin/PluginProviderModelPicker.tsx @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useMemo } from "react"; +import type { + ExperimentalProviderModelPickerProps, + ExperimentalProviderModelPickerValue, +} from "@get-bb/plugin-sdk"; +import { ModelReasoningPicker } from "@/components/pickers/ModelReasoningPicker"; +import { + formatModelLabel, + useThreadCreationOptions, +} from "@/hooks/useThreadCreationOptions"; +import { resolvePluginExecutionRouting } from "./plugin-execution-routing"; + +function selectionKey(value: ExperimentalProviderModelPickerValue): string { + return [ + value.providerId, + value.model, + value.reasoningLevel, + value.serviceTier ?? "", + ].join("\0"); +} + +/** + * Controlled SDK adapter over the same picker and selection controller used + * by bb's composers. Provider previews stay inside ModelReasoningPicker; only + * its verified, fully-resolved default is allowed across the public boundary. + */ +export function PluginProviderModelPicker({ + value, + onChange, + routing, + allowProviderChange = true, + align = "start", + disabled, + className, +}: ExperimentalProviderModelPickerProps) { + const resolvedRouting = useMemo( + () => resolvePluginExecutionRouting(routing), + [routing], + ); + const controlledKey = `${resolvedRouting.key}\0${selectionKey(value)}`; + const controller = useThreadCreationOptions({ + scope: "component-local", + initialProviderId: value.providerId, + initialModel: value.model, + initialReasoningLevel: value.reasoningLevel, + initialServiceTier: value.serviceTier, + resetKey: controlledKey, + resolveProviderRouting: () => resolvedRouting.query, + }); + + const emit = useCallback( + (next: ExperimentalProviderModelPickerValue) => { + if (selectionKey(next) !== selectionKey(value)) { + onChange(next); + } + }, + [onChange, value], + ); + + useEffect(() => { + if ( + !controller.modelCatalogIsVerified || + controller.selectedModel.length === 0 + ) { + return; + } + emit({ + providerId: controller.selectedProviderId, + model: controller.selectedModel, + reasoningLevel: controller.reasoningLevel, + ...(controller.serviceTier === undefined + ? {} + : { serviceTier: controller.serviceTier }), + }); + }, [ + controller.modelCatalogIsVerified, + controller.reasoningLevel, + controller.selectedModel, + controller.selectedProviderId, + controller.serviceTier, + emit, + ]); + + const handleModelChange = useCallback( + (model: string) => { + if (!controller.modelCatalogIsVerified) return; + controller.setSelectedModel(model); + }, + [controller], + ); + const handleReasoningChange = useCallback( + ( + reasoningLevel: ExperimentalProviderModelPickerValue["reasoningLevel"], + ) => { + if (!controller.modelCatalogIsVerified) return; + emit({ + providerId: controller.selectedProviderId, + model: controller.selectedModel, + reasoningLevel, + ...(controller.serviceTier === undefined + ? {} + : { serviceTier: controller.serviceTier }), + }); + }, + [controller, emit], + ); + const handleFastModeChange = useCallback( + (enabled: boolean) => { + if ( + !controller.modelCatalogIsVerified || + !controller.supportsServiceTier + ) { + return; + } + emit({ + providerId: controller.selectedProviderId, + model: controller.selectedModel, + reasoningLevel: controller.reasoningLevel, + serviceTier: enabled ? "fast" : "default", + }); + }, + [controller, emit], + ); + const handleProviderPreviewResolved = useCallback( + (selection: { + providerId: string; + model: string; + reasoningLevel: ExperimentalProviderModelPickerValue["reasoningLevel"]; + supportsServiceTier: boolean; + }) => { + emit({ + providerId: selection.providerId, + model: selection.model, + reasoningLevel: selection.reasoningLevel, + ...(selection.supportsServiceTier && value.serviceTier !== undefined + ? { serviceTier: value.serviceTier } + : {}), + }); + }, + [emit, value.serviceTier], + ); + + return ( + {} : undefined} + onProviderPreviewResolved={ + allowProviderChange ? handleProviderPreviewResolved : undefined + } + requireVerifiedProviderPreview={allowProviderChange} + hasMultipleProviders={controller.hasMultipleProviders} + modelValue={controller.selectedModel} + modelOptions={controller.modelOptions} + moreModelOptions={controller.moreModelOptions} + modelIsLoading={controller.isLoadingModels} + modelLoadFailed={controller.modelLoadFailed} + modelLoadError={controller.modelLoadError} + onModelChange={handleModelChange} + formatModelLabel={formatModelLabel} + reasoningValue={controller.reasoningLevel} + reasoningOptions={controller.reasoningOptions} + onReasoningChange={handleReasoningChange} + fastModeEnabled={controller.serviceTier === "fast"} + onFastModeChange={handleFastModeChange} + showFastModeToggle={controller.supportsServiceTier} + serviceTierSupportByProvider={controller.serviceTierSupportByProvider} + commandShortcutsEnabled={false} + align={align} + disabled={disabled} + className={className} + /> + ); +} diff --git a/apps/app/src/components/plugin/PluginSettings.tsx b/apps/app/src/components/plugin/PluginSettings.tsx index 74b1363cb8..a5d91dd283 100644 --- a/apps/app/src/components/plugin/PluginSettings.tsx +++ b/apps/app/src/components/plugin/PluginSettings.tsx @@ -89,7 +89,6 @@ interface PluginSettingFieldProps { descriptor: PluginSettingFieldDescriptor; draft: unknown; onChange: (value: string | boolean) => void; - settingKey: string; storedValue: unknown; } @@ -97,7 +96,6 @@ function PluginSettingField({ descriptor, draft, onChange, - settingKey, storedValue, }: PluginSettingFieldProps) { const projects = useSidebarNavigation({ @@ -256,7 +254,6 @@ export function PluginSettingsForm({ pluginId }: { pluginId: string }) { : {})} > { beforeEach(() => { resetAllCrashedPluginSlotsForTest(); + resetPluginCssForTest(); // React logs boundary-caught errors; keep test output quiet. vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -26,6 +29,7 @@ describe("PluginSlotMount", () => { afterEach(() => { cleanup(); + resetPluginCssForTest(); vi.restoreAllMocks(); }); @@ -49,6 +53,46 @@ describe("PluginSlotMount", () => { expect(screen.getByText("healthy slot")).toBeDefined(); }); + it("keeps one sheet through simultaneous mounts and a portal until the final route unmount", async () => { + applyPluginCss("demo", "/demo.css?h=v1"); + function PortalContent() { + return createPortal(
portalled plugin content
, document.body); + } + const view = render( + <> + + + + + + + , + ); + const pluginSheets = () => + document.head.querySelectorAll('link[data-bb-plugin-css="demo"]'); + expect(pluginSheets()).toHaveLength(1); + expect(screen.getByText("portalled plugin content")).toBeDefined(); + + view.rerender( + + + , + ); + expect(pluginSheets()).toHaveLength(1); + + view.unmount(); + await act(async () => {}); + expect(pluginSheets()).toHaveLength(0); + }); + it("keeps a crashed slot instance disabled for the session across remounts", () => { const first = render( diff --git a/apps/app/src/components/plugin/PluginSlotMount.tsx b/apps/app/src/components/plugin/PluginSlotMount.tsx index 084bfd555f..8934425814 100644 --- a/apps/app/src/components/plugin/PluginSlotMount.tsx +++ b/apps/app/src/components/plugin/PluginSlotMount.tsx @@ -1,5 +1,6 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; import { Pill } from "@bb/shared-ui/pill"; +import { usePluginCss } from "@/lib/plugin-css"; import { PluginContext, PluginSlotOwnershipContext, @@ -51,7 +52,7 @@ function releaseSlotInstanceOwnedState(instanceKey: string): void { for (const release of releases) release(); } -export function pluginSlotInstanceKey( +function pluginSlotInstanceKey( pluginId: string, slotKind: string, slotId: string, @@ -182,7 +183,7 @@ class PluginSlotBoundary extends Component< } } -export interface PluginSlotMountProps { +interface PluginSlotMountProps { pluginId: string; /** e.g. "homepageSection", "navPanel" — combined with slotId per instance. */ slotKind: string; @@ -223,6 +224,7 @@ export function PluginSlotMount({ instanceId, onCrash, }: PluginSlotMountProps) { + usePluginCss(pluginId); return ( + ); +} diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 824eb0dd8a..d08cd461f8 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -73,6 +73,7 @@ function systemConfig(): SystemConfigResponse { pluginThemes: [], featureFlags: { placeholder: false, timelineWindowEventBudget: 1_500 }, hostDaemonPort: null, + localHelperPorts: [], serverUrl: "http://localhost:38886", primaryHostId: null, primaryHostPlatform: null, diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 6ebb10e4ae..708f91b4be 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -15,7 +15,7 @@ import { } from "@bb/shared-ui/resource-list"; import { cn } from "@bb/shared-ui/lib/utils"; import { CreateWithTemplatesButton } from "@/components/create-via-prompt-examples"; -import { CREATE_PLUGIN_PROMPT } from "@/lib/create-resource-prompts"; +import { CREATE_PLUGIN_PROMPT } from "@bb/client-core"; import { TOOLS_PAGE_BAND_CLASSES } from "@/components/tools/tools-navigation"; import { AddPluginDialog, diff --git a/apps/app/src/components/plugin/browse-hero/BrowseArchetypeCards.tsx b/apps/app/src/components/plugin/browse-hero/BrowseArchetypeCards.tsx index 83fd5a9c58..21e879994c 100644 --- a/apps/app/src/components/plugin/browse-hero/BrowseArchetypeCards.tsx +++ b/apps/app/src/components/plugin/browse-hero/BrowseArchetypeCards.tsx @@ -1,11 +1,9 @@ import { TooltipProvider } from "@bb/shared-ui/tooltip"; -import { CREATE_PLUGIN_PROMPT } from "@/lib/create-resource-prompts"; import { ShowcaseExampleCard } from "@/components/showcase-hero/ShowcaseArchetypeCards"; -import type { ShowcaseArchetype } from "@/components/showcase-hero/showcase-archetype"; -import { showcaseArchetypePrompt } from "@/components/showcase-hero/showcase-archetype"; import { BROWSE_ARCHETYPES, UTILITY_EXAMPLES, + archetypePrompt, utilityPrompt, } from "./browse-hero-archetypes"; @@ -20,12 +18,10 @@ import { */ export function BrowseArchetypeCards({ onCreate, - archetypes = BROWSE_ARCHETYPES, className, }: { /** Receives the full composer prompt for the chosen example. */ onCreate: (prompt: string) => void; - archetypes?: readonly ShowcaseArchetype[]; className?: string; }) { return ( @@ -37,18 +33,14 @@ export function BrowseArchetypeCards({ Start from an example
- {archetypes.map((archetype) => ( + {BROWSE_ARCHETYPES.map((archetype) => ( - onCreate( - showcaseArchetypePrompt(CREATE_PLUGIN_PROMPT, archetype), - ) - } + onClick={() => onCreate(archetypePrompt(archetype))} /> ))}
diff --git a/apps/app/src/components/plugin/browse-hero/BrowseHeroCarousel.test.tsx b/apps/app/src/components/plugin/browse-hero/BrowseHeroCarousel.test.tsx index 89598aa87a..20924ef840 100644 --- a/apps/app/src/components/plugin/browse-hero/BrowseHeroCarousel.test.tsx +++ b/apps/app/src/components/plugin/browse-hero/BrowseHeroCarousel.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { CREATE_PLUGIN_PROMPT } from "@/lib/create-resource-prompts"; +import { CREATE_PLUGIN_PROMPT } from "@bb/client-core"; import { BROWSE_ARCHETYPES, UTILITY_EXAMPLES, diff --git a/apps/app/src/components/plugin/browse-hero/BrowseHeroCarousel.tsx b/apps/app/src/components/plugin/browse-hero/BrowseHeroCarousel.tsx index fa39a8ae7a..3ce66a6ce3 100644 --- a/apps/app/src/components/plugin/browse-hero/BrowseHeroCarousel.tsx +++ b/apps/app/src/components/plugin/browse-hero/BrowseHeroCarousel.tsx @@ -1,12 +1,11 @@ import type { IconName } from "@bb/shared-ui/icon"; import { PLUGINS_BROWSE_DESCRIPTION } from "@/components/plugin/plugins-collection-copy"; -import { CREATE_PLUGIN_PROMPT } from "@/lib/create-resource-prompts"; +import { CREATE_PLUGIN_PROMPT } from "@bb/client-core"; import { ShowcaseHeroCarousel, type ShowcaseHeroComposerConfig, type ShowcaseHeroCopy, } from "@/components/showcase-hero/ShowcaseHeroCarousel"; -import type { ShowcaseArchetype } from "@/components/showcase-hero/showcase-archetype"; import { BROWSE_ARCHETYPES } from "./browse-hero-archetypes"; import { MINI_APP_SCENES } from "./MiniAppScenes"; @@ -33,8 +32,7 @@ const PLUGIN_HERO_COMPOSER: ShowcaseHeroComposerConfig = { draftKey: "plugins-browse-hero", }; -export interface BrowseHeroCarouselProps { - archetypes?: readonly ShowcaseArchetype[]; +interface BrowseHeroCarouselProps { /** Stories force a slide and disable autoplay to capture a stable frame. */ initialIndex?: number; autoplay?: boolean; @@ -53,7 +51,6 @@ export interface BrowseHeroCarouselProps { * create-plugin prompt prefix. */ export function BrowseHeroCarousel({ - archetypes = BROWSE_ARCHETYPES, initialIndex = 0, autoplay = true, composerDisabled = false, @@ -62,7 +59,7 @@ export function BrowseHeroCarousel({ }: BrowseHeroCarouselProps) { return ( [] = [ { @@ -91,12 +87,12 @@ const ARCHETYPE_SOURCE: readonly Omit[] = [ export const BROWSE_ARCHETYPES: readonly BrowseArchetype[] = ARCHETYPE_SOURCE.map((archetype) => ({ ...archetype, - id: showcaseArchetypeId(archetype.title), + id: archetype.title.toLowerCase().replace(/[^a-z0-9]+/g, "-"), })); /** The full composer prompt for an archetype, matching the New plugin menu. */ export function archetypePrompt(archetype: BrowseArchetype): string { - return showcaseArchetypePrompt(CREATE_PLUGIN_PROMPT, archetype); + return `${CREATE_PLUGIN_PROMPT}${archetype.brief}.`; } /** @@ -108,7 +104,7 @@ export function archetypePrompt(archetype: BrowseArchetype): string { * Both tiers feed every create-plugin surface (hero cards, New plugin menu), * so the lists cannot drift apart. */ -export interface UtilityExample { +interface UtilityExample { id: string; /** The API surface this example exercises, in plain words. */ label: string; diff --git a/apps/app/src/components/plugin/composer-customizations.test.ts b/apps/app/src/components/plugin/composer-customizations.test.ts deleted file mode 100644 index fdcf77390d..0000000000 --- a/apps/app/src/components/plugin/composer-customizations.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { PluginComposerCustomizationSlot } from "@/lib/plugin-slots"; -import { composerCustomizationsForScope } from "./composer-customizations"; - -function customization( - id: string, - scopes?: PluginComposerCustomizationSlot["scopes"], -): PluginComposerCustomizationSlot { - return { - id, - pluginId: "scope-test", - generation: 1, - ...(scopes === undefined ? {} : { scopes }), - }; -} - -describe("composerCustomizationsForScope", () => { - it("preserves order and treats omitted scopes as all and empty scopes as none", () => { - const all = customization("all"); - const thread = customization("thread", ["thread"]); - const none = customization("none", []); - const newThread = customization("new-thread", ["new-thread"]); - - expect( - composerCustomizationsForScope([all, thread, none, newThread], "thread"), - ).toEqual([all, thread]); - }); -}); diff --git a/apps/app/src/components/plugin/composer-customizations.ts b/apps/app/src/components/plugin/composer-customizations.ts deleted file mode 100644 index 73687351ff..0000000000 --- a/apps/app/src/components/plugin/composer-customizations.ts +++ /dev/null @@ -1 +0,0 @@ -export { resolveComposerCustomizations as composerCustomizationsForScope } from "@/lib/plugin-slot-resolvers"; diff --git a/apps/app/src/components/plugin/file-opener-tabs.test.ts b/apps/app/src/components/plugin/file-opener-tabs.test.ts index 155eabaa8a..5661218e23 100644 --- a/apps/app/src/components/plugin/file-opener-tabs.test.ts +++ b/apps/app/src/components/plugin/file-opener-tabs.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import { threadTabsSchema } from "@bb/server-contract"; import type { PluginFileOpenerSlot } from "@/lib/plugin-slots"; import type { OpenSecondaryPanelTabRequest } from "@/components/secondary-panel/useThreadFileTabs"; -import { createFileOpenerTabForRequest } from "./file-opener-tabs"; +import { + buildFileOpenerPanelTab, + createFileOpenerOriginalTab, + createFileOpenerTabForRequest, + parseFileOpenerParams, +} from "./file-opener-tabs"; const MARKDOWN_OPENER = { component: () => null, @@ -94,4 +99,135 @@ describe("createFileOpenerTabForRequest thread-tabs contract", () => { }); expect(() => threadTabsSchema.parse([tab])).not.toThrow(); }); + + it("preserves the selected host for a project-backed opener", () => { + const tab = createFileOpenerTabForRequest({ + fileOpeners: [MARKDOWN_OPENER], + preference: {}, + projectHostId: "host_remote", + projectId: "proj_1", + request: { + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "docs/readme.md", + source: { kind: "working-tree" }, + statusLabel: null, + }, + }, + resolvedEnvironmentId: null, + threadId: null, + }); + + const params = parseFileOpenerParams(tab?.paramsJson ?? null); + expect(params?.source).toMatchObject({ + kind: "workspace", + projectId: "proj_1", + experimental_hostId: "host_remote", + }); + expect(() => threadTabsSchema.parse([tab])).not.toThrow(); + }); +}); + +describe("createFileOpenerOriginalTab", () => { + it("uses persisted workspace routing while retaining owner presentation", () => { + const openerTab = buildFileOpenerPanelTab( + MARKDOWN_OPENER, + { + path: "persisted/readme.md", + source: { + kind: "workspace", + environmentId: null, + experimental_hostId: "host_opened", + projectId: "proj_opened", + threadId: null, + }, + }, + { + environmentId: "env_stale", + kind: "workspace-file-preview", + projectId: "proj_stale", + tab: { + lineRange: { endLineNumber: 12, startLineNumber: 8 }, + path: "stale/readme.md", + source: { kind: "working-tree" }, + statusLabel: null, + }, + threadId: "thr_stale", + }, + ); + + expect(createFileOpenerOriginalTab(openerTab)).toMatchObject({ + environmentId: null, + kind: "workspace-file-preview", + lineRange: { endLineNumber: 12, startLineNumber: 8 }, + path: "persisted/readme.md", + projectId: "proj_opened", + source: { kind: "working-tree" }, + }); + }); + + it("uses persisted host routing instead of stale owner identity", () => { + const openerTab = buildFileOpenerPanelTab( + MARKDOWN_OPENER, + { + path: "/persisted/notes.md", + source: { + kind: "host", + environmentId: null, + experimental_hostId: "host_opened", + projectId: null, + threadId: null, + }, + }, + { + environmentId: null, + hostId: "host_stale", + kind: "host-file-preview", + tab: { + lineRange: { endLineNumber: 4, startLineNumber: 4 }, + path: "/stale/notes.md", + }, + threadId: null, + }, + ); + + expect(createFileOpenerOriginalTab(openerTab)).toMatchObject({ + environmentId: null, + hostId: "host_opened", + kind: "host-file-preview", + lineRange: { endLineNumber: 4, startLineNumber: 4 }, + path: "/persisted/notes.md", + threadId: null, + }); + }); + + it("uses persisted thread-storage routing instead of stale owner identity", () => { + const openerTab = buildFileOpenerPanelTab( + MARKDOWN_OPENER, + { + path: "persisted/plan.md", + source: { + kind: "thread-storage", + environmentId: "env_opened", + projectId: null, + threadId: "thr_opened", + }, + }, + { + environmentId: "env_stale", + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "stale/plan.md" }, + threadId: "thr_stale", + }, + ); + + expect(createFileOpenerOriginalTab(openerTab)).toMatchObject({ + environmentId: "env_opened", + isPinned: false, + kind: "thread-storage-file-preview", + path: "persisted/plan.md", + threadId: "thr_opened", + }); + }); }); diff --git a/apps/app/src/components/plugin/file-opener-tabs.ts b/apps/app/src/components/plugin/file-opener-tabs.ts index 0514104048..ce02981705 100644 --- a/apps/app/src/components/plugin/file-opener-tabs.ts +++ b/apps/app/src/components/plugin/file-opener-tabs.ts @@ -1,11 +1,9 @@ -import type { - PluginFileOpenerProps, - PluginFileOpenerSource, -} from "@get-bb/plugin-sdk"; +import type { PluginFileOpenerProps } from "@get-bb/plugin-sdk"; import type { ThreadTabFileOpenerOwner } from "@bb/server-contract"; import { createPluginPanelFixedPanelTab, type PluginPanelFixedPanelTab, + type SecondaryFileFixedPanelTab, } from "@/lib/fixed-panel-tabs-state"; import type { FileOpenerPreferenceMap } from "@/lib/file-opener-preference"; import { @@ -21,11 +19,18 @@ import type { OpenSecondaryPanelTabRequest } from "@/components/secondary-panel/ * the opened file (`PluginFileOpenerProps`). Same identity semantics as * action tabs — same opener + same file focuses the existing tab. */ -export const FILE_OPENER_ACTION_ID_PREFIX = "file-opener:"; +const FILE_OPENER_ACTION_ID_PREFIX = "file-opener:"; -export type PluginFileOpenerFile = Pick< - PluginFileOpenerProps, - "path" | "source" +type PluginFileOpenerFile = Pick; + +export type FileOpenerOriginalTab = Extract< + SecondaryFileFixedPanelTab, + { + kind: + | "workspace-file-preview" + | "host-file-preview" + | "thread-storage-file-preview"; + } >; export function fileOpenerIdFromActionId(actionId: string): string | null { @@ -65,12 +70,14 @@ export function parseFileOpenerParams( const { path, source } = parsed as { path?: unknown; source?: unknown }; if (typeof path !== "string" || path.length === 0) return null; if (typeof source !== "object" || source === null) return null; - const { kind, threadId, environmentId, projectId } = source as { - kind?: unknown; - threadId?: unknown; - environmentId?: unknown; - projectId?: unknown; - }; + const { kind, threadId, environmentId, projectId, experimental_hostId } = + source as { + kind?: unknown; + threadId?: unknown; + environmentId?: unknown; + projectId?: unknown; + experimental_hostId?: unknown; + }; if (kind !== "workspace" && kind !== "host" && kind !== "thread-storage") { return null; } @@ -81,25 +88,75 @@ export function parseFileOpenerParams( threadId: typeof threadId === "string" ? threadId : null, environmentId: typeof environmentId === "string" ? environmentId : null, projectId: typeof projectId === "string" ? projectId : null, + ...(typeof experimental_hostId === "string" + ? { experimental_hostId } + : {}), }, }; } /** - * A per-open viewer choice (the link context menu): "builtin" pins the - * built-in preview; an opener ref forces that plugin opener. Absent means - * follow the extension's automatic or pinned Settings choice. + * Rebuild the native preview behind a plugin file opener. Persisted params own + * file/routing identity; the owner retains only native presentation state. */ -export type FileTabViewerOverride = FileOpenerOverride; +export function createFileOpenerOriginalTab( + tab: PluginPanelFixedPanelTab, +): FileOpenerOriginalTab | null { + const owner = tab.fileOpenerOwner; + const file = parseFileOpenerParams(tab.paramsJson); + if (owner === undefined || file === null) return null; + + const id = `${tab.id}:file-opener-original`; + if ( + owner.kind === "workspace-file-preview" && + file.source.kind === "workspace" + ) { + return { + ...owner.tab, + environmentId: file.source.environmentId, + id, + kind: "workspace-file-preview", + path: file.path, + projectId: file.source.projectId, + }; + } + if (owner.kind === "host-file-preview" && file.source.kind === "host") { + return { + ...owner.tab, + environmentId: file.source.environmentId, + hostId: file.source.experimental_hostId ?? null, + id, + kind: "host-file-preview", + path: file.path, + threadId: file.source.threadId, + }; + } + if ( + owner.kind === "thread-storage-file-preview" && + file.source.kind === "thread-storage" + ) { + return { + ...owner.tab, + environmentId: file.source.environmentId, + id, + isPinned: false, + kind: "thread-storage-file-preview", + path: file.path, + threadId: file.source.threadId, + }; + } + return null; +} -export interface CreateFileOpenerTabForRequestArgs { +interface CreateFileOpenerTabForRequestArgs { fileOpeners: readonly PluginFileOpenerSlot[]; preference: FileOpenerPreferenceMap; + projectHostId?: string | null; projectId: string | null; request: OpenSecondaryPanelTabRequest; resolvedEnvironmentId: string | null | undefined; threadId: string | null | undefined; - viewer?: FileTabViewerOverride; + viewer?: FileOpenerOverride; } /** @@ -111,6 +168,7 @@ export interface CreateFileOpenerTabForRequestArgs { export function createFileOpenerTabForRequest({ fileOpeners, preference, + projectHostId, projectId, request, resolvedEnvironmentId, @@ -125,14 +183,27 @@ export function createFileOpenerTabForRequest({ }); if (owner === null) return null; const file = fileForOwnerRequest(owner); + const routedFile: PluginFileOpenerFile = + file.source.kind === "workspace" && + file.source.environmentId === null && + file.source.projectId !== null && + projectHostId + ? { + ...file, + source: { + ...file.source, + experimental_hostId: projectHostId, + }, + } + : file; const resolved = resolveFileOpenerReplacement({ registrations: fileOpeners, preference, - path: file.path, + path: routedFile.path, ...(viewer !== undefined ? { override: viewer } : {}), }); return resolved.kind === "plugin" - ? buildFileOpenerPanelTab(resolved.registration, file, owner) + ? buildFileOpenerPanelTab(resolved.registration, routedFile, owner) : null; } @@ -148,33 +219,51 @@ function ownerRequestForOpenRequest({ switch (request.kind) { case "workspace-file-preview": { // Same guard as the built-in path, plus live-content-only rules. - if (resolvedEnvironmentId === undefined) return null; + if ( + request.environmentId === undefined && + resolvedEnvironmentId === undefined + ) { + return null; + } if (request.tab.source.kind !== "working-tree") return null; if (request.tab.statusLabel === "deleted") return null; + const environmentId = + request.environmentId ?? resolvedEnvironmentId ?? null; return { kind: request.kind, - environmentId: resolvedEnvironmentId, - projectId: resolvedEnvironmentId === null ? projectId : null, + environmentId, + projectId: environmentId === null ? projectId : null, tab: request.tab, threadId: threadId ?? null, }; } case "host-file-preview": { + if (request.hostId !== undefined) { + return { + kind: request.kind, + environmentId: null, + hostId: request.hostId, + tab: request.tab, + threadId: null, + }; + } if (!threadId || !resolvedEnvironmentId) return null; return { kind: request.kind, environmentId: resolvedEnvironmentId, + hostId: null, tab: request.tab, threadId, }; } case "thread-storage-file-preview": { - if (!threadId) return null; + const storageThreadId = request.threadId ?? threadId; + if (!storageThreadId) return null; return { kind: request.kind, environmentId: resolvedEnvironmentId ?? null, tab: request.tab, - threadId, + threadId: storageThreadId, }; } default: @@ -189,40 +278,35 @@ function fileForOwnerRequest( case "workspace-file-preview": return { path: owner.tab.path, - source: buildSource("workspace", { + source: { + kind: "workspace", environmentId: owner.environmentId, projectId: owner.projectId, threadId: owner.threadId, - }), + }, }; case "host-file-preview": return { path: owner.tab.path, - source: buildSource("host", { + source: { + kind: "host", environmentId: owner.environmentId, + ...(owner.hostId === null + ? {} + : { experimental_hostId: owner.hostId }), projectId: null, threadId: owner.threadId, - }), + }, }; case "thread-storage-file-preview": return { path: owner.tab.path, - source: buildSource("thread-storage", { + source: { + kind: "thread-storage", environmentId: owner.environmentId, projectId: null, threadId: owner.threadId, - }), + }, }; } } - -function buildSource( - kind: PluginFileOpenerSource["kind"], - fields: { - environmentId: string | null; - projectId: string | null; - threadId: string | null; - }, -): PluginFileOpenerSource { - return { kind, ...fields }; -} diff --git a/apps/app/src/components/plugin/management/AddPluginDialog.tsx b/apps/app/src/components/plugin/management/AddPluginDialog.tsx index 8675071afe..f7dde552b9 100644 --- a/apps/app/src/components/plugin/management/AddPluginDialog.tsx +++ b/apps/app/src/components/plugin/management/AddPluginDialog.tsx @@ -70,7 +70,7 @@ function catalogInstallDescription( return `Install this ${publisherLabel} plugin from its listed source repository.`; } -export interface AddPluginDialogProps { +interface AddPluginDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onInstalled?: (plugin: InstalledPlugin) => void; diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index b5d87f6cfa..769b97a890 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -480,7 +480,7 @@ function BrowseCard({ > uninstall.mutate()} diff --git a/apps/app/src/components/plugin/management/PluginRowSignal.stories.tsx b/apps/app/src/components/plugin/management/PluginRowSignal.stories.tsx index 05d144c1e9..6d51f3117c 100644 --- a/apps/app/src/components/plugin/management/PluginRowSignal.stories.tsx +++ b/apps/app/src/components/plugin/management/PluginRowSignal.stories.tsx @@ -61,7 +61,7 @@ function StateRow({ /** * Every state of the installed row's status/action slot around updates, * following the multi-state pattern: scroll one story, review the surface. - * The control is a quiet neutral button whose tinted up-arrow carries the + * The control is a quiet neutral button whose tinted download mark carries the * tone; it names a version only when the version is readable — git sources * report commit hashes, which belong (shortened) in the dialog, not the row. */ diff --git a/apps/app/src/components/plugin/management/PluginRowSignal.test.tsx b/apps/app/src/components/plugin/management/PluginRowSignal.test.tsx index e89c97d3f5..fda6e0c84b 100644 --- a/apps/app/src/components/plugin/management/PluginRowSignal.test.tsx +++ b/apps/app/src/components/plugin/management/PluginRowSignal.test.tsx @@ -8,6 +8,24 @@ import { displayPluginVersion } from "./plugin-ui"; afterEach(cleanup); describe("PluginRowSignalView", () => { + it("uses the shared update-action icon", () => { + render( + , + ); + + expect( + screen + .getByRole("button", { + name: "Update to 1.9.0", + }) + .querySelector('[data-icon="Download"]'), + ).not.toBeNull(); + }); + it("keeps runtime health icon-only until hover or focus and opens details", async () => { const onStatusClick = vi.fn(); render( diff --git a/apps/app/src/components/plugin/management/PluginRowSignal.tsx b/apps/app/src/components/plugin/management/PluginRowSignal.tsx index ea94feeb59..b081cfd93b 100644 --- a/apps/app/src/components/plugin/management/PluginRowSignal.tsx +++ b/apps/app/src/components/plugin/management/PluginRowSignal.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { UPDATE_ACTION_ICON } from "@bb/domain/update-state"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -72,7 +73,7 @@ export function PluginRowSignalView({ aria-label={updateDescription} onClick={onUpdateClick} > - + {updateDescription} diff --git a/apps/app/src/components/plugin/management/PluginUpdatesCard.test.tsx b/apps/app/src/components/plugin/management/PluginUpdatesCard.test.tsx index 9010b9fe7f..2178c790a0 100644 --- a/apps/app/src/components/plugin/management/PluginUpdatesCard.test.tsx +++ b/apps/app/src/components/plugin/management/PluginUpdatesCard.test.tsx @@ -95,9 +95,11 @@ describe("PluginDetailReleaseControl", () => { { wrapper }, ); - expect( - screen.getByRole("button", { name: "Update Linear to 1.9.0" }), - ).toBeTruthy(); + const update = screen.getByRole("button", { + name: "Update Linear to 1.9.0", + }); + expect(update).toBeTruthy(); + expect(update.querySelector('[data-icon="Download"]')).not.toBeNull(); expect(screen.queryByText("Compatible with your bb.")).toBeNull(); }); diff --git a/apps/app/src/components/plugin/management/PluginUpdatesCard.tsx b/apps/app/src/components/plugin/management/PluginUpdatesCard.tsx index e56ffb16c0..1a7fca041e 100644 --- a/apps/app/src/components/plugin/management/PluginUpdatesCard.tsx +++ b/apps/app/src/components/plugin/management/PluginUpdatesCard.tsx @@ -1,19 +1,14 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { UPDATE_ACTION_ICON } from "@bb/domain/update-state"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; -import { PluginBannerBar } from "@/components/tools/plugin-detail-banner"; import { appToast } from "@/components/ui/app-toast"; import { invalidatePluginList } from "@/hooks/cache-owners/plugin-cache-owner"; import { applyPluginUpdate } from "@/hooks/queries/plugin-catalog-queries"; import type { PluginListItem } from "@/hooks/queries/plugin-settings-queries"; import { pluginAdminErrorMessage } from "@/lib/plugin-admin-error"; -import { pluginUpdateAvailableVersion } from "./plugin-status"; -import { - DetailsDisclosure, - displayPluginVersion, - formatAbsoluteDate, -} from "./plugin-ui"; +import { DetailsDisclosure, displayPluginVersion } from "./plugin-ui"; import { UpdatePluginDialog } from "./UpdatePluginDialog"; /** @@ -28,63 +23,8 @@ export function pluginHasUpdateSurfaces(plugin: PluginListItem): boolean { return plugin.provenance === "direct" || plugin.provenance === "catalog"; } -export function PluginUpdateBanner({ plugin }: { plugin: PluginListItem }) { - const [updateOpen, setUpdateOpen] = useState(false); - const availableVersion = pluginUpdateAvailableVersion(plugin); - const failure = plugin.updateState.lastFailure; - - if (!pluginHasUpdateSurfaces(plugin)) return null; - - if (failure !== null) { - return ( - 0 - ? failure.detail - : `Code and data were restored to ${displayPluginVersion(plugin.version)}.` - } - /> - ); - } - - if (availableVersion === null) return null; - - return ( - <> - setUpdateOpen(true)} - > - Update - - } - /> - - - ); -} - /** The newest release that exists but cannot run on this bb version. */ -export function pluginCompatibilityBlockedVersion( +function pluginCompatibilityBlockedVersion( plugin: PluginListItem, ): string | null { if (!pluginHasUpdateSurfaces(plugin)) return null; @@ -180,7 +120,7 @@ export function PluginDetailReleaseControl({ aria-label={`Update ${plugin.name ?? plugin.id} to ${displayPluginVersion(availableVersion)}`} onClick={() => setDetailsOpen(true)} > - + Update void; @@ -237,7 +238,9 @@ function UpdatePluginDialogContent({ > {update.isPending ? ( - ) : null} + ) : ( + + )} Update @@ -303,6 +306,7 @@ function UpdatePluginDialogContent({ Close diff --git a/apps/app/src/components/plugin/management/plugin-status.ts b/apps/app/src/components/plugin/management/plugin-status.ts index f45081b8cb..360edab3df 100644 --- a/apps/app/src/components/plugin/management/plugin-status.ts +++ b/apps/app/src/components/plugin/management/plugin-status.ts @@ -10,7 +10,7 @@ export interface PluginRuntimeStatusPresentation { recovery: string; } -export type PluginRuntimeStatusDefinition = Omit< +type PluginRuntimeStatusDefinition = Omit< PluginRuntimeStatusPresentation, "condition" | "recovery" >; @@ -20,7 +20,7 @@ export type PluginRuntimeStatusDefinition = Omit< * remains lifecycle state, while updates remain release state; neither is * folded into this health vocabulary. */ -export const PLUGIN_RUNTIME_STATUS_DEFINITIONS: Record< +const PLUGIN_RUNTIME_STATUS_DEFINITIONS: Record< PluginRuntimeStatus, PluginRuntimeStatusDefinition | null > = { @@ -41,12 +41,6 @@ export const PLUGIN_RUNTIME_STATUS_DEFINITIONS: Record< degraded: { icon: "AlertTriangle", label: "Degraded", tone: "warning" }, }; -export function pluginRuntimeStatusDefinition( - status: PluginRuntimeStatus, -): PluginRuntimeStatusDefinition | null { - return PLUGIN_RUNTIME_STATUS_DEFINITIONS[status]; -} - function pluginRuntimeRecovery(plugin: PluginListItem): string { switch (plugin.status) { case "error": @@ -96,7 +90,7 @@ function pluginRuntimeCondition(plugin: PluginListItem): string { export function pluginRuntimeStatusPresentation( plugin: PluginListItem, ): PluginRuntimeStatusPresentation | null { - const definition = pluginRuntimeStatusDefinition(plugin.status); + const definition = PLUGIN_RUNTIME_STATUS_DEFINITIONS[plugin.status]; if (definition === null) return null; return { ...definition, @@ -165,11 +159,3 @@ export function pluginRowSignal( } return null; } - -/** The detail-page banner mirrors the row pill's update case. */ -export function pluginUpdateAvailableVersion( - plugin: PluginListItem, -): string | null { - const signal = pluginRowSignal(plugin); - return signal?.kind === "update" ? signal.version : null; -} diff --git a/apps/app/src/components/plugin/management/plugin-ui.tsx b/apps/app/src/components/plugin/management/plugin-ui.tsx index 48f0fb0629..c4e44b816b 100644 --- a/apps/app/src/components/plugin/management/plugin-ui.tsx +++ b/apps/app/src/components/plugin/management/plugin-ui.tsx @@ -22,7 +22,7 @@ import type { PluginListItem } from "@/hooks/queries/plugin-settings-queries"; */ /** - * The update control's icon accent: a quiet neutral button whose up-arrow + * The update control's icon accent: a quiet neutral button whose download mark * carries the "improvement available" tone, instead of a full green pill * shouting over the row. */ @@ -48,18 +48,6 @@ export function displayPluginVersion(version: string): string { return /^[0-9a-f]{12,}$/iu.test(version) ? version.slice(0, 7) : version; } -/** Success verdict banner tint (sketch v2 `.banner`). */ -export const SUCCESS_BANNER_STYLE = { - background: "color-mix(in oklab, var(--success) 9%, var(--canvas))", - borderColor: "color-mix(in oklab, var(--success) 35%, var(--canvas))", -} as const; - -/** Warning note tint (sketch `.notebox.warn`, full-trust warning). */ -export const WARNING_NOTE_STYLE = { - background: "color-mix(in oklab, var(--warning-text) 6%, var(--canvas))", - borderColor: "color-mix(in oklab, var(--warning-text) 35%, var(--canvas))", -} as const; - export const SUCCESS_TEXT_STYLE = { color: "color-mix(in oklab, var(--success) 80%, var(--ink))", } as const; @@ -160,7 +148,7 @@ export function CatalogEntryIcon({ * the entry's initial and not a tile. The `className` sizes the footprint so * it aligns with sibling logo images. */ -export function PlaceholderBadge({ +function PlaceholderBadge({ className, iconName = "Zap", }: { @@ -188,7 +176,7 @@ export function formatAbsoluteDate(epochMs: number): string { }); } -export interface DetailsDisclosureProps { +interface DetailsDisclosureProps { summary: string; children: ReactNode; /** Pre-expand when the details are the story (failure, skipped release). */ @@ -240,19 +228,14 @@ export function DetailsDisclosure({ export function KeyValueGrid({ entries, }: { - entries: { key: string; value: ReactNode; mono?: boolean }[]; + entries: { key: string; value: ReactNode }[]; }) { return (
{entries.map((entry) => (
{entry.key}
-
+
{entry.value}
diff --git a/apps/app/src/components/plugin/new-thread-environment-seed.ts b/apps/app/src/components/plugin/new-thread-environment-seed.ts index 5ee781f135..6651aba501 100644 --- a/apps/app/src/components/plugin/new-thread-environment-seed.ts +++ b/apps/app/src/components/plugin/new-thread-environment-seed.ts @@ -11,7 +11,7 @@ import type { RootComposeSelectedBranch } from "@/views/root-compose-thread-envi * inverse of `resolveRootComposeThreadEnvironment`, up to the limits listed * on `NewThreadComposerProps.defaultEnvironment`. */ -export interface NewThreadEnvironmentSeed { +interface NewThreadEnvironmentSeed { selectionValue: string; branch: RootComposeSelectedBranch | null; } diff --git a/apps/app/src/components/plugin/plugin-composer-host.tsx b/apps/app/src/components/plugin/plugin-composer-host.tsx index b0b73a9e02..70ffa3ec61 100644 --- a/apps/app/src/components/plugin/plugin-composer-host.tsx +++ b/apps/app/src/components/plugin/plugin-composer-host.tsx @@ -11,7 +11,7 @@ import { } from "react"; import type { ComposerView, PluginComposerScope } from "@get-bb/plugin-sdk"; import { isComposerDraftEmpty } from "@get-bb/plugin-sdk/internal/composer-view"; -import type { PromptDraftState } from "@/lib/prompt-draft"; +import type { PromptDraftState } from "@bb/client-core"; /** * Binds plugin composer hooks to the exact composer owned by a pane. This is @@ -41,7 +41,7 @@ export function composerScopeIdentity(scope: PluginComposerScope): string { } } -export interface PluginComposerViewModelInput { +interface PluginComposerViewModelInput { scope: PluginComposerScope; layout: ComposerView["layout"]; text: string; diff --git a/apps/app/src/components/plugin/plugin-execution-routing.ts b/apps/app/src/components/plugin/plugin-execution-routing.ts new file mode 100644 index 0000000000..eb75730997 --- /dev/null +++ b/apps/app/src/components/plugin/plugin-execution-routing.ts @@ -0,0 +1,26 @@ +import type { SystemProvidersQuery } from "@bb/server-contract"; +import type { ExperimentalProviderModelPickerRouting } from "@get-bb/plugin-sdk"; + +export interface ResolvedPluginExecutionRouting { + key: string; + query: SystemProvidersQuery; +} + +/** Keep every plugin execution control on the same routed query identity. */ +export function resolvePluginExecutionRouting( + routing: ExperimentalProviderModelPickerRouting | undefined, +): ResolvedPluginExecutionRouting { + if (routing?.kind === "host") { + return { + key: `host:${routing.hostId}`, + query: { hostId: routing.hostId }, + }; + } + if (routing?.kind === "environment") { + return { + key: `environment:${routing.environmentId}`, + query: { environmentId: routing.environmentId }, + }; + } + return { key: "primary", query: {} }; +} diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx index 332494c36f..44e97f8fd9 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -41,6 +41,7 @@ import { PluginPanelHeaderCenter, } from "./PluginPanelHeader"; import { resetAllCrashedPluginSlotsForTest } from "./PluginSlotMount"; +import { applyPluginCss, resetPluginCssForTest } from "@/lib/plugin-css"; import { ComposerActionsSlot } from "./PluginComposerActions"; import { PluginContext } from "./plugin-context"; import { @@ -68,7 +69,7 @@ import { import { NewTabActions } from "@/components/secondary-panel/NewTabFileSearch"; import { buildFileOpenerPanelTab } from "./file-opener-tabs"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; -import type { PromptDraftState } from "@/lib/prompt-draft"; +import type { PromptDraftState } from "@bb/client-core"; function composerTextEffectValues(storageKey: string | null) { return getComposerTextEffects(storageKey).map(({ effect }) => effect); @@ -96,6 +97,7 @@ afterEach(() => { resetPluginFrontendBootStateForTest(); window.localStorage.clear(); resetAllCrashedPluginSlotsForTest(); + resetPluginCssForTest(); vi.restoreAllMocks(); }); @@ -1311,6 +1313,58 @@ describe("PluginNavSidebarItems + PluginPanelView", () => { expect(screen.getByText("board panel body")).toBeDefined(); }); + it("releases the plugin stylesheet when navigation unmounts the panel route", async () => { + setPluginSlotRegistrations( + "demo", + registrationSet({ + navPanels: [ + { + id: "board", + title: "Demo board", + icon: "columns", + path: "board", + component: Board, + }, + ], + }), + ); + applyPluginCss("demo", "/demo.css?h=route"); + function LeavePanel() { + const navigate = useNavigate(); + return ( + + ); + } + render( + + + + + + + } + /> + home
} /> + + , + ); + expect( + document.head.querySelector('link[data-bb-plugin-css="demo"]'), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Leave panel" })); + await act(async () => {}); + expect(screen.getByText("home")).toBeDefined(); + expect( + document.head.querySelector('link[data-bb-plugin-css="demo"]'), + ).toBeNull(); + }); + it("shows a plugin panel's position when it is open in a split", () => { setPluginSlotRegistrations( "demo", @@ -1534,12 +1588,13 @@ describe("plugin panel shared title bar and full-bleed body", () => { expect(screen.queryByText(/plugin demo crashed/)).toBeNull(); }); - it("always renders the shared title and headerContent", () => { + it("gives headerContent independent CSS ownership without a mounted panel body", async () => { function Accessory() { return ; } const panel = panelSlot({ headerContent: Accessory }); - render( + applyPluginCss("demo", "/demo.css?h=header"); + const view = render( <> @@ -1549,6 +1604,16 @@ describe("plugin panel shared title bar and full-bleed body", () => { expect( screen.getByRole("button", { name: "Toggle sidebar" }), ).toBeDefined(); + expect( + document.head.querySelector('link[data-bb-plugin-css="demo"]'), + ).not.toBeNull(); + expect(screen.queryByTestId("plugin-panel-body")).toBeNull(); + + view.unmount(); + await act(async () => {}); + expect( + document.head.querySelector('link[data-bb-plugin-css="demo"]'), + ).toBeNull(); }); it("keys the right-panel toggle target to its owning pane", () => { diff --git a/apps/app/src/components/plugin/plugin-thread-panel-navigation.test.tsx b/apps/app/src/components/plugin/plugin-thread-panel-navigation.test.tsx index 4b513c6134..9fa7ef5d3d 100644 --- a/apps/app/src/components/plugin/plugin-thread-panel-navigation.test.tsx +++ b/apps/app/src/components/plugin/plugin-thread-panel-navigation.test.tsx @@ -1,80 +1,59 @@ // @vitest-environment jsdom -import { useState } from "react"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useBbNavigate } from "@/lib/plugin-sdk-hooks"; -import { PluginSlotMount } from "./PluginSlotMount"; -import { PluginThreadPanelNavigationProvider } from "./plugin-thread-panel-navigation"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { + getActiveThreadPanelOpener, + resetActiveThreadPanelOpenerForTest, + usePublishThreadPanelOpener, + type PluginThreadPanelOpenHandler, +} from "./plugin-thread-panel-navigation"; + +function Pane({ + opener, + isFocused, +}: { + opener: PluginThreadPanelOpenHandler; + isFocused: boolean; +}) { + usePublishThreadPanelOpener(opener, isFocused); + return null; +} -afterEach(cleanup); +afterEach(() => { + cleanup(); + resetActiveThreadPanelOpenerForTest(); +}); -function NavigationProbe() { - const navigate = useBbNavigate(); - const [accepted, setAccepted] = useState(null); - return ( +it("publishes only the focused pane's opener", () => { + // A split mounts one thread view per pane, each with its own panel tabs, so + // an unscoped store would open a plugin's panel in whichever pane mounted + // last rather than the one the user is looking at. + const left = vi.fn(() => true); + const right = vi.fn(() => true); + const { rerender } = render( <> - - - {accepted === null ? "idle" : accepted ? "accepted" : "rejected"} - - - ); -} - -function PluginProbe() { - return ( - - - + + + , ); -} -describe("plugin thread-panel navigation", () => { - it("binds generic panel requests to the calling plugin", () => { - const openThreadPanel = vi.fn(() => true); - render( - - - - - , - ); + getActiveThreadPanelOpener()?.({ actionId: "a", pluginId: "p" }); + expect(left).toHaveBeenCalledTimes(1); + expect(right).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("button", { name: "Open details" })); - - expect(openThreadPanel).toHaveBeenCalledWith({ - pluginId: "workflows", - actionId: "details", - title: "Run details", - params: { runId: "run_1" }, - }); - expect(screen.getByText("accepted")).toBeTruthy(); - }); - - it("returns false outside a thread-panel surface", () => { - render( - - - , - ); - - fireEvent.click(screen.getByRole("button", { name: "Open details" })); + rerender( + <> + + + , + ); + getActiveThreadPanelOpener()?.({ actionId: "a", pluginId: "p" }); + expect(right).toHaveBeenCalledTimes(1); + expect(left).toHaveBeenCalledTimes(1); +}); - expect(screen.getByText("rejected")).toBeTruthy(); - }); +it("reports no opener when no thread view is focused", () => { + render( true)} isFocused={false} />); + expect(getActiveThreadPanelOpener()).toBeNull(); }); diff --git a/apps/app/src/components/plugin/plugin-thread-panel-navigation.tsx b/apps/app/src/components/plugin/plugin-thread-panel-navigation.tsx index bb0a2afb64..3e67d12bb2 100644 --- a/apps/app/src/components/plugin/plugin-thread-panel-navigation.tsx +++ b/apps/app/src/components/plugin/plugin-thread-panel-navigation.tsx @@ -1,4 +1,11 @@ -import { createContext, type ReactNode, useContext } from "react"; +import { + createContext, + useEffect, + useLayoutEffect, + useRef, + type ReactNode, + useContext, +} from "react"; import type { BbNavigate } from "@get-bb/plugin-sdk"; export type PluginThreadPanelOpenHandler = ( @@ -27,3 +34,57 @@ export function PluginThreadPanelNavigationProvider({ export function usePluginThreadPanelOpenHandler(): PluginThreadPanelOpenHandler | null { return useContext(PluginThreadPanelNavigationContext); } + +// --------------------------------------------------------------------------- +// Active opener, for host surfaces mounted outside the provider +// --------------------------------------------------------------------------- + +/** + * The focused thread view's opener, published to a module-level store. + * + * The context above only reaches descendants of a thread view, which is right + * for the timeline and its message actions. The quick palette is mounted by + * `AppLayout` beside the routes, so it can never read that context, yet a + * plugin's palette row must still be able to open that plugin's panel in the + * thread the user is looking at. + * + * Only the focused pane publishes, because a split has one opener per pane and + * "the thread side panel" otherwise has no single meaning. A lone thread view + * counts as focused (see `DefaultPaneContextProvider`). + */ +const focusedOpeners = new Map(); + +export function usePublishThreadPanelOpener( + openThreadPanel: PluginThreadPanelOpenHandler, + isActive: boolean, +): void { + const handlerRef = useRef(openThreadPanel); + useLayoutEffect(() => { + handlerRef.current = openThreadPanel; + }, [openThreadPanel]); + const tokenRef = useRef(null); + tokenRef.current ??= Symbol("thread-panel-opener"); + useEffect(() => { + const token = tokenRef.current; + if (token === null || !isActive) return; + focusedOpeners.set(token, (options) => handlerRef.current(options)); + return () => { + focusedOpeners.delete(token); + }; + }, [isActive]); +} + +/** + * Null when no thread view is on screen — the palette then reports a declined + * open to the plugin rather than pretending. During a focus handover two views + * can briefly claim focus; the most recent one wins. + */ +export function getActiveThreadPanelOpener(): PluginThreadPanelOpenHandler | null { + let active: PluginThreadPanelOpenHandler | null = null; + for (const opener of focusedOpeners.values()) active = opener; + return active; +} + +export function resetActiveThreadPanelOpenerForTest(): void { + focusedOpeners.clear(); +} diff --git a/apps/app/src/components/plugin/pluginNavSidebarOrder.ts b/apps/app/src/components/plugin/pluginNavSidebarOrder.ts index 9f86a3dfa5..11a0396828 100644 --- a/apps/app/src/components/plugin/pluginNavSidebarOrder.ts +++ b/apps/app/src/components/plugin/pluginNavSidebarOrder.ts @@ -9,7 +9,7 @@ * place and a renamed panel id starts fresh at the end of the list. */ -export interface PluginNavPanelIdentity { +interface PluginNavPanelIdentity { pluginId: string; id: string; } @@ -27,9 +27,7 @@ interface ArrangePluginNavPanelsArgs { hiddenKeys: readonly string[]; } -export interface ArrangedPluginNavPanels< - TPanel extends PluginNavPanelIdentity, -> { +interface ArrangedPluginNavPanels { /** Panels rendered in the sidebar proper, in user order. */ visible: TPanel[]; /** Panels parked in the "More" disclosure, in user order. */ diff --git a/apps/app/src/components/project/ProjectActionsMenu.tsx b/apps/app/src/components/project/ProjectActionsMenu.tsx index c3d162c0d8..8b547a5772 100644 --- a/apps/app/src/components/project/ProjectActionsMenu.tsx +++ b/apps/app/src/components/project/ProjectActionsMenu.tsx @@ -32,7 +32,6 @@ interface ProjectActionsMenuBaseProps { interface ProjectActionsMenuProps extends ProjectActionsMenuBaseProps { triggerClassName?: string; - align?: "start" | "center" | "end"; onOpenChange?: (open: boolean) => void; } @@ -176,7 +175,6 @@ function ProjectActionsMenuItems({ export function ProjectActionsMenu({ project, triggerClassName, - align = "end", onOpenChange, }: ProjectActionsMenuProps) { return ( @@ -203,7 +201,7 @@ export function ProjectActionsMenu({ diff --git a/apps/app/src/components/project/ProjectActionsProvider.tsx b/apps/app/src/components/project/ProjectActionsProvider.tsx index 19291ddee9..c6597dfcd2 100644 --- a/apps/app/src/components/project/ProjectActionsProvider.tsx +++ b/apps/app/src/components/project/ProjectActionsProvider.tsx @@ -31,7 +31,7 @@ import { import { collapsedProjectIdsAtom } from "@/components/sidebar/sidebarCollapsedAtoms"; import { getRootComposeRoutePath } from "@/lib/route-paths"; -export interface ProjectActionsContextValue { +interface ProjectActionsContextValue { requestRename: (project: ProjectResponse) => void; requestDelete: (project: ProjectResponse) => void; requestAddLocalPath: (project: ProjectResponse) => void; diff --git a/apps/app/src/components/promptbox/AttachmentPreview.tsx b/apps/app/src/components/promptbox/AttachmentPreview.tsx index c25207c1a2..dcb8a2d324 100644 --- a/apps/app/src/components/promptbox/AttachmentPreview.tsx +++ b/apps/app/src/components/promptbox/AttachmentPreview.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { getWrappedImageIndex, ImageLightbox } from "@/components/ui/image-lightbox.js"; import { Icon } from "@bb/shared-ui/icon"; -import type { PromptDraftAttachment } from "@/lib/prompt-draft"; +import type { PromptDraftAttachment } from "@bb/client-core"; import { toUserAttachmentImageSrc } from "@/lib/user-attachment-images"; import { getLocalAttachmentPreviewSrc, diff --git a/apps/app/src/components/promptbox/ComposerEditorSlot.tsx b/apps/app/src/components/promptbox/ComposerEditorSlot.tsx index b4351ce15f..93082a4370 100644 --- a/apps/app/src/components/promptbox/ComposerEditorSlot.tsx +++ b/apps/app/src/components/promptbox/ComposerEditorSlot.tsx @@ -7,14 +7,16 @@ import { type PromptMentionLinkResolver, } from "./editor/prompt-mention-link"; -export type ComposerEditorLayout = "thread" | "root-compose"; +type ComposerEditorLayout = "thread" | "root-compose"; const COMPOSER_EDITOR_MAX_HEIGHT_BY_LAYOUT: Record< ComposerEditorLayout, string > = { - thread: "50dvh", - "root-compose": "70dvh", + // Reserve the fixed action row and border so the standard prompt box does + // not grow beyond its intended viewport-relative cap. + thread: "calc(50dvh - 3rem)", + "root-compose": "calc(70dvh - 3rem)", }; // TipTap's `blur` command defers to the next animation frame, so blur the @@ -29,8 +31,6 @@ export function ComposerEditorSlot({ editor, scrollContainerRef, inputLocked, - isZenMode, - hasCompactControls, isCompactLayout, minHeight, layout, @@ -39,8 +39,6 @@ export function ComposerEditorSlot({ editor: Editor | null; scrollContainerRef: RefObject; inputLocked: boolean; - isZenMode: boolean; - hasCompactControls: boolean; isCompactLayout: boolean; minHeight: number; layout: ComposerEditorLayout; @@ -58,22 +56,14 @@ export function ComposerEditorSlot({ // text size utilities as owning line-height and would otherwise drop // this, making Composer rows tighter than timeline messages. "leading-relaxed", - isZenMode && "min-h-0 flex-1", - hasCompactControls && !isZenMode && "pr-14", isCompactLayout && "h-12 overflow-hidden pb-0 pr-14 pt-0", )} style={{ - minHeight: isZenMode - ? "0px" - : isCompactLayout - ? "48px" - : `${minHeight}px`, - height: isZenMode ? "100%" : isCompactLayout ? "48px" : undefined, - maxHeight: isZenMode - ? "none" - : isCompactLayout - ? "48px" - : COMPOSER_EDITOR_MAX_HEIGHT_BY_LAYOUT[layout], + minHeight: isCompactLayout ? "48px" : `${minHeight}px`, + height: isCompactLayout ? "48px" : undefined, + maxHeight: isCompactLayout + ? "48px" + : COMPOSER_EDITOR_MAX_HEIGHT_BY_LAYOUT[layout], }} > diff --git a/apps/app/src/components/promptbox/ExecutionControls.tsx b/apps/app/src/components/promptbox/ExecutionControls.tsx index 51ddb3203e..a588fd52f6 100644 --- a/apps/app/src/components/promptbox/ExecutionControls.tsx +++ b/apps/app/src/components/promptbox/ExecutionControls.tsx @@ -11,17 +11,17 @@ import { } from "@/components/pickers/ModelReasoningPicker"; import { type PickerOption } from "@/components/pickers/OptionPicker"; import type { ModelPickerOption } from "@/components/pickers/model-picker-option"; +import type { ProviderPickerOption } from "@/components/pickers/model-brand-prefix"; -export interface ExecutionProviderConfig { - options?: readonly PickerOption[]; +interface ExecutionProviderConfig { + options?: readonly ProviderPickerOption[]; selectedId?: string; /** Omit to render the provider as locked (used by FollowUp where the thread is committed). */ onChange?: (value: string) => void; hasMultiple?: boolean; - displayName?: string; } -export interface ExecutionModelConfig { +interface ExecutionModelConfig { active?: { model: string } | null; selected: string; options: readonly ModelPickerOption[]; @@ -33,14 +33,14 @@ export interface ExecutionModelConfig { onChange: (value: string) => void; } -export interface ExecutionServiceTierConfig { +interface ExecutionServiceTierConfig { value?: ServiceTier; onChange: (value: ServiceTier | undefined) => void; supported: boolean; supportByProvider?: Record; } -export interface ExecutionReasoningConfig { +interface ExecutionReasoningConfig { value: ReasoningLevel; options: readonly PickerOption[]; onChange: (value: ReasoningLevel) => void; diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx index ad7b36e7d3..3419369d75 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx @@ -62,8 +62,8 @@ import type { ExecutionPermissionConfig, } from "@/components/promptbox/ExecutionControls"; import { PageShell } from "@/components/ui/page-shell.js"; -import { promptDraftToInput, type PromptDraftState } from "@/lib/prompt-draft"; -import { queuedInputToDraft } from "@/views/thread-detail/threadQueuedMessages"; +import { promptDraftToInput, type PromptDraftState } from "@bb/client-core"; +import { queuedInputToDraft } from "@bb/client-core"; export default { title: "promptbox/Follow Up Prompt Box", @@ -73,14 +73,12 @@ const noop = () => {}; const STORY_BRANCH_NAME = "bb/design-system-polish"; // FollowUp commits the provider — omit `onChange` so the picker renders the -// provider segment as locked, and pass `displayName` so the static label -// shows even without a selectedId lookup. +// provider segment as locked. const baseExecution = makeExecutionControlsProps({ provider: { options: STORY_PROVIDER_OPTIONS, selectedId: "codex", hasMultiple: true, - displayName: "Codex", }, }); const claudePlanExecution = makeExecutionControlsProps({ @@ -88,7 +86,6 @@ const claudePlanExecution = makeExecutionControlsProps({ options: STORY_PROVIDER_OPTIONS, selectedId: "claude-code", hasMultiple: true, - displayName: "Claude Code", }, model: { active: { model: "claude-sonnet-5" }, @@ -560,7 +557,7 @@ interface RowConfig { contextWindowUsage?: ThreadContextWindowUsage | null; stack?: ReactNode | null; queuedMessages?: readonly ThreadQueuedMessage[]; - zenModeResetKey?: string; + collapseResetKey?: string; hideComposer?: boolean; /** Defaults to the editable execution controls; override to show the read-only model/provider config. */ execution?: ExecutionControlsProps; @@ -611,7 +608,7 @@ function Row({ contextWindowUsage = null, stack = null, queuedMessages: initialQueuedMessages, - zenModeResetKey = "thr_demo", + collapseResetKey = "thr_demo", hideComposer = false, execution = baseExecution, permission = basePermission, @@ -726,7 +723,7 @@ function Row({ permissionReadOnly promptActions={promptActions} typeahead={typeaheadBase} - zenModeResetKey={`${zenModeResetKey}:queued-message`} + collapseResetKey={`${collapseResetKey}:queued-message`} isPrimaryComposer={false} showScrollToBottomButton={false} /> @@ -744,7 +741,7 @@ function Row({ resolvedCompactPlaceholder, resolvedPlaceholder, threadRuntimeDisplayStatus, - zenModeResetKey, + collapseResetKey, ], ); const queueElement = @@ -819,7 +816,7 @@ function Row({ promptActions={promptActions} readOnly={readOnly} typeahead={typeaheadBase} - zenModeResetKey={zenModeResetKey} + collapseResetKey={collapseResetKey} /> ); diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx index 516c36f753..6bf26d9245 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx @@ -56,11 +56,12 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ footerStart, compact, onSubmit, + onEscape, blurOnPointerSubmit, promptBoxRef, submission, suppressPluginComposerCustomizations, - zenMode, + onCollapse, heightAnimationKey, minHeight, voice, @@ -71,6 +72,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ placeholder?: string; }; onSubmit: () => void; + onEscape?: () => void; blurOnPointerSubmit?: boolean; promptBoxRef?: { current: { @@ -80,7 +82,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ }; submission?: { onModifierSubmit?: () => void }; suppressPluginComposerCustomizations?: boolean; - zenMode?: { resetKey: string | number }; + onCollapse?: () => void; heightAnimationKey?: string | number; minHeight?: number; voice?: { state: "idle" | "recording" | "transcribing" | "error" }; @@ -88,7 +90,6 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
({ + {onCollapse ? ( + + ) : null} + {onEscape ? ( + + ) : null}
), })); @@ -209,7 +220,6 @@ function createFollowUpPromptBoxProps( execution: { provider: { selectedId: "codex", - displayName: "Codex", }, model: { selected: "gpt-5", @@ -249,7 +259,7 @@ function createFollowUpPromptBoxProps( onQueryChange: vi.fn(), }, }, - zenModeResetKey: "thr_test", + collapseResetKey: "thr_test", }; } @@ -653,6 +663,18 @@ describe("FollowUpPromptBox", () => { expect(mocks.scrollToBottom).toHaveBeenCalledOnce(); }); + it("forwards the composer's host Escape action", () => { + const props = createFollowUpPromptBoxProps({ kind: "ready" }); + const onEscape = vi.fn(); + if (!props.composer) throw new Error("Expected follow-up composer props"); + props.composer.onEscape = onEscape; + + render(); + fireEvent.click(screen.getByRole("button", { name: "Escape" })); + + expect(onEscape).toHaveBeenCalledOnce(); + }); + it.each([ { setting: false, @@ -754,6 +776,34 @@ describe("FollowUpPromptBox", () => { expect( screen.queryByRole("button", { name: /Make prompt box/u }), ).toBeNull(); + expect( + screen.queryByRole("button", { name: "Collapse prompt box" }), + ).toBeNull(); + }); + + it("collapses a wide composer until the user focuses it again", () => { + const props = createFollowUpPromptBoxProps({ kind: "ready" }); + props.environmentSummary = Local environment; + render(); + + expect(screen.getByText("Local environment")).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { name: "Collapse prompt box" }), + ); + + expect(screen.getByTestId("prompt-box").getAttribute("data-compact")).toBe( + "true", + ); + expect(screen.queryByText("Local environment")).toBeNull(); + + act(() => + screen.getByRole("textbox", { name: "Follow-up prompt" }).focus(), + ); + + expect(screen.getByTestId("prompt-box").getAttribute("data-compact")).toBe( + null, + ); + expect(screen.getByText("Local environment")).toBeTruthy(); }); it("collapses after a pointer submission when the keyboard viewport settles", async () => { @@ -1060,6 +1110,18 @@ describe("FollowUpPromptBox", () => { } }); + it("keeps the status footer out of text selection", () => { + const props = createFollowUpPromptBoxProps({ kind: "ready" }); + props.environmentSummary = Local environment; + render(); + + const footer = document.querySelector("[data-follow-up-composer-footer]"); + expect(footer?.classList.contains("select-none")).toBe(true); + expect(screen.getByText("Local environment").closest(".select-none")).toBe( + footer, + ); + }); + it("keeps the full composer visible on desktop", () => { const props = createFollowUpPromptBoxProps({ kind: "ready" }); props.environmentSummary = Local environment; @@ -1138,9 +1200,6 @@ describe("FollowUpPromptBox", () => { rerender(); expect(screen.getByTestId("prompt-box")).toBe(initialPromptBox); - expect(initialPromptBox.getAttribute("data-zen-reset-key")).toBe( - "thr_test:mobile", - ); }); it("uses the caller-specific compact placeholder", () => { diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 4bc0b4bcf0..80bb9b76cf 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -1,3 +1,4 @@ +import type { FollowUpSubmitMode } from "@bb/client-core"; import { memo, useCallback, @@ -18,6 +19,7 @@ import type { } from "@bb/domain"; import type { ComposerView, PluginComposerScope } from "@get-bb/plugin-sdk"; import type { ComposerTextEffectSource } from "@/lib/composer-text-effects"; +import { isKeyboardFocusTarget } from "@/components/layout/useMobileVisualViewportHeight"; import { ComposerBannersSlot } from "@/components/plugin/PluginComposerBanners"; import { PluginComposerHostProvider, @@ -52,11 +54,11 @@ import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; import { ThreadContextWindowIndicator } from "@/components/thread/timeline"; import { THREAD_PROMPT_CONTEXT_BANNER_ROW_HEIGHT } from "@/components/promptbox/banner/ThreadPromptContextBanner"; import { + isPlanModePrompt, permissionDisplayForActivePromptMode, permissionDisplayForPromptMode, shouldDisablePermissionPickerForActivePromptMode, - shouldDisablePermissionPickerForPromptMode, -} from "./effective-prompt-mode"; +} from "@bb/client-core"; type PromptBoxWithScrollAnchorProps = ComponentProps< typeof PromptBoxInternal @@ -126,39 +128,12 @@ const DEFAULT_FOLLOW_UP_COMPOSER_SCOPE = { projectId: null, } as const; -function isKeyboardFocusTarget(target: EventTarget | null): boolean { - return ( - target instanceof HTMLElement && - (target.isContentEditable || - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement) - ); -} -/** - * Discriminated state for the composer's submit affordances. Replaces the - * previous canSendFollowUp / canQueueFollowUp / canStopRuntime / onStop - * boolean soup. The caller computes one of these from runtimeDisplayStatus + - * pending-interaction state and passes it down; the composer reads .kind to - * render submit/queue/stop affordances. - */ -export type FollowUpBlockedReason = - | "loading-execution-options" - | "loading-pending-interactions" - | "pending-interaction" - | "provisioning" - | "stopping" - | "unavailable"; - -export type FollowUpSubmitMode = - /** Idle thread — submit creates a new turn; no stop affordance. */ - | { kind: "ready" } - /** Runtime is active or host-reconnecting — submit queues the message; stop the runtime. */ - | { kind: "queue"; onStop: () => void } - /** Runtime is pre-start or waiting on the host — can't send/queue, but can stop. */ - | { kind: "stop-only"; onStop: () => void } - /** Can't submit and can't stop — show why. */ - | { kind: "blocked"; reason: FollowUpBlockedReason }; +// The submit-mode discriminated union lives in @bb/client-core so the shared +// submission policy and the native composer read the same shape. +export type { + FollowUpBlockedReason, + FollowUpSubmitMode, +} from "@bb/client-core"; export interface FollowUpComposerProps { history: HistoryConfig; @@ -169,6 +144,12 @@ export interface FollowUpComposerProps { onChangeMessage: (value: string, mentionRanges: PromptTextMention[]) => void; onModifierSubmit: () => void; onSubmit: () => void; + /** + * Escape pressed in the editor with no higher-priority consumer open. + * The sent-message editor passes its cancel action; when omitted, Escape + * blurs the editor (the bottom and queued-message composers' behavior). + */ + onEscape?: () => void; /** Accessible label and tooltip for the primary submit action. */ submitTitle?: string; compactPromptPlaceholder: string; @@ -235,8 +216,8 @@ export interface FollowUpPromptBoxProps { /** Active scope used to filter and lifecycle-key plugin banner slots. */ pluginComposerScope?: PluginComposerScope | null; textEffects?: readonly ComposerTextEffectSource[]; - /** zenMode resetKey — typically the active thread id, so zen-mode collapses on thread change. */ - zenModeResetKey: string | number; + /** Scope key for resetting a manually collapsed prompt box on thread change. */ + collapseResetKey: string | number; /** * Changing this refocuses the composer caret to the end — e.g. after editing a * queued message restores its text into the draft. @@ -328,7 +309,7 @@ function FollowUpPromptBoxWithComposer({ pluginComposerHost, pluginComposerScope, textEffects, - zenModeResetKey, + collapseResetKey, focusEndKey, isPrimaryComposer = true, showScrollToBottomButton = true, @@ -396,19 +377,26 @@ function FollowUpPromptBoxWithComposer({ const pendingFocusExpansionCleanupRef = useRef<(() => void) | null>(null); const pendingFocusLossCleanupRef = useRef<(() => void) | null>(null); const [isInteractionExpanded, setIsInteractionExpanded] = useState(false); - const isMobilePromptBoxCompact = isCompactViewport && !isInteractionExpanded; + const [widePromptBoxCollapsedFor, setWidePromptBoxCollapsedFor] = useState< + string | number | null + >(null); + const isWidePromptBoxCollapsed = + widePromptBoxCollapsedFor === collapseResetKey; + const isPromptBoxCompact = + isWidePromptBoxCollapsed || (isCompactViewport && !isInteractionExpanded); const compactConfig = useMemo( () => - isCompactViewport + isCompactViewport || isWidePromptBoxCollapsed ? { - isCompact: isMobilePromptBoxCompact, + isCompact: isPromptBoxCompact, placeholder: composer.compactPromptPlaceholder, } : undefined, [ composer.compactPromptPlaceholder, isCompactViewport, - isMobilePromptBoxCompact, + isPromptBoxCompact, + isWidePromptBoxCollapsed, ], ); const setInteractionExpanded = useCallback((nextExpanded: boolean) => { @@ -429,6 +417,7 @@ function FollowUpPromptBoxWithComposer({ const handleComposerFocus = useCallback( (event: ReactFocusEvent) => { cancelPendingFocusLoss(); + setWidePromptBoxCollapsedFor(null); if (interactionExpandedRef.current) return; if ( !isCompactViewport || @@ -589,6 +578,13 @@ function FollowUpPromptBoxWithComposer({ setInteractionExpanded, ], ); + const collapseWidePromptBox = useCallback(() => { + cancelPendingFocusExpansion(); + cancelPendingFocusLoss(); + interactionExpandedRef.current = false; + setIsInteractionExpanded(false); + setWidePromptBoxCollapsedFor(collapseResetKey); + }, [cancelPendingFocusExpansion, cancelPendingFocusLoss, collapseResetKey]); useEffect( () => () => { cancelPendingFocusExpansion(); @@ -617,23 +613,28 @@ function FollowUpPromptBoxWithComposer({ ), [execution, executionControlsDisabled], ); + const selectedProviderPlanModeCopy = execution.provider.options?.find( + (option) => option.value === execution.provider.selectedId, + )?.planModeCopy; const promptModeInput = useMemo( () => ({ - providerId: execution.provider.selectedId, + planModeCopy: selectedProviderPlanModeCopy, value: composer.message, mentionRanges: composer.mentionRanges, }), - [composer.mentionRanges, composer.message, execution.provider.selectedId], + [composer.mentionRanges, composer.message, selectedProviderPlanModeCopy], ); const permissionDisplayOverride = useMemo( () => - permissionDisplayForActivePromptMode(activePromptMode) ?? - permissionDisplayForPromptMode(promptModeInput), - [activePromptMode, promptModeInput], + permissionDisplayForActivePromptMode( + activePromptMode, + selectedProviderPlanModeCopy, + ) ?? permissionDisplayForPromptMode(promptModeInput), + [activePromptMode, promptModeInput, selectedProviderPlanModeCopy], ); const permissionPickerDisabledByPlanMode = shouldDisablePermissionPickerForActivePromptMode(activePromptMode) || - shouldDisablePermissionPickerForPromptMode(promptModeInput); + isPlanModePrompt(promptModeInput); const permissionReadOnlyResolved = (permissionReadOnly ?? readOnly ?? false) || hasPendingInteraction; const permissionPickerDisabled = @@ -730,6 +731,7 @@ function FollowUpPromptBoxWithComposer({ mentionRanges={composer.mentionRanges} onChange={composer.onChangeMessage} onSubmit={onPrimarySubmit} + onEscape={composer.onEscape} blurOnPointerSubmit={isCompactViewport && isPointerCoarse} textEffects={textEffects} onComposerLayoutChange={setComposerLayout} @@ -779,20 +781,14 @@ function FollowUpPromptBoxWithComposer({ suppressPluginComposerCustomizations } compact={compactConfig} - zenMode={{ - layout: "thread", - storageKey: null, - resetKey: `${zenModeResetKey}:${ - isCompactViewport ? "mobile" : "desktop" - }`, - resetOnSubmit: true, - }} + editorLayout="thread" + onCollapse={isCompactViewport ? undefined : collapseWidePromptBox} footerStart={footerStart} /> - {!isMobilePromptBoxCompact ? ( + {!isPromptBoxCompact ? (
{environmentSummary} @@ -839,7 +835,7 @@ interface DefaultFollowUpComposerProps { } /** BB's presentation for a host-owned follow-up Composer controller. */ -export function DefaultFollowUpComposer({ +function DefaultFollowUpComposer({ active, composerElement, hasPluginComposerScope, diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index 18a30f9e98..58b6005934 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -19,6 +19,7 @@ import type { NewThreadRequest } from "@get-bb/plugin-sdk"; import type { CreateExecutionInputSources, SidebarBootstrapResponse, + SystemExecutionOptionsModelLoadError, } from "@bb/server-contract"; import type { ProjectSelectorCreateProjectConfig } from "@/components/pickers/ProjectSelector"; import { @@ -26,12 +27,13 @@ import { encodeReuseValue, parseEnvironmentValue, } from "@/components/pickers/environment-picker-value"; +import { formatModelLoadErrorText } from "@/components/pickers/model-load-error-message"; import { NewThreadPromptBox, type NewThreadPromptBoxProps, } from "@/components/promptbox/NewThreadPromptBox"; import { withAppPromptActions } from "@/components/promptbox/PromptBoxActionsMenu"; -import { buildProviderPromptActionProps } from "@/components/promptbox/mentions/command-trigger"; +import { buildProviderPromptActionProps } from "@bb/client-core"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import type { PromptBoxHandle } from "@/components/promptbox/PromptBoxInternal"; import { type PluginComposerHost } from "@/components/plugin/plugin-composer-host"; @@ -65,7 +67,7 @@ import { promptDraftToInput, type PromptDraftAttachment, type PromptDraftState, -} from "@/lib/prompt-draft"; +} from "@bb/client-core"; import { getProjectComposeRoutePath, getThreadRoutePath, @@ -89,7 +91,7 @@ import { type RootComposeSelectedBranch, } from "@/views/root-compose-thread-environment"; -export type NewThreadComposerSelectionScope = "new-thread" | "component-local"; +type NewThreadComposerSelectionScope = "new-thread" | "component-local"; export interface NewThreadComposerSeed { providerId?: string; @@ -101,21 +103,21 @@ export interface NewThreadComposerSeed { initialPrompt?: string; } -export interface NewThreadComposerLocks { +interface NewThreadComposerLocks { project?: boolean; provider?: boolean; environment?: boolean; branch?: boolean; } -export interface NewThreadComposerPromptOptions { +interface NewThreadComposerPromptOptions { id?: string; placeholder?: string; autoFocus?: boolean; - zenModeStorageKey: string; banner?: ReactNode; header?: ReactNode; - externallyBlocked?: boolean; + /** When present, submission is blocked and this reason is shown on the submit button. */ + blockedReason?: string; resolveMentionLink?: PromptMentionLinkResolver; /** Override the host bound to this prompt box; omission uses this Composer's host. */ pluginComposerHost?: PluginComposerHost; @@ -139,17 +141,10 @@ export interface NewThreadComposerState { projectSources: SidebarProject["sources"]; connectedHostIds: ReadonlySet; primaryHostId: string | null; - reuseThreadOptions: ReturnType; - effectiveEnvironmentValue: string; parsedEnvironment: ParsedEnvironment; projectHostId: string | null; panelThreadId: string | null; selectedProviderId: string; - selectedModel: string; - reasoningLevel: ReasoningLevel; - permissionMode: PermissionMode; - serviceTier: ServiceTier | undefined; - supportsServiceTier: boolean; promptDraft: PromptDraftController; promptBoxRef: React.RefObject; pluginComposerHost: PluginComposerHost; @@ -174,7 +169,7 @@ export interface NewThreadComposerProps { selectionScope: NewThreadComposerSelectionScope; seed?: NewThreadComposerSeed; resetKey?: string | number | null; - preferConnectedProviderWhenUnset?: boolean; + preferReadyProviderWhenUnset?: boolean; onSubmit: (request: NewThreadRequest) => void | Promise; focusRequest?: number; children: (state: NewThreadComposerState) => ReactNode; @@ -185,6 +180,74 @@ type ProjectDefaultsState = | { status: "error" } | { status: "resolved"; defaults: ProjectExecutionDefaults | null }; +export interface ResolveNewThreadSubmitDisabledReasonArgs { + branchMutationBlockerTitle: string | null; + isCopyingAttachments: boolean; + isLoadingModels: boolean; + isSubmitting: boolean; + isUploading: boolean; + managedWorktreeUnavailableReason: string | null; + modelLoadError: SystemExecutionOptionsModelLoadError | null; + projectDefaultsStatus: ProjectDefaultsState["status"]; + projectDefaultsUnavailable: boolean; + promptInputEmpty: boolean; + providerDisplayName: string; + selectedProviderId: string; + selectedThreadModel: string; + submissionEnvironmentUnavailable: boolean; +} + +export function resolveNewThreadSubmitDisabledReason({ + branchMutationBlockerTitle, + isCopyingAttachments, + isLoadingModels, + isSubmitting, + isUploading, + managedWorktreeUnavailableReason, + modelLoadError, + projectDefaultsStatus, + projectDefaultsUnavailable, + promptInputEmpty, + providerDisplayName, + selectedProviderId, + selectedThreadModel, + submissionEnvironmentUnavailable, +}: ResolveNewThreadSubmitDisabledReasonArgs): string | null { + if (isSubmitting) return "Starting thread..."; + if (isCopyingAttachments) { + return "Moving attachments to the selected project..."; + } + if (isUploading) return "Uploading attachments..."; + if (projectDefaultsUnavailable) { + return projectDefaultsStatus === "error" + ? "Could not load the project's execution defaults." + : "Loading the project's execution defaults..."; + } + if (!selectedProviderId) return "Select a provider."; + if (isLoadingModels) { + return "Loading models from the selected machine..."; + } + + const fatalModelLoadError = + modelLoadError?.code === "provider_unavailable" || + modelLoadError?.code === "missing_executable" || + modelLoadError?.code === "auth_required"; + if (modelLoadError && (fatalModelLoadError || !selectedThreadModel)) { + return formatModelLoadErrorText({ + error: modelLoadError, + providerLabel: providerDisplayName || selectedProviderId, + }); + } + if (!selectedThreadModel) return "Select a model."; + if (submissionEnvironmentUnavailable) return "Select an environment."; + if (managedWorktreeUnavailableReason) { + return managedWorktreeUnavailableReason; + } + if (branchMutationBlockerTitle) return branchMutationBlockerTitle; + if (promptInputEmpty) return "Enter a prompt or attach a file."; + return null; +} + export function resolveNewThreadProjectDefaultsState({ cachedDefaults, projectFound, @@ -308,7 +371,7 @@ export function NewThreadComposer({ selectionScope, seed, resetKey, - preferConnectedProviderWhenUnset = false, + preferReadyProviderWhenUnset = false, onSubmit, focusRequest, children, @@ -483,8 +546,8 @@ export function NewThreadComposer({ resetKey: `${projectId}\0${seedSignature}`, resolveProviderRouting, initialProviderId: seed?.providerId ?? projectDefaults?.providerId, - preferConnectedProviderWhenUnset: - preferConnectedProviderWhenUnset && projectDefaults === null, + preferReadyProviderWhenUnset: + preferReadyProviderWhenUnset && projectDefaults === null, initialModel: seed?.model ?? projectDefaults?.model, initialServiceTier: seed?.serviceTier ?? projectDefaults?.serviceTier, initialReasoningLevel: @@ -500,7 +563,6 @@ export function NewThreadComposer({ environmentSelectionValue, hasMultipleProviders, isLoadingModels, - isResolvingInitialProvider, modelLoadError, modelLoadFailed, modelOptions, @@ -512,6 +574,7 @@ export function NewThreadComposer({ reasoningOptions, selectedModel, selectedProviderComposerActions, + selectedProviderDisplayName, selectedProviderId, serviceTier, serviceTierSupportByProvider, @@ -631,10 +694,12 @@ export function NewThreadComposer({ const worktreeUnavailable = worktreeDisabledReason !== null; const requestsManagedWorktree = isHostMode && parsedEnvironment.mode === "worktree"; - const managedWorktreeAvailabilityPending = - requestsManagedWorktree && !isProjectless && branchesQuery.isLoading; const managedWorktreeUnavailable = requestsManagedWorktree && worktreeUnavailable; + // Branch data enriches the picker and can downgrade a confirmed non-Git or + // commitless source, but loading it is not a creation prerequisite. A + // default worktree request is resolved authoritatively by the server during + // thread creation, including another host.list_branches inspection. useEffect(() => { if ( !worktreeUnavailable || @@ -1003,39 +1068,40 @@ export function NewThreadComposer({ selectedEnvironment ?? (selectionScope === "new-thread" ? seed?.environment : undefined) ?? null; - const baseSubmitDisabled = - !selectedProviderId || - isLoadingModels || - isResolvingInitialProvider || - modelLoadError?.code === "provider_unavailable" || - modelLoadError?.code === "missing_executable" || - modelLoadError?.code === "auth_required" || - !selectedThreadModel || - isSubmitting || - isCopyingAttachments || - isUploading || - projectDefaultsUnavailable || - promptInput.length === 0 || - submissionEnvironment === null || - managedWorktreeAvailabilityPending || - managedWorktreeUnavailable || - (branchEnvironmentMode === "local" && - selectedBranch !== null && - branchUiState.mutationBlocker !== null); + const submitDisabledReason = resolveNewThreadSubmitDisabledReason({ + branchMutationBlockerTitle: + branchEnvironmentMode === "local" && selectedBranch !== null + ? (branchUiState.mutationBlocker?.title ?? null) + : null, + isCopyingAttachments, + isLoadingModels, + isSubmitting, + isUploading, + managedWorktreeUnavailableReason: managedWorktreeUnavailable + ? worktreeDisabledReason + : null, + modelLoadError, + projectDefaultsStatus: projectDefaultsState.status, + projectDefaultsUnavailable, + promptInputEmpty: promptInput.length === 0, + providerDisplayName: selectedProviderDisplayName, + selectedProviderId, + selectedThreadModel, + submissionEnvironmentUnavailable: submissionEnvironment === null, + }); const handleSubmit = useCallback( - async (externallyBlocked: boolean) => { + async (blockedReason: string | null) => { const submittedDraft = promptDraft.getCurrent(); const input = promptDraftToInput(submittedDraft); if ( - externallyBlocked || - baseSubmitDisabled || + blockedReason !== null || + submitDisabledReason !== null || input.length === 0 || isSubmittingRef.current || projectDefaultsUnavailable || submissionEnvironment === null || !selectedProviderId || !selectedThreadModel || - managedWorktreeAvailabilityPending || managedWorktreeUnavailable ) { return; @@ -1071,10 +1137,8 @@ export function NewThreadComposer({ } }, [ - baseSubmitDisabled, clearReuseEnvironment, executionInputSources, - managedWorktreeAvailabilityPending, managedWorktreeUnavailable, onSubmit, permissionMode, @@ -1083,6 +1147,7 @@ export function NewThreadComposer({ promptDraft, reasoningLevel, seededExecutionInputSources, + submitDisabledReason, submissionEnvironment, selectedProviderId, selectedThreadModel, @@ -1152,7 +1217,7 @@ export function NewThreadComposer({ const renderPromptBox = useCallback( (options: NewThreadComposerPromptOptions) => { const locks = options.locks ?? {}; - const externallyBlocked = options.externallyBlocked ?? false; + const disabledReason = options.blockedReason ?? submitDisabledReason; return ( void handleSubmit(externallyBlocked)} + onSubmit={() => void handleSubmit(options.blockedReason ?? null)} isSubmitting={isSubmitting} - disabled={baseSubmitDisabled || externallyBlocked} + disabled={disabledReason !== null} + disabledReason={disabledReason ?? undefined} placeholder={options.placeholder} autoFocus={options.autoFocus} pluginComposerHost={options.pluginComposerHost ?? pluginComposerHost} textEffects={options.textEffects ?? textEffects} - zenModeStorageKey={options.zenModeStorageKey} history={{ currentDraft, entries: promptHistoryDrafts, @@ -1319,7 +1384,6 @@ export function NewThreadComposer({ [ activeModel, attachmentError, - baseSubmitDisabled, branchEnvironmentMode, branchOptions, branchUiState, @@ -1379,6 +1443,7 @@ export function NewThreadComposer({ sidebarNavigationSettled, supportsPermissionModeSelection, supportsServiceTier, + submitDisabledReason, textEffects, worktreeDisabledReason, worktreeUnavailable, @@ -1395,17 +1460,10 @@ export function NewThreadComposer({ projectSources, connectedHostIds, primaryHostId, - reuseThreadOptions, - effectiveEnvironmentValue, parsedEnvironment, projectHostId, panelThreadId, selectedProviderId, - selectedModel, - reasoningLevel, - permissionMode, - serviceTier, - supportsServiceTier, promptDraft, promptBoxRef, pluginComposerHost, diff --git a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx index 9d057d51ff..bc758442a2 100644 --- a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx +++ b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx @@ -105,7 +105,6 @@ function EnvironmentOptionsStrip({ variant="option" muted value={null} - currentBranch="main" options={STORY_BRANCH_OPTIONS} currentOptionLabel="Current: main" currentOptionTitle="Use the current checkout without switching branches" @@ -183,7 +182,6 @@ export function Overview() { branch={{ currentOptionLabel: "Current (detached)", currentOptionTitle: "Detached HEAD at a1b2c3d", - currentBranch: null, triggerLabel: "Current (detached)", triggerTitle: "Detached HEAD at a1b2c3d", optionDisabledReason: "Detached", @@ -198,7 +196,6 @@ export function Overview() { branch={{ currentOptionLabel: "Current (empty repo)", currentOptionTitle: "Repository has no commits yet", - currentBranch: null, triggerLabel: "Current (empty repo)", triggerTitle: "Repository has no commits yet", optionDisabledReason: "Empty", diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.stories.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.stories.tsx index 463d4d1d1e..528763e9df 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.stories.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.stories.tsx @@ -17,7 +17,7 @@ import { AUTOMATION_PROMPT_ACTION, CREATE_PLUGIN_PROMPT_ACTION, } from "@/components/promptbox/PromptBoxActionsMenu"; -import { CodexCliVersionBanner } from "@/components/promptbox/banner/CodexCliVersionBanner"; +import { ProviderCliVersionBanner } from "@/components/promptbox/banner/ProviderCliVersionBanner"; import type { PickerOption } from "@/components/pickers/OptionPicker"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { ModelPickerStoryQueryProvider } from "../../../.ladle/model-picker-query-provider"; @@ -168,7 +168,6 @@ function DefaultRow() { onSubmit={noop} isSubmitting={false} disabled={false} - zenModeStorageKey="bb.story.new-thread.default" history={baseHistory} typeahead={makeTypeahead()} attachments={makeAttachments()} @@ -195,7 +194,6 @@ function SubmittingRow() { onSubmit={noop} isSubmitting disabled - zenModeStorageKey="bb.story.new-thread.submitting" history={baseHistory} typeahead={makeTypeahead()} attachments={makeAttachments()} @@ -221,7 +219,6 @@ function LoadingModelsRow() { onSubmit={noop} isSubmitting={false} disabled - zenModeStorageKey="bb.story.new-thread.loading-models" history={baseHistory} typeahead={makeTypeahead()} attachments={makeAttachments()} @@ -256,7 +253,6 @@ function ModelLoadFailedRow() { onSubmit={noop} isSubmitting={false} disabled - zenModeStorageKey="bb.story.new-thread.model-load-failed" history={baseHistory} typeahead={makeTypeahead()} attachments={makeAttachments()} @@ -294,7 +290,6 @@ function UnsupportedCodexCliRow() { isSubmitting={false} disabled autoFocus={false} - zenModeStorageKey="bb.story.new-thread.unsupported-codex-cli" history={baseHistory} typeahead={makeTypeahead()} attachments={makeAttachments()} @@ -302,7 +297,8 @@ function UnsupportedCodexCliRow() { modeConfig={{ ...baseModeConfig, banner: ( - ; isSubmitting: boolean; disabled: boolean; + /** Explains a disabled submit action on hover and to assistive technology. */ + disabledReason?: string; /** Whether the editor should take passive focus when it mounts. */ autoFocus?: boolean; /** Active root-composer binding for plugin composer hooks and customizations. */ pluginComposerHost?: PluginComposerHost | null; textEffects?: readonly ComposerTextEffectSource[]; - /** zenMode storage key used for the root-compose zen-mode atom. */ - zenModeStorageKey: string; /** Overrides the default new-thread placeholder copy. */ placeholder?: string; @@ -231,10 +230,10 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({ promptBoxRef: externalPromptBoxRef, isSubmitting, disabled, + disabledReason, autoFocus, pluginComposerHost, textEffects, - zenModeStorageKey, placeholder: placeholderOverride, history, typeahead, @@ -302,9 +301,9 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({ promptBoxRef={promptBoxRef} isSubmitting={isSubmitting} disabled={disabled} + disabledReason={disabledReason} autoFocus={autoFocus} textEffects={textEffects} - zenModeStorageKey={zenModeStorageKey} placeholder={placeholderOverride} history={history} typeahead={typeahead} @@ -331,7 +330,7 @@ interface DefaultNewThreadComposerProps extends Omit< } /** BB's presentation for a host-owned new-thread Composer controller. */ -export const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ +const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ id, value, mentionRanges, @@ -340,9 +339,9 @@ export const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ promptBoxRef, isSubmitting, disabled, + disabledReason, autoFocus, textEffects, - zenModeStorageKey, placeholder: placeholderOverride, history, typeahead, @@ -357,20 +356,22 @@ export const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ const isProjectlessPrompt = project?.value === null; const placeholder = placeholderOverride ?? getNewThreadPromptPlaceholder(isProjectlessPrompt); + const selectedProviderPlanModeCopy = execution.provider.options?.find( + (option) => option.value === execution.provider.selectedId, + )?.planModeCopy; const promptModeInput = useMemo( () => ({ - providerId: execution.provider.selectedId, + planModeCopy: selectedProviderPlanModeCopy, value, mentionRanges, }), - [execution.provider.selectedId, mentionRanges, value], + [selectedProviderPlanModeCopy, mentionRanges, value], ); const permissionDisplayOverride = useMemo( () => permissionDisplayForPromptMode(promptModeInput), [promptModeInput], ); - const permissionPickerDisabledByPlanMode = - shouldDisablePermissionPickerForPromptMode(promptModeInput); + const permissionPickerDisabledByPlanMode = isPlanModePrompt(promptModeInput); const submitTitle = isSubmitting ? "Submitting..." : execution.model.isLoading @@ -407,13 +408,11 @@ export const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ submission={{ isSubmitting, disabled, + disabledReason, title: submitTitle, }} autoFocus={autoFocus} - zenMode={{ - layout: "root-compose", - storageKey: zenModeStorageKey, - }} + editorLayout="root-compose" minHeight={NEW_THREAD_PROMPT_BOX_MIN_HEIGHT} placeholder={placeholder} header={modeConfig.header} @@ -424,7 +423,7 @@ export const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ reproduces the 4px gap main got from a `space-y-1` wrapper in RootComposeView (now gone since the standalone project row was removed). */} -
+
{project ? ( void; - sources: readonly ProjectSource[]; - /** Opens the guided machine-setup flow for a machine without a project - * source (multi-machine menu only). */ - onRequestMachineSetup?: (host: Host) => void; - /** When true, the "Reuse existing worktree" entry in the env picker is - * disabled — caller signals the project has no worktree envs available. */ - reuseDisabled?: boolean; - worktreeDisabledReason?: string | null; - disabled?: boolean; -} +type NewThreadConnectedEnvironmentConfig = Omit< + NewThreadEnvironmentConfig, + "host" | "isLocal" | "machines" +>; -export interface NewThreadConnectedBranchConfig { - value: string | null; - currentBranch?: string | null; - isNew: boolean; - hidden?: boolean; - options: readonly string[]; - remoteOptions?: readonly string[]; - loading?: boolean; - placeholder?: string; - triggerLabel?: string; - triggerTitle?: string; - currentOptionLabel?: string | null; - currentOptionTitle?: string; - optionDisabledReason?: string | null; - optionDisabledTitle?: string; - createDisabledReason?: string | null; - createDisabledTitle?: string; - onChange: (value: string) => void; - onClear?: () => void; - onOpenChange?: (open: boolean) => void; - onSearchQueryChange?: (query: string) => void; - onCreateBaseChange?: (value: string) => void; - disabled?: boolean; +type NewThreadConnectedBranchConfig = Omit< + NewThreadBranchConfig, + "onCreate" +> & { onCreate: () => void; -} +}; -export interface NewThreadConnectedModeConfig { +interface NewThreadConnectedModeConfig { environment: NewThreadConnectedEnvironmentConfig; branch: NewThreadConnectedBranchConfig; worktree: NewThreadWorktreeConfig; @@ -644,29 +613,14 @@ export interface NewThreadPromptBoxProps extends Omit< modeConfig: NewThreadConnectedModeConfig; } -type ConnectedThreadModeConfig = NewThreadConnectedModeConfig; - -type NewThreadPromptBoxRest = Omit; - /** * The composed prompt area for creating a new thread in a project — used by - * RootComposeView. It wires host queries through `ConnectedThreadModeBranch`. + * RootComposeView. It wires host queries into the UI mode config. */ export function NewThreadPromptBox({ - modeConfig, + modeConfig: threadConfig, ...rest }: NewThreadPromptBoxProps) { - return ; -} - -interface ConnectedThreadModeBranchProps extends NewThreadPromptBoxRest { - threadConfig: ConnectedThreadModeConfig; -} - -function ConnectedThreadModeBranch({ - threadConfig, - ...rest -}: ConnectedThreadModeBranchProps) { const { data: hosts } = useHosts(); const systemConfigQuery = useSystemConfig(); const primaryHostId = systemConfigQuery.data?.primaryHostId ?? null; @@ -709,29 +663,9 @@ function ConnectedThreadModeBranch({ const uiBranch = useMemo(() => { const branch = threadConfig.branch; return { - value: branch.value, - currentBranch: branch.currentBranch, + ...branch, isNew: allowCreate && branch.isNew, - hidden: branch.hidden, - options: branch.options, - remoteOptions: branch.remoteOptions, - loading: branch.loading, - placeholder: branch.placeholder, - triggerLabel: branch.triggerLabel, - triggerTitle: branch.triggerTitle, - currentOptionLabel: branch.currentOptionLabel, - currentOptionTitle: branch.currentOptionTitle, - optionDisabledReason: branch.optionDisabledReason, - optionDisabledTitle: branch.optionDisabledTitle, - createDisabledReason: branch.createDisabledReason, - createDisabledTitle: branch.createDisabledTitle, - onChange: branch.onChange, - onClear: branch.onClear, - onOpenChange: branch.onOpenChange, - onSearchQueryChange: branch.onSearchQueryChange, - onCreateBaseChange: branch.onCreateBaseChange, - disabled: branch.disabled, - ...(allowCreate ? { onCreate: branch.onCreate } : {}), + onCreate: allowCreate ? branch.onCreate : undefined, }; }, [allowCreate, threadConfig.branch]); diff --git a/apps/app/src/components/promptbox/PromptBoxActionsMenu.test.tsx b/apps/app/src/components/promptbox/PromptBoxActionsMenu.test.tsx index a1f2ae2e07..7f67b8384f 100644 --- a/apps/app/src/components/promptbox/PromptBoxActionsMenu.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxActionsMenu.test.tsx @@ -16,7 +16,7 @@ import { type PluginComposerHost, } from "@/components/plugin/plugin-composer-host"; import type { PluginComposerPlusMenuContribution } from "@/components/plugin/PluginComposerActions"; -import { emptyPromptDraftState } from "@/lib/prompt-draft"; +import { emptyPromptDraftState } from "@bb/client-core"; import { resetPluginLogoStoreForTest, setPluginLogoUrls, diff --git a/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx b/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx index 2399fdd34b..820b2e1e62 100644 --- a/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx +++ b/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx @@ -17,15 +17,10 @@ import { useResolvedComposerPlusMenuItems } from "@/components/plugin/composer-s import { useOptionalPluginComposerView } from "@/components/plugin/plugin-composer-host"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; -import { CREATE_PLUGIN_PROMPT } from "@/lib/create-resource-prompts"; -import type { ProviderPromptActionCommand } from "./mentions/command-trigger"; +import { CREATE_PLUGIN_PROMPT } from "@bb/client-core"; +import type { ProviderPromptActionCommand } from "@bb/client-core"; -export type PromptBoxActionKind = - | "skills" - | "plan" - | "goal" - | "automation" - | "plugin"; +type PromptBoxActionKind = "skills" | "plan" | "goal" | "automation" | "plugin"; export interface PromptBoxAction { kind: PromptBoxActionKind; @@ -35,7 +30,7 @@ export interface PromptBoxAction { disabled?: boolean; } -export interface PromptBoxActionsMenuProps { +interface PromptBoxActionsMenuProps { actions?: readonly PromptBoxAction[]; isAttaching?: boolean; onAttach?: () => void; diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx index 0c080072f7..b5c6c24f07 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx @@ -5,7 +5,7 @@ import { ExecutionControls } from "@/components/promptbox/ExecutionControls"; import type { PromptMentionSuggestion, ProviderCommandSuggestion, -} from "@/components/promptbox/mentions/types"; +} from "@bb/client-core"; import { PromptBoxInternal, type HistoryConfig, diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 5aaf90be23..f6e744b883 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import type { PromptTextMention } from "@bb/domain"; +import { TextSelection } from "@tiptap/pm/state"; import { EditorView } from "@tiptap/pm/view"; import { createRef, @@ -21,7 +22,7 @@ import { } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; -import { emptyPromptDraftState } from "@/lib/prompt-draft"; +import { emptyPromptDraftState } from "@bb/client-core"; import { getComposerInputLock, useComposer, @@ -67,7 +68,7 @@ import { import type { PromptMentionSuggestion, ProviderCommandSuggestion, -} from "./mentions/types"; +} from "@bb/client-core"; type PromptBoxProps = ComponentProps; @@ -501,8 +502,13 @@ function mockIPadOSWebKit(): () => void { }); } -afterEach(() => { +afterEach(async () => { cleanup(); + // TipTap's React hook defers editor destruction by 1 ms so a Strict Mode + // remount can reuse the instance. Let that teardown finish while this + // test's jsdom window is still alive instead of leaking it into the next + // test (or the environment shutdown after the final test). + await new Promise((resolve) => setTimeout(resolve, 2)); resetPluginLogoStoreForTest(); resetPluginSlotStoreForTest(); resetAllCrashedPluginSlotsForTest(); @@ -1190,6 +1196,31 @@ describe("PromptBoxInternal controlled value sync", () => { }); describe("PromptBoxInternal submit shortcuts", () => { + it("exposes the disabled submit reason as its label and hover tooltip", async () => { + const reason = "Loading models from the selected machine..."; + render( + , + ); + + const submit = screen.getByRole("button", { name: reason }); + expect(submit.hasAttribute("disabled")).toBe(true); + + const tooltipTrigger = submit.closest( + "[data-promptbox-submit-disabled-reason]", + ); + expect(tooltipTrigger).not.toBeNull(); + fireEvent.pointerMove(tooltipTrigger!, { pointerType: "mouse" }); + + await waitFor(() => { + expect(screen.getByRole("tooltip").textContent).toBe(reason); + }); + }); + it("continues to submit unmodified Enter on a fine-pointer device", () => { const restoreMatchMedia = mockPointerCoarse(false); try { @@ -1510,89 +1541,126 @@ describe("PromptBoxInternal submit shortcuts", () => { restoreMatchMedia(); } }); +}); - it("keeps hardware Enter as a newline in zen mode", async () => { - const restoreMatchMedia = mockPointerCoarse(true); - const restoreNavigator = mockIPadOSWebKit(); - const storageKey = "bb.test.promptbox.zen-submit-shortcut"; - window.localStorage.removeItem(storageKey); - try { - const onChange = vi.fn(); - const onSubmit = vi.fn(); - render( - , - ); - fireEvent.click( - screen.getByRole("button", { name: "Make prompt box larger" }), - ); +describe("PromptBoxInternal escape", () => { + it("blurs the editor when no host Escape action is provided", async () => { + const promptBoxRef = createRef(); + render( + , + ); + await focusPromptEnd(promptBoxRef); + const editor = getPromptEditorElement(); - fireEvent.keyDown(getPromptEditorElement(), { - key: "Enter", - code: "Enter", - }); + const wasNotCanceled = fireEvent.keyDown(editor, { key: "Escape" }); - expect(onSubmit).not.toHaveBeenCalled(); - await waitFor(() => - expect(onChange).toHaveBeenLastCalledWith("First line\n", []), - ); - } finally { - window.localStorage.removeItem(storageKey); - restoreNavigator(); - restoreMatchMedia(); - } + expect(wasNotCanceled).toBe(false); + expect(document.activeElement).not.toBe(editor); }); -}); -describe("PromptBoxInternal zen mode layout", () => { - it("animates the prompt box height when toggling zen mode", async () => { - const storageKey = "bb.test.promptbox.zen-height-animation"; - window.localStorage.removeItem(storageKey); + it("routes Escape to onEscape instead of blurring the editor", async () => { + const onEscape = vi.fn(); + const promptBoxRef = createRef(); + render( + , + ); + await focusPromptEnd(promptBoxRef); + + const wasNotCanceled = fireEvent.keyDown(getPromptEditorElement(), { + key: "Escape", + }); + + expect(onEscape).toHaveBeenCalledTimes(1); + expect(wasNotCanceled).toBe(false); + // The cancel action owns what happens next; the editor must not also blur. + expect(document.activeElement).toBe(getPromptEditorElement()); + }); + it("dismisses an open typeahead before Escape reaches onEscape", async () => { + const onEscape = vi.fn(); + const promptBoxRef = createRef(); render( , ); + await focusPromptEnd(promptBoxRef); + await screen.findByRole("button", { name: "review" }); - const form = document.querySelector("[data-promptbox]"); - if (!(form instanceof HTMLFormElement)) { - throw new Error("Prompt box form was not rendered"); - } + fireEvent.keyDown(getPromptEditorElement(), { key: "Escape" }); - vi.spyOn(form, "getBoundingClientRect") - .mockReturnValueOnce(new DOMRect(0, 0, 320, 96)) - .mockReturnValueOnce(new DOMRect(0, 0, 320, 512)) - .mockReturnValue(new DOMRect(0, 0, 320, 512)); + expect(onEscape).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.queryByRole("button", { name: "review" })).toBeNull(), + ); - expect( - screen.queryByRole("button", { name: "Make prompt box smaller" }), - ).toBeNull(); - fireEvent.click( - screen.getByRole("button", { name: "Make prompt box larger" }), + fireEvent.keyDown(getPromptEditorElement(), { key: "Escape" }); + expect(onEscape).toHaveBeenCalledTimes(1); + }); +}); + +describe("PromptBoxInternal size controls", () => { + it.each([ + ["thread", "calc(50dvh - 3rem)"], + ["root-compose", "calc(70dvh - 3rem)"], + ] as const)( + "caps the %s editor at its intended viewport height", + (layout, maxHeight) => { + render( + , + ); + + const editorScroll = document.querySelector( + "[data-promptbox-editor-scroll]", + ); + expect(editorScroll?.style.maxHeight).toBe(maxHeight); + }, + ); + + it("offers only the collapse action and releases editor focus", async () => { + const onCollapse = vi.fn(); + render( + , ); + await waitForPromptFocus(); - await waitFor(() => { - expect(form.style.transition).toContain("height 240ms"); - expect(form.style.height).toBe("512px"); - }); expect( - screen.getByRole("button", { name: "Make prompt box smaller" }), - ).toBeTruthy(); - expect( - screen.queryByRole("button", { name: "Make prompt box larger" }), + screen.queryByRole("button", { name: /Make prompt box/u }), ).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Collapse prompt box" }), + ); - fireEvent.transitionEnd(form, { propertyName: "height" }); - window.localStorage.removeItem(storageKey); + expect(onCollapse).toHaveBeenCalledOnce(); + expect(document.activeElement).not.toBe(getPromptEditorElement()); }); }); @@ -2070,6 +2138,9 @@ describe("PromptBoxInternal compact layout", () => { expect( screen.queryByRole("button", { name: /Make prompt box/u }), ).toBeNull(); + expect( + screen.queryByRole("button", { name: "Collapse prompt box" }), + ).toBeNull(); expect(getPromptEditorElement().getAttribute("data-placeholder")).toBe( "Ask a follow-up", ); @@ -2689,70 +2760,7 @@ describe("PromptBoxInternal compact layout", () => { } }); - it.each(["recording", "transcribing"] as const)( - "keeps zen sizing coherent while voice is %s", - async (state) => { - const storageKey = `bb.test.promptbox.voice-zen-${state}`; - window.localStorage.removeItem(storageKey); - const voice = { - state: "idle" as const, - isSupported: true, - stream: null, - start: vi.fn(), - stop: vi.fn(), - cancel: vi.fn(), - }; - const view = render( - , - ); - - fireEvent.click( - screen.getByRole("button", { name: "Make prompt box larger" }), - ); - await waitFor(() => - expect( - document - .querySelector("[data-promptbox]") - ?.hasAttribute("data-promptbox-zen"), - ).toBe(true), - ); - - view.rerender( - , - ); - - const form = document.querySelector("[data-promptbox]"); - const editorScroll = document.querySelector( - "[data-promptbox-editor-scroll]", - ); - const actionRow = document.querySelector("[data-promptbox-action-row]"); - const waveform = document.querySelector("canvas[aria-hidden]"); - expect(form?.hasAttribute("data-promptbox-zen")).toBe(true); - expect(form?.classList.contains("h-[50dvh]")).toBe(true); - expect(editorScroll?.style.height).toBe("100%"); - expect(editorScroll?.style.maxHeight).toBe("none"); - expect(getPromptEditorElement().textContent).toBe( - "Keep this zen prompt visible", - ); - expect(actionRow?.contains(waveform)).toBe(true); - - window.localStorage.removeItem(storageKey); - }, - ); - - it("does not expose zen controls in the full mobile layout", () => { + it("does not expose size controls in the full mobile layout", () => { render( { }); }); +describe("PromptBoxInternal selection reveal", () => { + async function nextAnimationFrame() { + await act( + () => + new Promise((resolve) => requestAnimationFrame(() => resolve())), + ); + } + + it("reveals the moving selection head, not the anchor, when a selection extends upward", async () => { + const lines = Array.from({ length: 40 }, (_, index) => `line ${index}`); + const { promptBoxRef } = renderPromptBox(lines.join("\n")); + + await focusPromptEnd(promptBoxRef); + await nextAnimationFrame(); + + const scrollContainer = document.querySelector( + "[data-promptbox-editor-scroll]", + ); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Prompt editor scroll container was not rendered"); + } + // jsdom does not lay out, so emulate a 100px viewport scrolled to the + // middle of the document. The selection anchor sits below the viewport + // (where the drag started) and the head sits above it (where the pointer + // is now). The browser's own drag autoscroll has already moved the + // viewport up toward the head. + let scrollTop = 500; + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + get: () => scrollTop, + set: (next: number) => { + scrollTop = next; + }, + }); + const scrollRectSpy = vi + .spyOn(scrollContainer, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, 0, 320, 100)); + let view: EditorView | null = null; + const coordsAtPosSpy = vi + .spyOn(EditorView.prototype, "coordsAtPos") + .mockImplementation(function (this: EditorView, pos: number) { + view = this; + const { selection } = this.state; + if (pos === selection.head && selection.head !== selection.anchor) { + return { left: 0, right: 0, top: -30, bottom: -14 }; + } + return { left: 0, right: 0, top: 160, bottom: 176 }; + }); + + try { + await waitFor(() => expect(view).not.toBeNull()); + const liveView = view as unknown as EditorView; + const { doc } = liveView.state; + // The focusEnd reveal above captured `view`; reset the baseline it set. + scrollTop = 500; + await act(async () => { + liveView.dispatch( + liveView.state.tr.setSelection( + TextSelection.create(doc, doc.content.size - 1, 1), + ), + ); + }); + await nextAnimationFrame(); + + // The reveal must follow the head upward (scrollTop decreases). Before + // the fix it revealed `selection.to` (the anchor) and yanked the + // viewport back down, fighting the drag autoscroll on every pointer move. + expect(scrollTop).toBeLessThan(500); + } finally { + coordsAtPosSpy.mockRestore(); + scrollRectSpy.mockRestore(); + } + }); +}); + describe("PromptBoxInternal prompt actions", () => { + it("keeps the action row out of text selection while the editor stays selectable", () => { + renderPromptBox(""); + + const actionRow = document.querySelector("[data-promptbox-action-row]"); + expect(actionRow?.classList.contains("select-none")).toBe(true); + expect(getPromptEditorElement().closest(".select-none")).toBeNull(); + }); + it("keeps the custom caret reveal for composer-handled text pastes", async () => { const { changes, promptBoxRef } = renderPromptBox(""); diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 19a897ee36..6f05f7de03 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1,5 +1,3 @@ -import { atom, useAtom } from "jotai"; -import { RESET, atomWithStorage } from "jotai/utils"; import type { PromptMentionCommandTrigger, PromptTextMention, @@ -10,6 +8,7 @@ import { TextSelection } from "@tiptap/pm/state"; import { useEditor, type Editor } from "@tiptap/react"; import { useCallback, + useContext, useEffect, useImperativeHandle, useLayoutEffect, @@ -24,6 +23,8 @@ import { type Ref, } from "react"; import { + commandPillDismissedRangeEnd, + findActiveTrigger, orderCommandSuggestions, type ActiveTrigger, type CommandMenuState, @@ -33,17 +34,21 @@ import { type PromptMentionSuggestion, type TypeaheadMenuState, type TypeaheadTrigger, -} from "@/components/promptbox/mentions/types"; +} from "@bb/client-core"; import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint"; import { useAppCommandKeyDispatch, useAppCommandShortcut, } from "@/components/commands/AppCommandProvider"; -import { commandPillDismissedRangeEnd } from "@/components/promptbox/mentions/command-trigger"; -import { findActiveTrigger } from "@/components/promptbox/mentions/find-active-trigger"; import { canLoadMoreCommandResults } from "@/components/promptbox/mentions/mention-menu-scroll"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@bb/shared-ui/tooltip"; import { ComposerActionsSlot } from "@/components/plugin/PluginComposerActions"; import { useResolvedComposerEditor } from "@/components/plugin/composer-slot-hooks"; import { @@ -64,18 +69,17 @@ import { REDUCED_MOTION_QUERY, } from "@bb/shared-ui/hooks/use-media-query"; import { blurActiveKeyboardInputWithin } from "@bb/shared-ui/overlay-trigger"; -import { createJsonLocalStorage } from "@/lib/browser-storage"; import { DEFAULT_PLUGIN_MENTION_TRIGGER, type PluginMentionTrigger, -} from "@/lib/plugin-mention-triggers"; +} from "@bb/client-core"; import { useRichTextEditingPreference } from "@/lib/rich-text-editing-preference"; import { arePromptDraftStatesEqual, isPromptDraftEmpty, type PromptDraftAttachment, type PromptDraftState, -} from "@/lib/prompt-draft"; +} from "@bb/client-core"; import { cn } from "@bb/shared-ui/lib/utils"; import { AttachmentPreview } from "./AttachmentPreview"; import { VoiceRecordingBar } from "./VoiceRecordingBar"; @@ -113,7 +117,7 @@ import { applyPromptParagraphNewline } from "./editor/prompt-editor-paragraph"; import { MentionMenu, type TypeaheadSuggestion } from "./mentions/MentionMenu"; import { parsePromptMentionClipboardElement } from "./mentions/prompt-mention-clipboard"; import { ComposerEditorSlot } from "./ComposerEditorSlot"; -import { useQueuedEditorTypeaheadLayoutReporter } from "./queued-editor-typeahead-layout"; +import { QueuedEditorTypeaheadLayoutContext } from "./queued-editor-typeahead-layout"; const PROMPTBOX_MIN_HEIGHT = 68; const PROMPTBOX_SELECTION_REVEAL_MARGIN = 12; @@ -176,17 +180,7 @@ function hasWhitespaceAfterPosition( return nextNode.type.name === "hardBreak"; } -type ZenModeLayout = "thread" | "root-compose"; - -const ZEN_MODE_STORAGE_KEY: Record = { - thread: "bb.promptbox.zen-mode.thread", - "root-compose": "bb.promptbox.zen-mode.root-compose", -}; - -const ZEN_MODE_HEIGHT_CLASS: Record = { - thread: "h-[50dvh]", - "root-compose": "h-[70dvh]", -}; +type PromptBoxEditorLayout = "thread" | "root-compose"; const COLLAPSING_GRID_CLASS = "grid transition-[grid-template-rows] duration-[180ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"; @@ -211,12 +205,74 @@ function shouldFinishVoiceCompletionTransitionImmediately(): boolean { export interface PromptBoxSubmissionConfig { isSubmitting?: boolean; disabled?: boolean; + /** Explains why submission is disabled. Shown on hover and used as the action's accessible label. */ + disabledReason?: string; title?: string; isRunning?: boolean; onStop?: () => void; onModifierSubmit?: () => void; } +interface PromptSubmitButtonProps { + canSubmit: boolean; + className: string; + disabledReason: string | undefined; + isCompact: boolean; + isSubmitting: boolean; + onClick: (event: ReactMouseEvent) => void; + onPointerDown: (event: ReactPointerEvent) => void; + title: string; +} + +function PromptSubmitButton({ + canSubmit, + className, + disabledReason, + isCompact, + isSubmitting, + onClick, + onPointerDown, + title, +}: PromptSubmitButtonProps) { + const button = ( + + ); + + if (!disabledReason) return button; + + return ( + + + + + {button} + + + {disabledReason} + + + ); +} + /** * The `@`-mention half of {@link TypeaheadConfig}. Unchanged from the prior * `MentionsConfig` surface other than living under `typeahead.mention`. @@ -304,14 +360,7 @@ export interface AttachmentsConfig { projectId?: string; } -export interface PromptBoxZenModeConfig { - layout?: ZenModeLayout; - storageKey?: string | null; - resetKey?: string | number; - resetOnSubmit?: boolean; -} - -export interface PromptBoxCompactConfig { +interface PromptBoxCompactConfig { isCompact: boolean; placeholder?: string; } @@ -323,7 +372,7 @@ export interface HistoryConfig { resetKey?: string | number; } -export type PromptVoiceState = "idle" | "recording" | "transcribing" | "error"; +type PromptVoiceState = "idle" | "recording" | "transcribing" | "error"; export interface PromptVoiceConfig { state: PromptVoiceState; @@ -349,14 +398,21 @@ export interface PromptBoxHandle { export type { PromptBoxAction } from "./PromptBoxActionsMenu"; -export type MentionMenuPlacement = "top" | "bottom"; +type MentionMenuPlacement = "top" | "bottom"; -export interface PromptBoxInternalProps { +interface PromptBoxInternalProps { id?: string; value: string; mentionRanges: readonly PromptTextMention[]; onChange: (value: string, mentionRanges: PromptTextMention[]) => void; onSubmit: () => void; + /** + * Replaces the default Escape behavior (blurring the editor). The + * sent-message editor passes its cancel action so Escape closes the editor. + * Higher-priority Escape consumers (typeahead dismissal, voice-recording + * cancel) still run first. + */ + onEscape?: () => void; /** Blur the editor after a pointer-activated primary submission. */ blurOnPointerSubmit?: boolean; placeholder?: string; @@ -395,7 +451,10 @@ export interface PromptBoxInternalProps { promptActions?: readonly PromptBoxAction[]; /** Suppress plugin composer regions without unmounting the editor. */ suppressPluginComposerCustomizations?: boolean; - zenMode?: PromptBoxZenModeConfig; + /** Selects the normal editor's viewport-relative height cap. */ + editorLayout?: PromptBoxEditorLayout; + /** Collapse a standard prompt box to its one-line presentation. */ + onCollapse?: () => void; /** Optional one-line presentation for unfocused mobile follow-up composers. */ compact?: PromptBoxCompactConfig; /** Compact placeholder used when a follow-up composer is narrowed by its container. */ @@ -424,7 +483,7 @@ interface DismissedTriggerRange { hasLeftRange: boolean; } -export interface PromptEditorValueKey { +interface PromptEditorValueKey { text: string; mentions: readonly PromptTextMention[]; } @@ -443,11 +502,6 @@ interface ParsedRichClipboardValue { value: PromptEditorValue; } -type ZenModeUpdate = - | boolean - | typeof RESET - | ((previous: boolean) => boolean | typeof RESET); - type PromptBoxMouseDownEvent = ReactMouseEvent; interface PromptActionInsertionRange { @@ -476,20 +530,6 @@ const PROMPTBOX_INTERACTIVE_TARGET_SELECTOR = [ "[role='option']", ].join(","); -function createTransientZenModeAtom() { - const baseAtom = atom(false); - return atom( - (get) => get(baseAtom), - (get, set, update: ZenModeUpdate) => { - const currentValue = get(baseAtom); - const nextValue = - typeof update === "function" ? update(currentValue) : update; - - set(baseAtom, nextValue === RESET ? false : nextValue); - }, - ); -} - /** * Structural equality between the last value synced into the editor and the * incoming controlled value. This used to be a JSON.stringify key compare, @@ -833,9 +873,13 @@ function revealPromptEditorSelection({ const scrollContainerRect = scrollContainer.getBoundingClientRect(); if (scrollContainerRect.height <= 0) return; + // Reveal the head, not `to`. While the user drags or Shift+Arrows a + // selection upward, the anchor stays below and `to` is the anchor. The + // browser autoscrolls toward the head; revealing `to` scrolled back toward + // the anchor on every selection update and the prompt jittered. let selectionRect: ReturnType; try { - selectionRect = editor.view.coordsAtPos(editor.state.selection.to); + selectionRect = editor.view.coordsAtPos(editor.state.selection.head); } catch { return; } @@ -1147,6 +1191,7 @@ export function PromptBoxInternal({ mentionRanges, onChange, onSubmit, + onEscape, blurOnPointerSubmit = false, placeholder = "Ask anything. @ to mention files, folders, or sections", autoFocus = true, @@ -1162,7 +1207,8 @@ export function PromptBoxInternal({ attachments: attachmentConfig = {}, promptActions, suppressPluginComposerCustomizations = false, - zenMode = {}, + editorLayout = "thread", + onCollapse, compact, containerCompactPlaceholder, heightAnimationKey, @@ -1175,6 +1221,7 @@ export function PromptBoxInternal({ const { isSubmitting = false, disabled: submitDisabled = false, + disabledReason: submitDisabledReason, title: submitTitle = "Submit (Enter)", isRunning = false, onStop, @@ -1208,12 +1255,6 @@ export function PromptBoxInternal({ onRemove: onRemoveAttachment, projectId: attachmentProjectId, } = attachmentConfig; - const { - layout: zenModeLayout = "thread", - storageKey: zenModeStorageKey, - resetKey: zenModeResetKey, - resetOnSubmit: resetZenModeOnSubmit = false, - } = zenMode; const isPointerCoarse = usePointerCoarse(); // Legacy iPads report an iPad platform; current iPadOS WebKit uses a // desktop-like MacIntel platform with touch points distinguishing it from @@ -1225,8 +1266,9 @@ export function PromptBoxInternal({ const shouldAvoidSoftKeyboardAutofocus = isPointerCoarse; const formRef = useRef(null); const typeaheadMenuRef = useRef(null); - const reportQueuedEditorTypeaheadLayout = - useQueuedEditorTypeaheadLayoutReporter(); + const reportQueuedEditorTypeaheadLayout = useContext( + QueuedEditorTypeaheadLayoutContext, + ); const blurAfterPointerSubmitRef = useRef(false); const heightAnimationFromRef = useRef(null); const capturePromptBoxHeight = useCallback(() => { @@ -1292,23 +1334,6 @@ export function PromptBoxInternal({ // Mark session transitions before dispatching state so overlapping React // priorities cannot enqueue the same multi-state reset more than once. const hasActiveHistorySessionRef = useRef(false); - const resolvedZenModeStorageKey = - zenModeStorageKey ?? ZEN_MODE_STORAGE_KEY[zenModeLayout]; - const zenModeAtom = useMemo( - () => - resolvedZenModeStorageKey - ? atomWithStorage( - resolvedZenModeStorageKey, - false, - createJsonLocalStorage(), - { - getOnInit: true, - }, - ) - : createTransientZenModeAtom(), - [resolvedZenModeStorageKey], - ); - const [isZenMode, setIsZenMode] = useAtom(zenModeAtom); const isVoiceRecording = voice?.state === "recording"; const isVoiceProcessing = voice?.state === "transcribing"; const showVoiceActionGroup = isVoiceRecording || isVoiceProcessing; @@ -1432,9 +1457,8 @@ export function PromptBoxInternal({ voiceCompletionPromiseRef.current = transition; return transition; }, []); - const showZenLayout = isZenMode; const showCompactLayout = - compact?.isCompact === true && !showVoiceActionGroup && !isZenMode; + compact?.isCompact === true && !showVoiceActionGroup; const effectivePlaceholder = showCompactLayout ? (compact.placeholder ?? placeholder) : placeholder; @@ -1442,11 +1466,7 @@ export function PromptBoxInternal({ const composerInputLocked = useComposerInputLock( pluginComposerHost?.textEffectKey ?? null, ); - const composerLayout = showCompactLayout - ? "compact" - : showZenLayout - ? "zen" - : "expanded"; + const composerLayout = showCompactLayout ? "compact" : "expanded"; const localComposerView = usePluginComposerViewModel({ scope: pluginComposerHost?.scope ?? { kind: "new-thread", @@ -1680,7 +1700,7 @@ export function PromptBoxInternal({ richTextMarkdown: richTextEditing, }), }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- value/mentionRanges are read once per editor instance on purpose (see above). + // oxlint-disable-next-line react/exhaustive-deps -- value/mentionRanges are read once per editor instance on purpose (see above). }, [richTextEditing]); const editor = useEditor( @@ -2032,18 +2052,9 @@ export function PromptBoxInternal({ scheduleRevealEditorSelection(); }, [editor, focusEndKey, isPointerCoarse, scheduleRevealEditorSelection]); - useEffect(() => { - if (zenModeResetKey === undefined) return; - if (resolvedZenModeStorageKey) { - setIsZenMode(RESET); - return; - } - setIsZenMode(false); - }, [resolvedZenModeStorageKey, setIsZenMode, zenModeResetKey]); - useLayoutEffect(() => { scheduleRevealEditorSelection(); - }, [isZenMode, minHeight, scheduleRevealEditorSelection]); + }, [minHeight, scheduleRevealEditorSelection]); const resetHistorySession = useCallback(() => { if (!hasActiveHistorySessionRef.current) return; @@ -2144,7 +2155,7 @@ export function PromptBoxInternal({ formElement.addEventListener("transitionend", handleTransitionEnd); return cleanup; - }, [heightAnimationKey, isZenMode, showCompactLayout, zenModeLayout]); + }, [heightAnimationKey, showCompactLayout]); const trimmedValue = value.trim(); const hasAttachments = attachments.length > 0; @@ -2679,9 +2690,8 @@ export function PromptBoxInternal({ setVoiceActionTransition("exiting"); voice?.cancel(); }, [voice]); - const effectiveSubmitTitle = isZenMode - ? submitTitle.replace(/^Submit\s+/, "") - : submitTitle; + const effectiveSubmitTitle = + !canSubmit && submitDisabledReason ? submitDisabledReason : submitTitle; const emitAttachmentFiles = useCallback( (files: File[]) => { @@ -2691,20 +2701,6 @@ export function PromptBoxInternal({ [onAttachFiles], ); - const resetZenModeAfterSubmit = useCallback(() => { - if (!resetZenModeOnSubmit || !isZenMode) return; - if (resolvedZenModeStorageKey) { - setIsZenMode(RESET); - return; - } - setIsZenMode(false); - }, [ - isZenMode, - resetZenModeOnSubmit, - resolvedZenModeStorageKey, - setIsZenMode, - ]); - const submitPrompt = useCallback(() => { const shouldBlurAfterSubmit = blurAfterPointerSubmitRef.current; blurAfterPointerSubmitRef.current = false; @@ -2713,8 +2709,7 @@ export function PromptBoxInternal({ if (shouldBlurAfterSubmit) { blurPromptEditor(editorRef.current); } - resetZenModeAfterSubmit(); - }, [canSubmit, onSubmit, resetZenModeAfterSubmit]); + }, [canSubmit, onSubmit]); const handleSubmitClick = useCallback( (event: ReactMouseEvent) => { @@ -2768,8 +2763,7 @@ export function PromptBoxInternal({ const submitModifierPrompt = useCallback(() => { if (!canModifierSubmit || !onModifierSubmit) return; onModifierSubmit(); - resetZenModeAfterSubmit(); - }, [canModifierSubmit, onModifierSubmit, resetZenModeAfterSubmit]); + }, [canModifierSubmit, onModifierSubmit]); const applyHistoryDraft = useCallback( (draft: PromptDraftState) => { @@ -2792,45 +2786,14 @@ export function PromptBoxInternal({ [history, scheduleRevealEditorSelection, syncTriggerState], ); - const focusEditorAfterSizeChange = useCallback(() => { - // Size changes on mobile web are presentation-only. Keeping focus where it - // is prevents the soft keyboard from covering the thread after a tap. - if (isPointerCoarse) return; - requestAnimationFrame(() => { - const currentEditor = editorRef.current; - if (!currentEditor || currentEditor.isDestroyed) return; - - currentEditor.commands.focus(); - scheduleRevealEditorSelection(); - }); - }, [isPointerCoarse, scheduleRevealEditorSelection]); - - const exitZenMode = useCallback(() => { + const collapsePromptBox = useCallback(() => { + if (!onCollapse) return; capturePromptBoxHeight(); - if (!isZenMode) return; - setIsZenMode(false); - focusEditorAfterSizeChange(); - }, [ - capturePromptBoxHeight, - focusEditorAfterSizeChange, - isZenMode, - setIsZenMode, - ]); - - const enterZenMode = useCallback(() => { - capturePromptBoxHeight(); - // Mobile follow-up composers expand by focus, not a manual size control. - if (compact) return; - if (isZenMode) return; - setIsZenMode(true); - focusEditorAfterSizeChange(); - }, [ - capturePromptBoxHeight, - focusEditorAfterSizeChange, - isZenMode, - compact, - setIsZenMode, - ]); + // The compact editor expands when it receives focus. Release the current + // editor focus before collapsing so the next click can expand it again. + blurPromptEditor(editorRef.current); + onCollapse(); + }, [capturePromptBoxHeight, onCollapse]); const handleAttachmentInputChange = useCallback( (event: ChangeEvent) => { @@ -2986,11 +2949,16 @@ export function PromptBoxInternal({ } // Escape releases the composer so the keyboard can reach the rest of the - // app. Higher-priority Escape behavior still runs first: the typeahead - // menu above dismisses itself, and voice recording cancels from a window + // app — or cancels the sent-message editor when `onEscape` is provided. + // Higher-priority Escape behavior still runs first: the typeahead menu + // above dismisses itself, and voice recording cancels from a window // capture listener that stops the event before the editor sees it. A // locked editor never reaches here — see the editor container below. if (event.key === "Escape") { + if (onEscape) { + onEscape(); + return true; + } blurPromptEditor(currentEditor); return true; } @@ -3082,7 +3050,7 @@ export function PromptBoxInternal({ !event.metaKey && !event.altKey && !event.ctrlKey && - (event.shiftKey || isZenMode || !canSubmitWithEnterKey); + (event.shiftKey || !canSubmitWithEnterKey); if (isPromptNewlineKey && currentEditor && exitHeading(currentEditor)) { event.preventDefault(); return true; @@ -3097,7 +3065,7 @@ export function PromptBoxInternal({ return true; } - if (isZenMode || !canSubmitWithEnterKey) return false; + if (!canSubmitWithEnterKey) return false; const isSubmitKey = event.key === "Enter" && !event.shiftKey; if (!isSubmitKey) return false; @@ -3119,9 +3087,9 @@ export function PromptBoxInternal({ dispatchAppCommandKey, history, isPointerCoarse, - isZenMode, loadMoreCommands, onCommandQueryChange, + onEscape, onMentionQueryChange, onModifierSubmit, postCompositionKeyDownEvents, @@ -3159,7 +3127,6 @@ export function PromptBoxInternal({ ref={formRef} data-promptbox="" data-promptbox-compact={showCompactLayout ? "" : undefined} - data-promptbox-zen={showZenLayout ? "" : undefined} data-promptbox-voice-active={showVoiceActionGroup ? "" : undefined} onSubmit={handleSubmit} onMouseDown={handlePromptBoxMouseDown} @@ -3177,11 +3144,6 @@ export function PromptBoxInternal({ className={cn( "group/promptbox relative w-full rounded-xl border border-border bg-background shadow-lift", showCompactLayout && "overflow-hidden", - // Zen toggles only the *height* of the box; the inset padding stays - // identical so the placeholder/text doesn't jump when toggling. - // `flex flex-col` lets the editor's `flex-1` fill the dvh height. - showZenLayout && "flex flex-col", - showZenLayout && ZEN_MODE_HEIGHT_CLASS[zenModeLayout], className, )} > @@ -3194,72 +3156,47 @@ export function PromptBoxInternal({ />
{header && !showCompactLayout ? ( - // Left padding matches the editor's so the header content aligns - // with the placeholder column in both normal and zen modes (editor - // shifts from px-4 to px-6 when entering zen). Right padding leaves - // room for the zen-mode toggle button in the top-right corner. Zen - // mode also gets more top room since the card fills the viewport. + // Left padding matches the editor's placeholder column. Right + // padding leaves room for prompt box controls in the top-right.
{header}
) : null} -
+
{!showCompactLayout ? ( <>
-
- {isZenMode ? ( - - ) : null} - {!isZenMode && !compact ? ( + {onCollapse ? ( +
- ) : null} -
+
+ ) : null} ) : null}
@@ -3298,20 +3233,15 @@ export function PromptBoxInternal({ ref={typeaheadMenuRef} data-promptbox-typeahead-menu="" className={cn( - // Zen mode: menu floats inside the form, anchored just above - // the action footer so it stays visible. The form's pb-3 + - // ~36px button row sets the bottom offset. - // Normal mode: menu floats outside the form (above or below). + // The menu floats outside the form (above or below). // -left-px / -right-px aligns the menu with the form's outer // edge (form has a 1px border; left-0/right-0 would otherwise // sit inside it, leaving the banner above peeking out 1px on // each side). "absolute -left-px -right-px z-20", - isZenMode - ? "bottom-14 px-3" - : mentionMenuPlacement === "top" - ? "bottom-full mb-2" - : "top-full mt-2", + mentionMenuPlacement === "top" + ? "bottom-full mb-2" + : "top-full mt-2", )} > @@ -3493,15 +3423,8 @@ export function PromptBoxInternal({ ) : ( - + disabledReason={ + !canSubmit ? submitDisabledReason : undefined + } + isCompact={showCompactLayout} + isSubmitting={isSubmitting} + onPointerDown={handleSubmitPointerDown} + onClick={handleSubmitClick} + title={effectiveSubmitTitle} + /> )}
diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx index e8c017ffbb..98f28956f0 100644 --- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx +++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx @@ -6,6 +6,22 @@ import { describe, expect, it, vi } from "vitest"; import { ThreadEnvironmentSummary } from "./ThreadEnvironmentSummary"; describe("ThreadEnvironmentSummary", () => { + it("uses a host-free environment label in compact prompt boxes", () => { + render( + , + ); + + expect( + document.querySelector('[data-promptbox-full-label=""]')?.textContent, + ).toBe("Mac Studio · New worktree"); + expect( + document.querySelector('[data-promptbox-compact-label=""]')?.textContent, + ).toBe("Worktree"); + }); + it("explains the create-thread action in a tooltip", async () => { render( diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx index 5539071f72..93a0aecce4 100644 --- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx +++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { OptionDisplay } from "@/components/pickers/OptionPicker"; +import { OptionDisplay } from "@bb/shared-ui/option-display"; import { copyToClipboardWithToast } from "@/lib/clipboard"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { @@ -13,12 +13,12 @@ const CHECKOUT_CHIP_BASE_CLASS_NAME = "flex min-w-0 flex-1 items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground"; const CHECKOUT_CHIP_BUTTON_CLASS_NAME = `${CHECKOUT_CHIP_BASE_CLASS_NAME} cursor-pointer transition-colors hover:bg-state-hover hover:text-foreground`; -export interface ThreadEnvironmentSummaryProps { +interface ThreadEnvironmentSummaryProps { /** Display name of the thread's project, shown alongside the environment. */ projectName?: string; - /** Full mode label used for the title (e.g. "Working locally" / "Worktree"). */ + /** Full mode label used on larger prompt boxes and in the title. */ environmentLabel?: string; - /** Visible label used in the promptbox footer. */ + /** Short label used when the promptbox switches to its compact layout. */ environmentCompactLabel?: string; /** Icon for the environment (e.g. monitor / git branch). */ environmentIcon?: IconName; @@ -36,7 +36,8 @@ export interface ThreadEnvironmentSummaryProps { * Read-only — environment editing happens elsewhere. * * Responsive behavior: - * - The visible environment label always uses the compact display string. + * - The full environment label is replaced by the compact display string in + * narrow promptbox shells. * - The summary can shrink inside the follow-up strip so permission/context * controls stay pinned and text truncates instead of wrapping. * - Branch chip hides only in very narrow promptbox shells and truncates @@ -55,8 +56,6 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({ } const checkoutCopyValue = environmentCheckout?.copyValue ?? null; - const visibleEnvironmentLabel = environmentCompactLabel ?? environmentLabel; - return (
{projectName ? ( @@ -72,8 +71,8 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({ ) : null} diff --git a/apps/app/src/components/promptbox/banner/AnimatedBody.tsx b/apps/app/src/components/promptbox/banner/AnimatedBody.tsx index 6b2b6ded12..8db465df6a 100644 --- a/apps/app/src/components/promptbox/banner/AnimatedBody.tsx +++ b/apps/app/src/components/promptbox/banner/AnimatedBody.tsx @@ -1,7 +1,7 @@ import { useState, type ReactNode } from "react"; import { cn } from "@bb/shared-ui/lib/utils"; -export interface AnimatedBodyProps { +interface AnimatedBodyProps { id: string; labelledBy: string; isExpanded: boolean; diff --git a/apps/app/src/components/promptbox/banner/CodexCliVersionBanner.test.tsx b/apps/app/src/components/promptbox/banner/CodexCliVersionBanner.test.tsx deleted file mode 100644 index 0def1e62a5..0000000000 --- a/apps/app/src/components/promptbox/banner/CodexCliVersionBanner.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { CodexCliVersionBanner } from "./CodexCliVersionBanner"; - -afterEach(() => { - cleanup(); -}); - -describe("CodexCliVersionBanner", () => { - it("presents the blocking update as an attention alert with a direct action", () => { - const onUpdate = vi.fn(); - render( - , - ); - - expect( - screen.getByRole("region", { name: "Codex update required" }), - ).toBeTruthy(); - expect(screen.getByRole("alert").textContent).toContain( - "Update Codex before starting a thread. Installed 0.135.0; version 0.136.0 or newer is required.", - ); - - fireEvent.click(screen.getByRole("button", { name: "Update Codex" })); - expect(onUpdate).toHaveBeenCalledOnce(); - }); - - it("shows update progress without repeating an ambiguous version fallback", () => { - render( - , - ); - - expect(screen.getByRole("alert").textContent).toContain( - "Installed 0.135.0; a newer version is required.", - ); - expect( - ( - screen.getByRole("button", { - name: "Updating…", - }) as HTMLButtonElement - ).disabled, - ).toBe(true); - }); -}); diff --git a/apps/app/src/components/promptbox/banner/CodexCliVersionBanner.tsx b/apps/app/src/components/promptbox/banner/CodexCliVersionBanner.tsx deleted file mode 100644 index 1b160df9cb..0000000000 --- a/apps/app/src/components/promptbox/banner/CodexCliVersionBanner.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; -import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard"; - -interface CodexCliVersionBannerProps { - currentVersion: string | null; - minimumSupportedVersion: string | null; - canUpdate: boolean; - updating: boolean; - onUpdate: () => void; -} - -function versionRequirementCopy( - currentVersion: string | null, - minimumSupportedVersion: string | null, -): string { - if (currentVersion !== null && minimumSupportedVersion !== null) { - return `Installed ${currentVersion}; version ${minimumSupportedVersion} or newer is required.`; - } - if (currentVersion !== null) { - return `Installed ${currentVersion}; a newer version is required.`; - } - if (minimumSupportedVersion !== null) { - return `Version ${minimumSupportedVersion} or newer is required.`; - } - return "A newer version is required."; -} - -/** - * Blocking update state for the new-thread composer. This is intentionally - * more prominent than passive prompt context: the composer cannot submit until - * the user resolves it. - */ -export function CodexCliVersionBanner({ - currentVersion, - minimumSupportedVersion, - canUpdate, - updating, - onUpdate, -}: CodexCliVersionBannerProps) { - return ( - -
- - - -
-

- Codex update required -

-

- Update Codex before starting a thread.{" "} - {versionRequirementCopy(currentVersion, minimumSupportedVersion)} -

-
- {canUpdate ? ( - - ) : null} -
-
- ); -} diff --git a/apps/app/src/components/promptbox/banner/PromptStackCard.tsx b/apps/app/src/components/promptbox/banner/PromptStackCard.tsx index 90c92f5667..5e8e2e1915 100644 --- a/apps/app/src/components/promptbox/banner/PromptStackCard.tsx +++ b/apps/app/src/components/promptbox/banner/PromptStackCard.tsx @@ -2,10 +2,10 @@ import { type CSSProperties, type ReactNode, type Ref } from "react"; import { cn } from "@bb/shared-ui/lib/utils"; export const PROMPT_STACK_CARD_ROW_HEIGHT = 32; -export const PROMPT_STACK_CARD_RADIUS_CLASS = "rounded-lg"; +const PROMPT_STACK_CARD_RADIUS_CLASS = "rounded-lg"; // Outer cards are rounded-lg (8px). A 4px inset means inner hover/focus // targets use rounded (4px) so the corner arcs stay visually aligned. -export const PROMPT_STACK_INLAY_RADIUS_CLASS = "rounded"; +const PROMPT_STACK_INLAY_RADIUS_CLASS = "rounded"; export const PROMPT_STACK_INLAY_INSET_CLASS = "p-1"; export const PROMPT_STACK_INLAY_SEGMENT_CLASS = cn( "min-h-6 px-2 py-1", @@ -26,11 +26,6 @@ export interface PromptStackCardProps { className?: string; rootRef?: Ref; style?: CSSProperties; - /** - * Makes the card keyboard-focusable — set to 0 when the card is itself a - * scroll region (e.g. a height-capped list) so keyboard users can scroll it. - */ - tabIndex?: number; } /** @@ -46,7 +41,6 @@ export function PromptStackCard({ className, rootRef, style, - tabIndex, }: PromptStackCardProps) { if (ariaLabel) { return ( @@ -55,7 +49,6 @@ export function PromptStackCard({ aria-label={ariaLabel} className={cn(BASE_CHROME, className)} style={style} - tabIndex={tabIndex} > {children} @@ -66,7 +59,6 @@ export function PromptStackCard({ ref={rootRef as Ref} className={cn(BASE_CHROME, className)} style={style} - tabIndex={tabIndex} > {children}
diff --git a/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.test.tsx b/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.test.tsx new file mode 100644 index 0000000000..30dfb7ae7d --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ProviderCliVersionBanner } from "./ProviderCliVersionBanner"; + +afterEach(() => { + cleanup(); +}); + +describe("ProviderCliVersionBanner", () => { + it("uses the selected provider's identity and update requirement", () => { + const onUpdate = vi.fn(); + render( + , + ); + + expect( + screen.getByRole("region", { name: "Example Agent update required" }), + ).toBeTruthy(); + expect(screen.getByRole("alert").textContent).toContain( + "Update Example Agent before starting a thread. Installed 0.135.0; version 0.136.0 or newer is required.", + ); + + fireEvent.click( + screen.getByRole("button", { name: "Update Example Agent" }), + ); + expect(onUpdate).toHaveBeenCalledOnce(); + }); + + it("shows update progress without repeating an ambiguous version fallback", () => { + render( + , + ); + + expect(screen.getByRole("alert").textContent).toContain( + "Installed 0.135.0; a newer version is required.", + ); + expect( + ( + screen.getByRole("button", { + name: "Updating…", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); +}); diff --git a/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.tsx b/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.tsx new file mode 100644 index 0000000000..b18f8c5d08 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.tsx @@ -0,0 +1,84 @@ +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard"; + +interface ProviderCliVersionBannerProps { + displayName: string; + currentVersion: string | null; + minimumSupportedVersion: string | null; + canUpdate: boolean; + updating: boolean; + onUpdate: () => void; +} + +function versionRequirementCopy( + currentVersion: string | null, + minimumSupportedVersion: string | null, +): string { + if (currentVersion !== null && minimumSupportedVersion !== null) { + return `Installed ${currentVersion}; version ${minimumSupportedVersion} or newer is required.`; + } + if (currentVersion !== null) { + return `Installed ${currentVersion}; a newer version is required.`; + } + if (minimumSupportedVersion !== null) { + return `Version ${minimumSupportedVersion} or newer is required.`; + } + return "A newer version is required."; +} + +/** Blocking update state for the selected provider in the new-thread composer. */ +export function ProviderCliVersionBanner({ + displayName, + currentVersion, + minimumSupportedVersion, + canUpdate, + updating, + onUpdate, +}: ProviderCliVersionBannerProps) { + return ( + +
+ + + +
+

+ {displayName} update required +

+

+ Update {displayName} before starting a thread.{" "} + {versionRequirementCopy(currentVersion, minimumSupportedVersion)} +

+
+ {canUpdate ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx index db742c0fb0..426dd11a4b 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx @@ -7,7 +7,7 @@ import { render, waitFor, } from "@testing-library/react"; -import { useLayoutEffect } from "react"; +import { useContext, useLayoutEffect } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadQueuedMessage } from "@bb/domain"; import type { Active, DroppableContainer } from "@dnd-kit/core"; @@ -21,7 +21,7 @@ import { snapGroupBoundaryDragTransform, } from "./QueuedMessagesList"; import { - useQueuedEditorTypeaheadLayoutReporter, + QueuedEditorTypeaheadLayoutContext, type QueuedEditorTypeaheadLayout, } from "@/components/promptbox/queued-editor-typeahead-layout"; @@ -45,7 +45,7 @@ function TypeaheadLayoutFixture({ }: { layout: QueuedEditorTypeaheadLayout; }) { - const reportLayout = useQueuedEditorTypeaheadLayoutReporter(); + const reportLayout = useContext(QueuedEditorTypeaheadLayoutContext); useLayoutEffect(() => { reportLayout?.(layout); }, [layout, reportLayout]); diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index 0c2f06c6df..c4d7d1b868 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx @@ -66,8 +66,12 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { countQueuedMessageAttachments, formatQueuedMessagePreview, -} from "@/views/thread-detail/threadQueuedMessages"; -import type { QueuedMessageReorderRequest } from "@/lib/queued-message-reorder"; +} from "@bb/client-core"; +import { + collectLeadQueuedMessageGroupIds, + preserveLeadQueuedMessageGroupAfterReorder, + type QueuedMessageReorderRequest, +} from "@/lib/queued-message-reorder"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { shiftMentionsToTextRange } from "@/components/thread/timeline/ConversationMessageMentions"; import { @@ -282,43 +286,6 @@ function CompactQueuedMarkdownPreview({ ); } -function collectLeadQueuedMessageGroupIds( - queuedMessages: readonly ThreadQueuedMessage[], -): string[] { - const ids: string[] = []; - for (const queuedMessage of queuedMessages) { - ids.push(queuedMessage.id); - if (!queuedMessage.groupWithNext) break; - } - return ids; -} - -function preserveLeadQueuedMessageGroupAfterReorder({ - originalLeadGroupIds, - queuedMessages, -}: { - originalLeadGroupIds: readonly string[]; - queuedMessages: readonly ThreadQueuedMessage[]; -}): ThreadQueuedMessage[] { - if (originalLeadGroupIds.length <= 1) { - return queuedMessages.map((queuedMessage) => ({ - ...queuedMessage, - groupWithNext: false, - })); - } - - const originalLeadGroupIdSet = new Set(originalLeadGroupIds); - const preservesLeadGroup = queuedMessages - .slice(0, originalLeadGroupIds.length) - .every((queuedMessage) => originalLeadGroupIdSet.has(queuedMessage.id)); - - return queuedMessages.map((queuedMessage, index) => ({ - ...queuedMessage, - groupWithNext: - preservesLeadGroup && index < originalLeadGroupIds.length - 1, - })); -} - export function resolveQueuedMessageDrag({ activeId, combinedIds, diff --git a/apps/app/src/components/promptbox/banner/ThreadBackgroundCommandsCard.tsx b/apps/app/src/components/promptbox/banner/ThreadBackgroundCommandsCard.tsx index bebfa7e3c8..f41d8b4110 100644 --- a/apps/app/src/components/promptbox/banner/ThreadBackgroundCommandsCard.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadBackgroundCommandsCard.tsx @@ -4,7 +4,10 @@ import type { TimelineWorkflowWorkRow } from "@bb/server-contract"; import { durationToCompactString } from "@bb/thread-view"; import { useResizeObserver } from "usehooks-ts"; import { AnimatedBody } from "@/components/promptbox/banner/AnimatedBody"; -import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard"; +import { + PROMPT_STACK_CARD_ROW_HEIGHT, + PromptStackCard, +} from "@/components/promptbox/banner/PromptStackCard"; import { useSecondTick } from "@/hooks/useSecondTick"; import { Icon } from "@bb/shared-ui/icon"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; @@ -13,10 +16,9 @@ import { activityMetaClass, activityRowClass, activityTextClass, -} from "@/components/ui/activity-row-styles"; +} from "@bb/shared-ui/activity-row-styles"; import { cn } from "@bb/shared-ui/lib/utils"; -const CARD_ROW_HEIGHT = 32; const BODY_ID = "thread-background-commands-card-body"; const TOGGLE_ID = "thread-background-commands-card-toggle"; // Keep this threshold aligned with the promptbox-shell container query in @@ -185,7 +187,7 @@ function BackgroundActivitySummary({ ); } -export interface ThreadBackgroundCommandsCardProps { +interface ThreadBackgroundCommandsCardProps { commands: TimelineWorkflowWorkRow[]; isExpanded: boolean; onToggle: () => void; @@ -237,7 +239,7 @@ export function ThreadBackgroundCommandsCard({ rootRef={cardRef} ariaLabel={groupLabel} className="overflow-hidden" - style={{ minHeight: CARD_ROW_HEIGHT }} + style={{ minHeight: PROMPT_STACK_CARD_ROW_HEIGHT }} >
{canExpand ? ( diff --git a/apps/app/src/components/promptbox/banner/ThreadGoalCard.tsx b/apps/app/src/components/promptbox/banner/ThreadGoalCard.tsx index 57db11afdd..92493678e1 100644 --- a/apps/app/src/components/promptbox/banner/ThreadGoalCard.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadGoalCard.tsx @@ -1,14 +1,16 @@ import type { ThreadTimelineGoal } from "@bb/domain"; -import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard"; +import { + PROMPT_STACK_CARD_ROW_HEIGHT, + PromptStackCard, +} from "@/components/promptbox/banner/PromptStackCard"; import { activityIconClass, activityRowClass, activityTextClass, -} from "@/components/ui/activity-row-styles"; +} from "@bb/shared-ui/activity-row-styles"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; -const GOAL_CARD_ROW_HEIGHT = 32; const GOAL_HEADER_GROUP_CLASS = activityRowClass( "active", "flex w-full items-stretch rounded-none px-0 py-0", @@ -40,7 +42,7 @@ function formatTokenUsage(goal: ThreadTimelineGoal): string { return `${used} / ${goal.tokenBudget.toLocaleString()} tokens`; } -export interface ThreadGoalCardProps { +interface ThreadGoalCardProps { goal: ThreadTimelineGoal | null; isClearPending?: boolean; isExpanded: boolean; @@ -74,7 +76,7 @@ export function ThreadGoalCard({
+ + + { expect(markup).toContain("Committed"); expect(markup).toContain("1 file"); }); + + it.each([ + { + label: "checked open", + pullRequest: pullRequestFixture, + expectedMinWidthClass: "min-w-13", + }, + { + label: "merged", + pullRequest: { + ...pullRequestFixture, + state: "merged" as const, + attention: "merged" as const, + }, + expectedMinWidthClass: "min-w-8", + }, + { + label: "closed", + pullRequest: { + ...pullRequestFixture, + state: "closed" as const, + attention: "closed" as const, + }, + expectedMinWidthClass: "min-w-8", + }, + ])( + "reserves only the width needed by a $label pull request status pill", + ({ pullRequest, expectedMinWidthClass }) => { + render( + + + , + ); + + const pullRequestLink = screen.getByRole("link", { + name: /Pull request 128:/, + }); + expect( + ["min-w-8", "min-w-13"].filter((className) => + pullRequestLink.classList.contains(className), + ), + ).toEqual([expectedMinWidthClass]); + }, + ); }); describe("ThreadPromptContextBanner git section body", () => { diff --git a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx index b9a459f1a7..f7fd3b417d 100644 --- a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx @@ -20,7 +20,7 @@ import { import { activityIconClass, activityRowClass, -} from "@/components/ui/activity-row-styles"; +} from "@bb/shared-ui/activity-row-styles"; import { WorkspaceChangesList } from "@/components/thread/WorkspaceChangesList"; import { formatChangeSummary, @@ -33,6 +33,7 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { getPullRequestAttentionDisplay, + getPullRequestGithubCheckStatus, PULL_REQUEST_STATE_DISPLAY, } from "@/lib/pull-request-display"; import { PullRequestStatusPill } from "@/components/pull-request/PullRequestStatusPill"; @@ -79,7 +80,7 @@ export interface ThreadPromptParentThreadSection { * caller is responsible for filtering down to active children — the banner * just renders what it's given. */ -export interface ThreadPromptChildThreadItem { +interface ThreadPromptChildThreadItem { id: string; title: string; href: string; @@ -159,7 +160,7 @@ export type ThreadPromptContextBannerExpandedSection = export const THREAD_PROMPT_CONTEXT_BANNER_ROW_HEIGHT = PROMPT_STACK_CARD_ROW_HEIGHT; -export interface ThreadPromptContextBannerProps { +interface ThreadPromptContextBannerProps { gitSection: ThreadPromptGitSection | null; /** * True while the workspace status query for this thread is in flight. Holds @@ -634,9 +635,12 @@ function PullRequestBannerLink({ className={cn( "flex items-center gap-1.5 text-xs text-muted-foreground no-underline transition-colors hover:bg-state-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", PROMPT_STACK_INLAY_SEGMENT_CLASS, - // Preserve the checked status pill (min-w-9) plus the inlay's px-2. - // Labels may still truncate, but the two status glyphs must not clip. - "min-w-13 overflow-hidden", + // Preserve the status pill plus the inlay's px-2. Open/draft PRs with + // checks need two glyphs; terminal/no-check PRs need only one. + getPullRequestGithubCheckStatus(pullRequest) !== null + ? "min-w-13" + : "min-w-8", + "overflow-hidden", )} > diff --git a/apps/app/src/components/promptbox/banner/ThreadPromptModeCard.tsx b/apps/app/src/components/promptbox/banner/ThreadPromptModeCard.tsx index 98ee8d6cf5..a2f9753ef6 100644 --- a/apps/app/src/components/promptbox/banner/ThreadPromptModeCard.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadPromptModeCard.tsx @@ -1,14 +1,16 @@ import type { ThreadTimelineActivePromptMode } from "@bb/domain"; -import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard"; +import { + PROMPT_STACK_CARD_ROW_HEIGHT, + PromptStackCard, +} from "@/components/promptbox/banner/PromptStackCard"; import { activityIconClass, activityRowClass, activityTextClass, -} from "@/components/ui/activity-row-styles"; +} from "@bb/shared-ui/activity-row-styles"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; -const PROMPT_MODE_CARD_ROW_HEIGHT = 32; const PROMPT_MODE_HEADER_GROUP_CLASS = activityRowClass( "active", "flex w-full items-stretch rounded-none px-0 py-0", @@ -18,7 +20,7 @@ const PROMPT_MODE_HEADER_BUTTON_CLASS = const PROMPT_MODE_EXIT_BUTTON_CLASS = "flex min-h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-none border-l border-border/35 bg-transparent text-muted-foreground transition-colors hover:text-foreground disabled:cursor-wait disabled:text-muted-foreground/60"; -export interface ThreadPromptModeCardProps { +interface ThreadPromptModeCardProps { activePromptMode: ThreadTimelineActivePromptMode | null; isExitPending?: boolean; isExpanded: boolean; @@ -46,7 +48,7 @@ export function ThreadPromptModeCard({
= { in_progress: 0, pending: 1, @@ -31,7 +32,7 @@ const STATUS_ACTIVITY_STATE: Record< completed: "completed", }; -export interface ThreadTodoCardProps { +interface ThreadTodoCardProps { pendingTodos: ThreadTimelinePendingTodos | null; isExpanded: boolean; onToggle: () => void; @@ -152,7 +153,7 @@ export function ThreadTodoCard({
+ + + Open in external browser + + + )} {onOpenInEditor ? ( <> @@ -1008,10 +945,7 @@ function MarkdownFilePreview({ // Keep rendered Markdown on the ordinary document background. Its parent // owns the boundary, so another raised "paper" layer would make nested // file viewers feel like cards stacked inside cards. - +
(null); + const rowVirtualizer = useVirtualizer({ + count: bodyRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => CSV_PREVIEW_ROW_HEIGHT_PX, + overscan: CSV_PREVIEW_OVERSCAN_ROWS, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalRowsHeight = rowVirtualizer.getTotalSize(); + const firstVirtualRow = virtualRows[0]; + const lastVirtualRow = virtualRows[virtualRows.length - 1]; + const spacerTopHeight = firstVirtualRow?.start ?? 0; + const spacerBottomHeight = + lastVirtualRow === undefined + ? totalRowsHeight + : totalRowsHeight - lastVirtualRow.end; + return ( - + {/* Single scroll container for both axes: the sticky header row and row-number gutter stick against this box, the horizontal scrollbar stays visible at the panel bottom, and the sticky cells are clipped @@ -1051,7 +1004,10 @@ function CsvFilePreview({ file, onSelectionAddToChat }: CsvFilePreviewProps) { {/* overscroll-contain: panning a wide table past its edge must not chain into the browser back/forward gesture (kept alive globally — see app.css overscroll notes) or scroll an ancestor. */} -
+
- {bodyRows.map((row, rowIndex) => ( - - + + ) : null} + {virtualRows.map((virtualRow) => { + const rowIndex = virtualRow.index; + const row = bodyRows[rowIndex] ?? []; + return ( + - {rowIndex + 2} - - {columns.map((column) => { - const cell = row[column.index] ?? ""; - return ( - - ); - })} + + {columns.map((column) => { + const cell = row[column.index] ?? ""; + return ( + + ); + })} + + ); + })} + {spacerBottomHeight > 0 ? ( + + - ))} + ) : null}
0 ? ( +
+
- - {cell} - - + {rowIndex + 2} + + + {cell} + +
@@ -1203,216 +1177,6 @@ function IframeFilePreview({ sandbox, title, url }: IframeFilePreviewTarget) { ); } -function getPreviewTargetRoots(container: HTMLElement): ParentNode[] { - const roots: ParentNode[] = [container]; - // Pierre owns its rendered line elements inside an open shadow root, which - // normal descendant queries on the React wrapper cannot cross. - for (const pierreContainer of container.querySelectorAll( - DIFFS_TAG_NAME, - )) { - if (pierreContainer.shadowRoot !== null) { - roots.push(pierreContainer.shadowRoot); - } - } - return roots; -} - -function clearPreviewTargetLine(container: HTMLElement) { - for (const root of getPreviewTargetRoots(container)) { - const targetLines = root.querySelectorAll( - "[data-file-preview-target-line]", - ); - for (const targetLine of targetLines) { - targetLine.removeAttribute("data-file-preview-target-line"); - targetLine.removeAttribute("data-selected-line"); - } - } -} - -function findPreviewTargetLine( - container: HTMLElement, - lineNumber: number, -): HTMLElement | null { - const roots = getPreviewTargetRoots(container); - for (const root of roots) { - const lines = root.querySelectorAll(`[data-line="${lineNumber}"]`); - for (const line of lines) { - if (line instanceof HTMLElement && line.dataset.lineIndex !== undefined) { - return line; - } - } - } - for (const root of roots) { - const lines = root.querySelectorAll(`[data-line="${lineNumber}"]`); - for (const line of lines) { - if (line instanceof HTMLElement) { - return line; - } - } - } - return null; -} - -function findVirtualizedCodeViewport( - container: HTMLElement, -): HTMLElement | null { - return container.querySelector( - "[data-file-preview-code-viewport]", - ); -} - -/** - * Nudge the virtualized code viewport toward `lineNumber` when that row is not - * realized yet. With rendered rows in hand the distance is measured from the - * nearest one (rows are at least one line tall, so the step never overshoots - * in `wrap` mode); with none rendered the offset is estimated from the fixed - * line metrics. Each call moves at most to the estimate; the caller retries on - * the next frame once pierre has rendered the new window. - */ -function approachVirtualizedTargetLine( - container: HTMLElement, - lineNumber: number, -) { - const viewport = findVirtualizedCodeViewport(container); - if (viewport === null) return; - const viewportRect = viewport.getBoundingClientRect(); - const centerOffset = viewportRect.height / 2; - const renderedBounds = getRenderedPreviewLineBounds(container); - if (renderedBounds === null) { - const estimatedTop = - FILE_PREVIEW_CODE_GAP_BLOCK_PX + - (lineNumber - 1) * FILE_PREVIEW_CODE_LINE_HEIGHT_PX; - viewport.scrollTop = Math.max(0, estimatedTop - centerOffset); - return; - } - const { firstLineNumber, firstTop, lastLineNumber, lastBottom } = - renderedBounds; - if (lineNumber > lastLineNumber) { - const distance = - lastBottom - - viewportRect.top + - (lineNumber - lastLineNumber - 1) * FILE_PREVIEW_CODE_LINE_HEIGHT_PX; - viewport.scrollTop += Math.max(0, distance - centerOffset); - } else if (lineNumber < firstLineNumber) { - const distance = - viewportRect.top - - firstTop + - (firstLineNumber - lineNumber) * FILE_PREVIEW_CODE_LINE_HEIGHT_PX; - viewport.scrollTop = Math.max( - 0, - viewport.scrollTop - distance - centerOffset, - ); - } -} - -interface RenderedPreviewLineBounds { - firstLineNumber: number; - firstTop: number; - lastLineNumber: number; - lastBottom: number; -} - -function getRenderedPreviewLineBounds( - container: HTMLElement, -): RenderedPreviewLineBounds | null { - let bounds: RenderedPreviewLineBounds | null = null; - for (const root of getPreviewTargetRoots(container)) { - for (const line of root.querySelectorAll( - "[data-line][data-line-index]", - )) { - const lineNumber = Number(line.dataset.line); - if (!Number.isFinite(lineNumber)) continue; - const rect = line.getBoundingClientRect(); - if (bounds === null) { - bounds = { - firstLineNumber: lineNumber, - firstTop: rect.top, - lastLineNumber: lineNumber, - lastBottom: rect.bottom, - }; - continue; - } - if (lineNumber < bounds.firstLineNumber) { - bounds.firstLineNumber = lineNumber; - bounds.firstTop = rect.top; - } - if (lineNumber > bounds.lastLineNumber) { - bounds.lastLineNumber = lineNumber; - bounds.lastBottom = rect.bottom; - } - } - } - return bounds; -} - -function findPreviewScrollViewport(container: HTMLElement): HTMLElement | null { - const virtualizedViewport = findVirtualizedCodeViewport(container); - if (virtualizedViewport !== null) { - return virtualizedViewport; - } - const view = container.ownerDocument.defaultView; - if (view === null) return null; - - let candidate = container.parentElement; - while (candidate !== null) { - const overflowY = view.getComputedStyle(candidate).overflowY; - if ( - overflowY === "auto" || - overflowY === "scroll" || - overflowY === "overlay" - ) { - return candidate; - } - candidate = candidate.parentElement; - } - return null; -} - -function scrollPreviewTargetLine(container: HTMLElement, line: HTMLElement) { - const viewport = findPreviewScrollViewport(container); - if (viewport === null) return; - - const lineRect = line.getBoundingClientRect(); - const viewportRect = viewport.getBoundingClientRect(); - const lineCenter = lineRect.top + lineRect.height / 2; - const viewportCenter = viewportRect.top + viewportRect.height / 2; - // Adjust only the vertical scroll offset. `scrollIntoView()` can also move - // the horizontal axis when a long source line extends beyond the viewport. - viewport.scrollTop += lineCenter - viewportCenter; -} - -function formatLineRange(startLineNumber: number, endLineNumber: number) { - return startLineNumber === endLineNumber - ? String(startLineNumber) - : `${startLineNumber}-${endLineNumber}`; -} - -function buildFilePreviewLineSelectionText({ - contents, - path, - range, -}: { - contents: string; - path: string; - range: SelectedLineRange; -}): string | null { - const startLineNumber = Math.max(1, Math.min(range.start, range.end)); - const endLineNumber = Math.max( - startLineNumber, - Math.max(range.start, range.end), - ); - const lines = contents.split(/\r\n|\n|\r/); - const selectedLines = lines.slice(startLineNumber - 1, endLineNumber); - if (selectedLines.length === 0) { - return null; - } - const selectedText = selectedLines.join("\n").trimEnd(); - if (selectedText.trim().length === 0) { - return null; - } - return `${path}:${formatLineRange(startLineNumber, endLineNumber)}\n${selectedText}`; -} - function FilePreviewLoading() { return (
@@ -1434,6 +1198,12 @@ function FilePreviewMessage({ message, role }: FilePreviewMessageProps) { ); } +/** + * The preview's source body. Everything here is chrome and policy — which + * lines to highlight, whether to scroll to them, the selection-to-chat hook — + * while the render itself goes through the shared host boundary, so an + * `experimental_sourceCodeRenderer` replacement covers the file preview too. + */ function FilePreviewCode({ file, lineOverflowMode, @@ -1441,336 +1211,27 @@ function FilePreviewCode({ onSelectionAddToChat, path, }: FilePreviewCodeProps) { - const preferredTheme = usePreferredTheme(); - const codeTheme = useResolvedCodeThemePair(); - const containerRef = useRef(null); - // `PierreFile` captures the worker pool when it creates its instance, so - // wait for the workspace to build the pool before the first render. - const isWorkerPoolReady = useRequirePierreWorkerPool(); - const workerPool = usePierreWorkerPool(); - const lastWorkerPoolStatsKeyRef = useRef(null); - const [workerPoolStats, setWorkerPoolStats] = - useState(null); - const [, rerenderAfterWorkerPoolChange] = useState(0); - const fileIdentity = file.cacheKey ?? file.name; - const truncation = useMemo( - () => truncateFilePreviewCode(file.contents), - [file.contents], - ); - // Which file the user asked to see in full. Keyed by identity rather than a - // boolean so opening a different large file goes back to the capped view - // without an effect resetting state. - const [fullFileRequestedFor, setFullFileRequestedFor] = useState< - string | null - >(null); - const buildSelectionText = useCallback( - (range: SelectedLineRange) => - buildFilePreviewLineSelectionText({ - contents: file.contents, - path, - range, - }), - [file.contents, path], - ); - const lineSelectionActions = usePierreLineSelectionActions({ - buildSelectionText, - containerRef, - enabled: onSelectionAddToChat !== undefined, - onSelectionAddToChat, - }); - const options = useMemo>( - () => ({ - themeType: preferredTheme, - theme: codeTheme, - overflow: lineOverflowMode, - disableFileHeader: true, - enableGutterUtility: onSelectionAddToChat !== undefined, - enableLineSelection: - lineRange !== null || onSelectionAddToChat !== undefined, - lineHoverHighlight: - onSelectionAddToChat === undefined ? "disabled" : "number", - onGutterUtilityClick: - onSelectionAddToChat === undefined - ? undefined - : lineSelectionActions.onGutterUtilityClick, - onLineSelectionChange: lineSelectionActions.onLineSelectionChange, - onLineSelectionEnd: lineSelectionActions.onLineSelectionEnd, - onLineSelectionStart: lineSelectionActions.onLineSelectionStart, - }), - [ - codeTheme, - lineOverflowMode, - lineRange, - lineSelectionActions.onGutterUtilityClick, - lineSelectionActions.onLineSelectionChange, - lineSelectionActions.onLineSelectionEnd, - lineSelectionActions.onLineSelectionStart, - onSelectionAddToChat, - preferredTheme, - ], - ); - const selectedLines = useMemo(() => { - if (lineSelectionActions.selectedRange !== null) { - return lineSelectionActions.selectedRange; - } - return lineRange === null - ? null - : { - start: lineRange.startLineNumber, - end: lineRange.endLineNumber, - }; - }, [lineRange, lineSelectionActions.selectedRange]); - const targetLineNumber = selectedLines?.start ?? null; - // A deep link past the capped prefix is an implicit request for the whole - // file: the target line has to exist in the DOM to be scrolled to. - const showsFullFile = - truncation === null || - fullFileRequestedFor === fileIdentity || - (targetLineNumber !== null && - targetLineNumber > truncation.renderedLineCount); - const renderedFile = useMemo(() => { - if (showsFullFile || truncation === null) { - return file; - } - return { - ...file, - // The worker highlight cache is keyed by `cacheKey`; the capped prefix - // must not collide with the full file's entry. - cacheKey: - file.cacheKey === undefined ? undefined : `${file.cacheKey}:head`, - contents: truncation.contents, - }; - }, [file, showsFullFile, truncation]); - // Pierre's virtualized file instance keeps the contents it was hydrated - // with (`VirtualizedFile.render` ignores a later `file`), so a content swap - // — the capped prefix giving way to the full file, or a refetch — needs a - // fresh mount. Callers that supply a `cacheKey` already fold the content - // hash into it; otherwise hash here. - const renderedFileMountKey = useMemo( + const highlightedLines = useMemo( () => - renderedFile.cacheKey ?? - `${renderedFile.name}:${hashFilePreviewContents(renderedFile.contents)}`, - [renderedFile], - ); - // "Load full file" remounts pierre with the whole file; carry the reader's - // scroll offset across so the prefix they were looking at stays put. - const pendingViewportScrollTopRef = useRef(null); - const handleLoadFullFile = () => { - const viewport = - containerRef.current === null - ? null - : findVirtualizedCodeViewport(containerRef.current); - pendingViewportScrollTopRef.current = viewport?.scrollTop ?? null; - setFullFileRequestedFor(fileIdentity); - }; - useLayoutEffect(() => { - const scrollTop = pendingViewportScrollTopRef.current; - if (scrollTop === null) return; - pendingViewportScrollTopRef.current = null; - const viewport = - containerRef.current === null + lineRange === null ? null - : findVirtualizedCodeViewport(containerRef.current); - if (viewport === null) return; - viewport.scrollTop = scrollTop; - // The virtualizer sizes the fresh instance on its next frame; reapply once - // that height exists so the offset is not clamped away. - const frame = window.requestAnimationFrame(() => { - viewport.scrollTop = scrollTop; - }); - return () => window.cancelAnimationFrame(frame); - }, [renderedFileMountKey]); - - useEffect(() => { - if (!workerPool) { - setWorkerPoolStats(null); - return; - } - - lastWorkerPoolStatsKeyRef.current = null; - return workerPool.subscribeToStatChanges((stats) => { - setWorkerPoolStats(stats); - const statsKey = [ - stats.managerState, - stats.workersFailed, - stats.busyWorkers, - stats.queuedTasks, - stats.activeTasks, - stats.fileCacheSize, - ].join(":"); - if (lastWorkerPoolStatsKeyRef.current === statsKey) { - return; - } - lastWorkerPoolStatsKeyRef.current = statsKey; - rerenderAfterWorkerPoolChange((version) => version + 1); - }); - }, [file.contents, file.name, workerPool]); - - const shouldWaitForWorkerPool = - workerPool !== undefined && - workerPoolStats?.managerState !== "initialized" && - workerPoolStats?.workersFailed !== true; - // Pierre can mount an empty zero-height
 while its worker highlighter is
-  // still initializing, so the code view waits for pool readiness. After that
-  // a single mount is enough: pierre paints the plain-text AST first and
-  // repaints in place when the worker delivers the highlighted one. That
-  // repaint swaps the line elements, so the target-line effect below re-runs
-  // when the highlight cache entry for this file appears.
-  const workerHighlightCacheState =
-    workerPool?.getFileResultCache(renderedFile) !== undefined
-      ? "highlighted"
-      : "plain";
-
-  useEffect(() => {
-    const cleanupContainer = containerRef.current;
-    let animationFrame: number | null = null;
-    let attempts = 0;
-
-    // Retry on the next frame (the target line may not be in the DOM yet). One
-    // rAF channel only: `scrollToLine` overwrites `animationFrame` on each
-    // reschedule, so at most one callback is ever pending and cleanup cancels
-    // it — no doubling or leaked stale callbacks marking the wrong line.
-    function scheduleRetry() {
-      animationFrame = window.requestAnimationFrame(scrollToLine);
-    }
-
-    function scrollToLine() {
-      const container = containerRef.current;
-      if (!container) return;
-      clearPreviewTargetLine(container);
-      if (targetLineNumber === null) return;
-
-      const line = findPreviewTargetLine(container, targetLineNumber);
-      if (line) {
-        line.setAttribute("data-file-preview-target-line", "");
-        line.setAttribute("data-selected-line", "single");
-        scrollPreviewTargetLine(container, line);
-        return;
-      }
-
-      // The virtualizer only realizes rows near the scroll window, so a
-      // target outside it is not in the DOM yet. Move the viewport toward the
-      // line's estimated offset and let pierre render that window before the
-      // next attempt.
-      approachVirtualizedTargetLine(container, targetLineNumber);
-      attempts += 1;
-      if (attempts < FILE_PREVIEW_TARGET_LINE_MAX_ATTEMPTS) {
-        scheduleRetry();
-      }
-    }
-
-    scrollToLine();
-    return () => {
-      if (cleanupContainer) {
-        clearPreviewTargetLine(cleanupContainer);
-      }
-      if (animationFrame !== null) {
-        window.cancelAnimationFrame(animationFrame);
-      }
-    };
-  }, [
-    renderedFile.contents,
-    renderedFile.name,
-    shouldWaitForWorkerPool,
-    targetLineNumber,
-    workerHighlightCacheState,
-  ]);
-
-  if (shouldWaitForWorkerPool || !isWorkerPoolReady) {
-    return ;
-  }
-
-  return (
-    
- - - - {truncation !== null && !showsFullFile ? ( - - ) : null} - - - {lineSelectionActions.menu} -
- ); -} - -const FILE_PREVIEW_TARGET_LINE_MAX_ATTEMPTS = 40; - -/** - * The code view's own scroll container, registered as pierre's virtualizer - * root so `PierreFile` mounts a `VirtualizedFile` that renders only the rows - * near the viewport. This mirrors `@pierre/diffs/react`'s ``, - * inlined so the scroller carries a ref and a data marker the target-line - * scrolling can find without walking the tree by class name. - */ -function FilePreviewCodeViewport({ children }: { children: ReactNode }) { - const [virtualizer] = useState(() => - typeof window === "undefined" ? undefined : new PierreVirtualizer(), - ); - const viewportRef = useCallback( - (node: HTMLDivElement | null) => { - if (node !== null) { - virtualizer?.setup(node); - } else { - virtualizer?.cleanUp(); - } - }, - [virtualizer], + : { start: lineRange.startLineNumber, end: lineRange.endLineNumber }, + [lineRange], ); return ( - -
-
{children}
-
-
- ); -} - -function FilePreviewCodeTruncationNotice({ - truncation, - onLoadFullFile, -}: { - truncation: FilePreviewCodeTruncation; - onLoadFullFile: () => void; -}) { - return ( -
- - Showing the first {truncation.renderedLineCount.toLocaleString()} of{" "} - {truncation.totalLineCount.toLocaleString()} lines. - - -
+ } + onSelectionAddToChat={onSelectionAddToChat} + /> ); } diff --git a/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx b/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx index d40e3038cb..380e20224f 100644 --- a/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx +++ b/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx @@ -1,11 +1,11 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import type { FileContents } from "@pierre/diffs"; -import { - GIT_DIFF_VIEW_BASE_OPTIONS, - GitDiffCard, - type DiffFileContentsResult, - type RequestDiffFileContents, -} from "../git-diff/GitDiffCard"; +import type { DiffPresentation } from "@/components/code/code-rendering"; +import { GitDiffCard } from "../git-diff/GitDiffCard"; +import type { + DiffFileContentsResult, + RequestDiffFileContents, +} from "@/components/git-diff/GitDiffCardBody"; import { DEFAULT_CODE_OVERFLOW_MODE, type CodeOverflowMode, @@ -17,10 +17,9 @@ import { } from "./GitDiffToolbar"; import { parseGitDiffFiles, - summarizeGitDiff, + summarizeGitDiffFile, type ParsedGitDiffFile, } from "../git-diff/git-diff-parsing"; -import { usePreferredTheme } from "@/hooks/useTheme"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { appToast } from "@/components/ui/app-toast"; @@ -737,15 +736,16 @@ function InteractiveDiffPanel({ ), [diffs], ); - const aggregateStats = useMemo( - () => - summarizeGitDiff( - parsed.map((p) => p.fileDiff), - parsed.map((p) => p.fullDiff).join("\n"), - ), - [parsed], - ); - const preferredTheme = usePreferredTheme(); + const aggregateStats = useMemo(() => { + let insertions = 0; + let deletions = 0; + for (const entry of parsed) { + const fileStats = summarizeGitDiffFile(entry.fileDiff); + insertions += fileStats.insertions; + deletions += fileStats.deletions; + } + return { filesCount: parsed.length, insertions, deletions }; + }, [parsed]); const [selection, setSelection] = useState("working"); const [displayMode, setDisplayMode] = useState("unified"); const [lineOverflowMode, setLineOverflowMode] = useState( @@ -776,14 +776,13 @@ function InteractiveDiffPanel({ return next; }); }, []); - const viewOptions = useMemo( + const presentation = useMemo( () => ({ - ...GIT_DIFF_VIEW_BASE_OPTIONS, - diffStyle: displayMode, + view: displayMode, overflow: lineOverflowMode, - themeType: preferredTheme, + showLineNumbers: true, }), - [displayMode, lineOverflowMode, preferredTheme], + [displayMode, lineOverflowMode], ); const onOpenFileInEditor = useCallback((path: string) => { appToast.message("Opening in editor", { description: path }); @@ -837,7 +836,7 @@ function InteractiveDiffPanel({ toggleFileCollapsed(fileKey)} diff --git a/apps/app/src/components/secondary-panel/GitDiffToolbar.tsx b/apps/app/src/components/secondary-panel/GitDiffToolbar.tsx index fa4801f921..74f8dab839 100644 --- a/apps/app/src/components/secondary-panel/GitDiffToolbar.tsx +++ b/apps/app/src/components/secondary-panel/GitDiffToolbar.tsx @@ -137,7 +137,7 @@ function GitDiffSelector({ ); } -export interface GitDiffToolbarProps { +interface GitDiffToolbarProps { selectionValue: string; selectionOptions: readonly GitDiffSelectionOption[]; onSelectionChange: (value: string) => void; diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx index 7a4508f308..2458816366 100644 --- a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx +++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx @@ -69,7 +69,7 @@ export interface NewTabFileSearchProps { export type OpenBrowserHandler = () => void; export type StartTerminalHandler = () => void; -export interface NewTabActionsProps { +interface NewTabActionsProps { /** Open a session-based side chat of the current thread in its own tab. */ /** Desktop-only: open a new in-panel browser tab. Absent ⇒ no Browser entry. */ onOpenBrowser?: OpenBrowserHandler; @@ -120,7 +120,6 @@ interface FileSearchSectionItem { interface FileSearchSection { kind: FileSearchSectionKind; - label: string; items: FileSearchSectionItem[]; } @@ -167,7 +166,6 @@ const FILE_SEARCH_LIMIT = 20; const FILE_SEARCH_SECTION_ORDER: readonly FileSearchSectionKind[] = [ "files", "recent", - "actions", ]; const FILE_SEARCH_SECTION_LABELS = { @@ -205,12 +203,6 @@ function getFileSearchResultTitle(suggestion: FileSearchSuggestion): string { return `${FILE_SEARCH_SOURCE_LABELS[suggestion.source]}: ${suggestion.path}`; } -function getFileSearchSectionKind( - suggestion: FileSearchSuggestion, -): FileSearchSectionKind { - return "files"; -} - function groupFileSearchSections({ recentEntries, suggestions, @@ -226,7 +218,6 @@ function groupFileSearchSections({ } const created: FileSearchSection = { kind: sectionKind, - label: FILE_SEARCH_SECTION_LABELS[sectionKind], items: [], }; sectionsByKind.set(sectionKind, created); @@ -234,7 +225,7 @@ function groupFileSearchSections({ }; for (const suggestion of suggestions) { - ensureSection(getFileSearchSectionKind(suggestion)).items.push({ + ensureSection("files").items.push({ entry: { kind: "suggestion", suggestion }, index: 0, }); diff --git a/apps/app/src/components/secondary-panel/NewTabPage.tsx b/apps/app/src/components/secondary-panel/NewTabPage.tsx index f8b1ab5a38..701ba38f66 100644 --- a/apps/app/src/components/secondary-panel/NewTabPage.tsx +++ b/apps/app/src/components/secondary-panel/NewTabPage.tsx @@ -10,7 +10,7 @@ import { type NewTabPageFileSearchProps = Omit; -export interface NewTabPageProps extends NewTabPageFileSearchProps { +interface NewTabPageProps extends NewTabPageFileSearchProps { onOpenBrowser?: OpenBrowserHandler; onStartTerminal?: StartTerminalHandler; pluginActions?: readonly PluginPanelActionEntry[]; diff --git a/apps/app/src/components/secondary-panel/RightPanelFileTabIcon.tsx b/apps/app/src/components/secondary-panel/RightPanelFileTabIcon.tsx new file mode 100644 index 0000000000..0f3f4b03db --- /dev/null +++ b/apps/app/src/components/secondary-panel/RightPanelFileTabIcon.tsx @@ -0,0 +1,18 @@ +import { COARSE_POINTER_COMPACT_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { Icon } from "@bb/shared-ui/icon"; +import { resolveRightPanelFileVisual } from "./rightPanelFileVisuals"; + +interface RightPanelFileTabIconProps { + path: string; +} + +export function RightPanelFileTabIcon({ path }: RightPanelFileTabIconProps) { + const visual = resolveRightPanelFileVisual({ path }); + return ( + + ); +} diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx index fd6c76cce8..3e0edb14f0 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx @@ -183,7 +183,7 @@ export function SecondaryPanelLayout({ cancelCompactDrawerContentSettleFrame(); // Native browser visibility is external to React and must be revoked // before paint when the drawer identity changes. - // eslint-disable-next-line react-hooks/set-state-in-effect + // oxlint-disable-next-line react/set-state-in-effect setIsCompactDrawerContentSettled(false); }, [cancelCompactDrawerContentSettleFrame, open, renderAsDrawer, resetKey]); diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelSelectionActions.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelSelectionActions.tsx index e2053d3cf1..76895b5299 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelSelectionActions.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelSelectionActions.tsx @@ -8,6 +8,7 @@ import { import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { anchorPointFromMouseEvent, + firstClientRect, selectionAnchorFromPointerRelease, type MessageProseSelection, type SelectionAnchor, @@ -17,25 +18,9 @@ import { TimelineSelectionMenu } from "@/components/thread/timeline/TimelineSele interface SecondaryPanelSelectionActionsProps { children: ReactNode; - className?: string; onSelectionAddToChat?: (text: string) => void; } -function firstClientRect(range: Range): DOMRect | null { - const rects = range.getClientRects(); - for (let index = 0; index < rects.length; index += 1) { - const rect = rects.item(index); - if (rect === null) { - continue; - } - if (rect.width > 0 || rect.height > 0) { - return rect; - } - } - const rect = range.getBoundingClientRect(); - return rect.width > 0 || rect.height > 0 ? rect : null; -} - function isEventTargetWithinNode( event: Event, node: HTMLElement | null, @@ -110,7 +95,6 @@ function readSelectionWithinPanel({ export function SecondaryPanelSelectionActions({ children, - className, onSelectionAddToChat, }: SecondaryPanelSelectionActionsProps) { const nodeRef = useRef(null); @@ -223,7 +207,7 @@ export function SecondaryPanelSelectionActions({ return ( <> -
+
{children}
{ const { container } = render( createElement(SecondaryPanelTabStrip, { - fileTabs: [ + activeTabId: "browser", + tabs: [ { - id: "browser", - filename: "Browser", - isActive: true, + label: "Browser", isPinned: false, leadingVisual: null, statusLabel: null, onSelect: vi.fn(), onClose: vi.fn(), + renderContent: () => null, + tab: { id: "browser", kind: "new-tab" }, }, ], onReorderTab: vi.fn(), diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.touch-sensor.test.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.touch-sensor.test.tsx index 713d0a6de2..76a513e020 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.touch-sensor.test.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.touch-sensor.test.tsx @@ -7,16 +7,16 @@ import { type SecondaryPanelTabStripProps, } from "./SecondaryPanelTabStrip"; -function makeTabs(count: number): SecondaryPanelTabStripProps["fileTabs"] { +function makeTabs(count: number): SecondaryPanelTabStripProps["tabs"] { return Array.from({ length: count }, (_, index) => ({ - id: `tab-${index}`, - filename: `file-${index}.ts`, - isActive: index === 0, + label: `file-${index}.ts`, isPinned: false, leadingVisual: null, statusLabel: null, onSelect: vi.fn(), onClose: vi.fn(), + renderContent: () => null, + tab: { id: `tab-${index}`, kind: "new-tab" as const }, })); } @@ -36,7 +36,8 @@ describe("SecondaryPanelTabStrip touch sensor scoping", () => { const addSpy = vi.spyOn(window, "addEventListener"); const removeSpy = vi.spyOn(window, "removeEventListener"); const baseProps: SecondaryPanelTabStripProps = { - fileTabs: makeTabs(2), + activeTabId: "tab-0", + tabs: makeTabs(2), onReorderTab: vi.fn(), usesDesktopChrome: false, isPanelOpen: false, @@ -61,11 +62,7 @@ describe("SecondaryPanelTabStrip touch sensor scoping", () => { // A single tab has nothing to reorder even in an open panel. rerender( - , + , ); expect(touchMoveCalls(addSpy)).toHaveLength(1); }); diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx index 15d3d2dddf..5320988173 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx @@ -1,6 +1,7 @@ import { type CSSProperties, type MouseEventHandler, + type PointerEvent as ReactPointerEvent, type RefObject, useCallback, useEffect, @@ -41,10 +42,9 @@ import { MACOS_WINDOW_NO_DRAG_CLASS, } from "@/lib/bb-desktop"; import type { - SecondaryPanelFileTab, + SecondaryPanelRenderableTab, SecondaryPanelTabReorderHandler, -} from "./secondaryPanelFileTab"; -export type { SecondaryPanelFileTab } from "./secondaryPanelFileTab"; +} from "./secondaryPanelTab"; // Roughly one wide tab, so one click reveals the next tab without overshooting. const CHEVRON_SCROLL_STEP_PX = 140; @@ -85,7 +85,12 @@ const INITIAL_OVERFLOW_STATE: TabStripOverflowState = { }; export interface SecondaryPanelTabStripProps { - fileTabs: SecondaryPanelFileTab[]; + activeTabId: string | null; + tabs: readonly SecondaryPanelRenderableTab[]; + onBeginTabDrag?: ( + tabId: string, + event: ReactPointerEvent, + ) => void; onReorderTab: SecondaryPanelTabReorderHandler; usesDesktopChrome: boolean; /** @@ -95,32 +100,36 @@ export interface SecondaryPanelTabStripProps { * every page. */ isPanelOpen: boolean; - activeTreatment?: "fill" | "underline"; } -interface SortableFileTabProps { - activeTreatment: "fill" | "underline"; +interface SortablePanelTabProps { + isActive: boolean; activeTabRef: RefObject; dragDisabled: boolean; noDragClass: string | null; - tab: SecondaryPanelFileTab; + onBeginTabDrag?: ( + tabId: string, + event: ReactPointerEvent, + ) => void; + tab: SecondaryPanelRenderableTab; } /** * The middle, horizontally-scrolling region of the secondary panel tab strip. * - * Only the file tabs scroll; the leading Info/Diff controls and trailing + * Only the closable tabs scroll; the leading Info/Diff controls and trailing * new-tab/panel controls stay anchored outside this component. Edge * fades and scroll buttons appear only on a side that has more tabs, and the * active tab is auto-scrolled into view on mount and whenever it changes * (covering pointer, keyboard, and programmatic selection). */ export function SecondaryPanelTabStrip({ - fileTabs, + activeTabId, + tabs, + onBeginTabDrag, onReorderTab, usesDesktopChrome, isPanelOpen, - activeTreatment = "fill", }: SecondaryPanelTabStripProps) { const stripRef = useRef(null); const viewportRef = useRef(null); @@ -145,7 +154,7 @@ export function SecondaryPanelTabStrip({ clearDragClickSuppressionSoon, consumeDragClickSuppression, } = useDragClickSuppression(); - const dragDisabled = fileTabs.length < 2; + const dragDisabled = tabs.length < 2; const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 4 }, }); @@ -163,11 +172,11 @@ export function SecondaryPanelTabStrip({ { activationConstraint: { delay: 200, tolerance: 6 } }, ); const sensors = useSensors(mouseSensor, touchSensor); - const tabIds = useMemo(() => fileTabs.map((tab) => tab.id), [fileTabs]); + const tabIds = useMemo(() => tabs.map((tab) => tab.tab.id), [tabs]); const draggingTab = draggingTabId === null ? null - : (fileTabs.find((tab) => tab.id === draggingTabId) ?? null); + : (tabs.find((tab) => tab.tab.id === draggingTabId) ?? null); // Cheap: reads only scrollLeft (no layout flush) against the cached capacity. const applyEdgeFlags = useCallback(() => { @@ -257,7 +266,7 @@ export function SecondaryPanelTabStrip({ // rename), so re-measure capacity whenever the tab list changes. useEffect(() => { measureCapacity(); - }, [fileTabs, measureCapacity]); + }, [tabs, measureCapacity]); // A web-font swap changes the tabs' intrinsic width (and so scrollWidth) // without resizing the viewport or changing the tab list, which would leave the @@ -272,7 +281,6 @@ export function SecondaryPanelTabStrip({ // keeps a tab that was aligned to the old viewport edge from being clipped // when the controls reserve space. jsdom doesn't implement scrollIntoView, // so guard the call. - const activeTabId = fileTabs.find((tab) => tab.isActive)?.id ?? null; useLayoutEffect(() => { const activeTabElement = activeTabRef.current; if (activeTabElement === null) { @@ -423,13 +431,14 @@ export function SecondaryPanelTabStrip({ items={tabIds} strategy={horizontalListSortingStrategy} > - {fileTabs.map((tab) => ( - ( + ))} @@ -441,7 +450,10 @@ export function SecondaryPanelTabStrip({ {createPortal( {draggingTab === null ? null : ( - + )} , document.body, @@ -454,11 +466,12 @@ export function SecondaryPanelTabStrip({ handleDragCancel, handleDragEnd, tabIds, - fileTabs, + tabs, dragDisabled, noDragClass, + onBeginTabDrag, draggingTab, - activeTreatment, + activeTabId, ], ); @@ -530,26 +543,29 @@ export function SecondaryPanelTabStrip({ ); } -function SortableFileTab({ - activeTreatment, +function SortablePanelTab({ activeTabRef, dragDisabled, + isActive, noDragClass, + onBeginTabDrag, tab, -}: SortableFileTabProps) { +}: SortablePanelTabProps) { const { isDragging, listeners, setNodeRef, transform, transition } = useSortable({ - id: tab.id, + id: tab.tab.id, disabled: dragDisabled, }); + const { onPointerDown: sortablePointerDown, ...sortableListeners } = + listeners ?? {}; const setTabRef = useCallback( (element: HTMLDivElement | null) => { setNodeRef(element); - if (tab.isActive) { + if (isActive) { activeTabRef.current = element; } }, - [activeTabRef, setNodeRef, tab.isActive], + [activeTabRef, isActive, setNodeRef], ); const style = useMemo( () => ({ @@ -571,9 +587,13 @@ function SortableFileTab({ isDragging && "opacity-40", noDragClass, )} - {...listeners} + onPointerDown={(event) => { + onBeginTabDrag?.(tab.tab.id, event); + sortablePointerDown?.(event); + }} + {...sortableListeners} > - +
); } @@ -623,25 +643,22 @@ function TabStripScrollButton({ ); } -function FileTab({ +function PanelTab({ tab, - activeTreatment, + isActive, }: { - tab: SecondaryPanelFileTab; - activeTreatment: "fill" | "underline"; + tab: SecondaryPanelRenderableTab; + isActive: boolean; }) { const title = - tab.statusLabel === null - ? tab.filename - : `${tab.filename} (${tab.statusLabel})`; + tab.statusLabel === null ? tab.label : `${tab.label} (${tab.statusLabel})`; return ( diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx new file mode 100644 index 0000000000..10064655a3 --- /dev/null +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx @@ -0,0 +1,780 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { useState, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { + SidebarSplitContainer, + type SidebarSplitPaneRenderArgs, + type SidebarSplitTabDescriptor, +} from "./SidebarSplitContainer"; +import { + createSidebarSplitState, + focusSidebarPane, + moveSidebarTab, + serializeSidebarSplitState, + sidebarSplitStorageKey, + type SidebarSplitState, +} from "./sidebarSplitLayout"; +import { getFixedPanelTabsStateStorageKey } from "@/lib/fixed-panel-tabs-state"; + +const TABS: readonly SidebarSplitTabDescriptor[] = [ + { id: "tab-a", label: "A" }, + { id: "tab-b", label: "B" }, +]; +const PANEL_STATE_ID = "sidebar-split-container-test"; +let nextPaneInstance = 0; + +function createTwoPaneState(): SidebarSplitState { + const initial = createSidebarSplitState( + TABS.map((tab) => tab.id), + "tab-a", + ); + return moveSidebarTab( + initial, + initial.layout.focusedPaneId, + "tab-b", + { paneId: initial.layout.focusedPaneId, zone: "right" }, + { groupId: "group-b" }, + ); +} + +function createStackedPaneState(): SidebarSplitState { + const initial = createSidebarSplitState( + TABS.map((tab) => tab.id), + "tab-a", + ); + return moveSidebarTab( + initial, + initial.layout.focusedPaneId, + "tab-b", + { paneId: initial.layout.focusedPaneId, zone: "bottom" }, + { groupId: "group-b" }, + ); +} + +function persistState(state: SidebarSplitState): void { + window.localStorage.setItem( + sidebarSplitStorageKey(PANEL_STATE_ID), + serializeSidebarSplitState(state), + ); +} + +function renderContainer({ + activeTabId = "tab-a", + onActivateTab = vi.fn(), + renderPane, + tabs = TABS, +}: { + activeTabId?: string; + onActivateTab?: (tabId: string) => void; + renderPane: (args: SidebarSplitPaneRenderArgs) => ReactNode; + tabs?: readonly SidebarSplitTabDescriptor[]; +}) { + return render( + + + + + , + ); +} + +function StatefulPane({ + onMoveActiveTabToSide, + paneId, +}: { + onMoveActiveTabToSide: NonNullable< + SidebarSplitPaneRenderArgs["onMoveActiveTabToSide"] + >; + paneId: string; +}) { + const [instanceId] = useState(() => `${paneId}-${nextPaneInstance++}`); + return ( +
+ {instanceId} + +
+ ); +} + +describe("SidebarSplitContainer", () => { + beforeEach(() => { + window.localStorage.clear(); + nextPaneInstance = 0; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }); + + it("activates a focused pane outside React's state updater", async () => { + const split = createTwoPaneState(); + const firstPaneId = + split.layout.root.type === "split" + ? split.layout.root.children[0]?.type === "pane" + ? split.layout.root.children[0].paneId + : null + : null; + const secondPaneId = + split.layout.root.type === "split" + ? split.layout.root.children[1]?.type === "pane" + ? split.layout.root.children[1].paneId + : null + : null; + expect(firstPaneId).not.toBeNull(); + expect(secondPaneId).not.toBeNull(); + if (firstPaneId === null || secondPaneId === null) return; + persistState(focusSidebarPane(split, firstPaneId)); + + const activate = vi.fn(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + function Harness() { + const [activeTabId, setActiveTabId] = useState("tab-a"); + return ( + { + activate(tabId); + setActiveTabId(tabId); + }} + onGlobalTabReorder={vi.fn()} + panelStateId={PANEL_STATE_ID} + renderPane={({ paneId }) => ( +
{paneId}
+ )} + tabs={TABS} + /> + ); + } + + render( + + + + + , + ); + fireEvent.pointerDown(screen.getByTestId(`pane-content-${secondPaneId}`)); + + await waitFor(() => expect(activate).toHaveBeenCalledWith("tab-b")); + expect(activate).toHaveBeenCalledTimes(1); + expect( + consoleError.mock.calls.some((call) => + call.some( + (value) => + typeof value === "string" && + value.includes("Cannot update a component while rendering"), + ), + ), + ).toBe(false); + }); + + it("assigns focus and outer controls to the appropriate panes", () => { + const split = createTwoPaneState(); + const focusedPaneId = + split.layout.root.type === "split" && + split.layout.root.children[0]?.type === "pane" + ? split.layout.root.children[0].paneId + : split.layout.focusedPaneId; + persistState(focusSidebarPane(split, focusedPaneId)); + + renderContainer({ + renderPane: ({ isFocused, paneId, showOuterControls }) => ( +
+ {`${isFocused}:${showOuterControls}`} +
+ ), + }); + + const paneStates = screen.getAllByTestId(/pane-state-/); + expect(paneStates.map((pane) => pane.textContent)).toContain("true:false"); + expect(paneStates.map((pane) => pane.textContent)).toContain("false:true"); + }); + + it("keeps outer controls in the top pane of a stacked split", () => { + persistState(createStackedPaneState()); + + renderContainer({ + renderPane: ({ isTopRow, paneId, showOuterControls }) => ( +
+ {`${isTopRow}:${showOuterControls}`} +
+ ), + }); + + expect( + screen.getAllByTestId(/pane-edge-/).map((pane) => pane.textContent), + ).toEqual(["true:true", "false:false"]); + }); + + it("keeps an active New Tab replacement in the same split pane", async () => { + persistState(createStackedPaneState()); + + function Harness() { + const [terminalOpen, setTerminalOpen] = useState(false); + const tabs = terminalOpen + ? [ + TABS[0] as SidebarSplitTabDescriptor, + { id: "terminal-a", label: "Terminal" }, + ] + : TABS; + return ( + <> + + ( +
+ {group.activeTabId} +
+ )} + tabs={tabs} + /> + + ); + } + + render( + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Start terminal" })); + + await waitFor(() => + expect( + screen.getAllByTestId(/active-tab-/).map((tab) => tab.textContent), + ).toEqual(["tab-a", "terminal-a"]), + ); + expect(document.querySelectorAll("[data-split-pane-id]")).toHaveLength(2); + }); + + it.each([ + ["left", "flex-row", "tab-a,tab-b"], + ["right", "flex-row", "tab-b,tab-a"], + ["top", "flex-col", "tab-a,tab-b"], + ["bottom", "flex-col", "tab-b,tab-a"], + ] as const)( + "moves the active tab to the supported %s position without dragging", + (side, directionClass, expectedOrder) => { + renderContainer({ + renderPane: ({ group, onMoveActiveTabToSide }) => ( + + ), + }); + + fireEvent.click( + screen.getByRole("button", { name: `Move active ${side}tab-a` }), + ); + + const panes = Array.from( + document.querySelectorAll("[data-split-pane-id]"), + ); + expect(panes).toHaveLength(2); + expect(panes[0]?.parentElement?.parentElement?.className).toContain( + directionClass, + ); + expect( + panes + .map( + (pane) => + pane.querySelector("[data-testid='active-pane-tab']") + ?.textContent, + ) + .join(","), + ).toBe(expectedOrder); + }, + ); + + it("positions the focused active tab even when the control is in the outer pane", () => { + const split = createTwoPaneState(); + const firstPane = + split.layout.root.type === "split" && + split.layout.root.children[0]?.type === "pane" + ? split.layout.root.children[0] + : null; + expect(firstPane).not.toBeNull(); + if (firstPane === null) return; + persistState(focusSidebarPane(split, firstPane.paneId)); + + renderContainer({ + renderPane: ({ group, onMoveActiveTabToSide, showOuterControls }) => ( +
+ {group.activeTabId} + {showOuterControls ? ( + + ) : null} +
+ ), + }); + + fireEvent.click( + screen.getByRole("button", { name: "Move focused bottom" }), + ); + expect( + screen + .getAllByTestId("active-pane-tab") + .map((tab) => tab.textContent) + .join(","), + ).toBe("tab-b,tab-a"); + }); + + it("keeps stateful pane content attached to pane identity after a move", () => { + const split = createTwoPaneState(); + persistState(split); + + renderContainer({ + renderPane: ({ onMoveActiveTabToSide, paneId }) => + onMoveActiveTabToSide ? ( + + ) : null, + }); + + const paneIds = Array.from( + document.querySelectorAll("[data-split-pane-id]"), + (pane) => pane.dataset.splitPaneId, + ).filter((paneId): paneId is string => paneId !== undefined); + expect(paneIds).toHaveLength(2); + const before = new Map( + paneIds.map((paneId) => [ + paneId, + screen.getByTestId(`pane-instance-${paneId}`).textContent, + ]), + ); + const paneToMove = paneIds[1]; + expect(paneToMove).toBeDefined(); + if (paneToMove === undefined) return; + + fireEvent.click( + screen.getByRole("button", { name: `Move ${paneToMove} left` }), + ); + + for (const paneId of paneIds) { + expect(screen.getByTestId(`pane-instance-${paneId}`).textContent).toBe( + before.get(paneId) ?? "missing-instance", + ); + } + }); + + it.each([ + [ + "side-by-side", + createTwoPaneState, + "Resize right panel panes", + "vertical", + ], + [ + "stacked", + createStackedPaneState, + "Resize stacked right panel panes", + "horizontal", + ], + ] as const)( + "renders %s pane headers and bodies on either side of one continuous divider", + (_layout, createState, separatorName, orientation) => { + persistState(createState()); + renderContainer({ + renderPane: ({ group, paneId }) => ( +
+
{group.activeTabId}
+
{group.activeTabId}
+
+ ), + }); + + const panes = Array.from( + document.querySelectorAll("[data-split-pane-id]"), + ); + expect(panes).toHaveLength(2); + for (const pane of panes) { + const paneId = pane.dataset.splitPaneId; + expect( + pane.querySelector(`[data-testid='header-${paneId}']`), + ).not.toBeNull(); + expect( + pane.querySelector(`[data-testid='body-${paneId}']`), + ).not.toBeNull(); + } + const separator = screen.getByRole("separator", { + name: separatorName, + }); + expect(screen.getAllByRole("separator")).toHaveLength(1); + expect(separator.getAttribute("aria-orientation")).toBe(orientation); + expect(separator.className).toContain("bg-border-seam"); + }, + ); + + it("resizes stacked panes only from vertical pointer movement", () => { + persistState(createStackedPaneState()); + renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + + const separator = screen.getByRole("separator", { + name: "Resize stacked right panel panes", + }); + const hitTarget = separator.firstElementChild; + const previous = separator.previousElementSibling; + const next = separator.nextElementSibling; + if ( + !(hitTarget instanceof HTMLElement) || + !(previous instanceof HTMLElement) || + !(next instanceof HTMLElement) + ) { + throw new Error("Expected one stacked-pane resize pair"); + } + Object.defineProperty(hitTarget, "setPointerCapture", { value: vi.fn() }); + vi.spyOn(previous, "getBoundingClientRect").mockReturnValue({ + bottom: 400, + height: 400, + left: 0, + right: 800, + top: 0, + width: 800, + x: 0, + y: 0, + toJSON: () => ({}), + }); + vi.spyOn(next, "getBoundingClientRect").mockReturnValue({ + bottom: 801, + height: 400, + left: 0, + right: 800, + top: 401, + width: 800, + x: 0, + y: 401, + toJSON: () => ({}), + }); + + fireEvent.pointerDown(hitTarget, { + clientX: 400, + clientY: 400, + pointerId: 3, + }); + fireEvent.pointerMove(hitTarget, { + clientX: 700, + clientY: 400, + pointerId: 3, + }); + expect(Number.parseFloat(previous.style.flex)).toBeCloseTo(0.499, 3); + expect(Number.parseFloat(next.style.flex)).toBeCloseTo(0.501, 3); + + fireEvent.pointerMove(hitTarget, { + clientX: 700, + clientY: 600, + pointerId: 3, + }); + expect(Number.parseFloat(previous.style.flex)).toBeCloseTo(0.749, 3); + expect(Number.parseFloat(next.style.flex)).toBeCloseTo(0.251, 3); + fireEvent.pointerUp(hitTarget, { + clientX: 700, + clientY: 600, + pointerId: 3, + }); + }); + + it("does not resize or persist when the divider is pressed and released in place", () => { + persistState(createTwoPaneState()); + const storageKey = sidebarSplitStorageKey(PANEL_STATE_ID); + const setItem = vi.spyOn(Storage.prototype, "setItem"); + renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + const storedState = window.localStorage.getItem(storageKey); + setItem.mockClear(); + + const separator = screen.getByRole("separator"); + const hitTarget = separator.firstElementChild; + const previous = separator.previousElementSibling; + const next = separator.nextElementSibling; + if ( + !(hitTarget instanceof HTMLElement) || + !(previous instanceof HTMLElement) || + !(next instanceof HTMLElement) + ) { + throw new Error("Expected a resize pair"); + } + Object.defineProperty(hitTarget, "setPointerCapture", { value: vi.fn() }); + vi.spyOn(previous, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 0, + right: 400, + top: 0, + width: 400, + x: 0, + y: 0, + toJSON: () => ({}), + }); + vi.spyOn(next, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 401, + right: 801, + top: 0, + width: 400, + x: 401, + y: 0, + toJSON: () => ({}), + }); + const initialFlex = [previous.style.flex, next.style.flex]; + + fireEvent.pointerDown(hitTarget, { clientX: 400, pointerId: 22 }); + fireEvent.pointerUp(hitTarget, { clientX: 400, pointerId: 22 }); + + expect([previous.style.flex, next.style.flex]).toEqual(initialFlex); + expect(window.localStorage.getItem(storageKey)).toBe(storedState); + expect( + setItem.mock.calls.filter(([key]) => key === storageKey), + ).toHaveLength(0); + expect(separator.dataset.dragging).toBeUndefined(); + }); + + it("restores both adjacent flex values after pointer cancellation", () => { + persistState(createTwoPaneState()); + renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + + const separator = screen.getByRole("separator"); + expect(separator.className).toContain("bg-border-seam"); + const hitTarget = separator.firstElementChild; + const previous = separator.previousElementSibling; + const next = separator.nextElementSibling; + expect(hitTarget).toBeInstanceOf(HTMLElement); + expect(previous).toBeInstanceOf(HTMLElement); + expect(next).toBeInstanceOf(HTMLElement); + if ( + !(hitTarget instanceof HTMLElement) || + !(previous instanceof HTMLElement) || + !(next instanceof HTMLElement) + ) { + return; + } + Object.defineProperty(hitTarget, "setPointerCapture", { value: vi.fn() }); + Object.defineProperty(previous, "getBoundingClientRect", { + value: () => ({ left: 0, right: 400, top: 0, bottom: 600 }), + }); + Object.defineProperty(next, "getBoundingClientRect", { + value: () => ({ left: 401, right: 800, top: 0, bottom: 600 }), + }); + const previousFlex = previous.style.flex; + const nextFlex = next.style.flex; + + fireEvent.pointerDown(hitTarget, { clientX: 400, pointerId: 1 }); + fireEvent.pointerMove(hitTarget, { clientX: 560, pointerId: 1 }); + expect(previous.style.flex).not.toBe(previousFlex); + expect(next.style.flex).not.toBe(nextFlex); + + fireEvent.pointerCancel(hitTarget, { clientX: 560, pointerId: 1 }); + expect(previous.style.flex).toBe(previousFlex); + expect(next.style.flex).toBe(nextFlex); + expect(document.body.style.userSelect).toBe(""); + }); + + it("keeps divider drag cursor and selection state off the document root", () => { + persistState(createTwoPaneState()); + renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + + const separator = screen.getByRole("separator"); + const hitTarget = separator.firstElementChild; + const previous = separator.previousElementSibling; + const next = separator.nextElementSibling; + if ( + !(hitTarget instanceof HTMLElement) || + !(previous instanceof HTMLElement) || + !(next instanceof HTMLElement) + ) { + throw new Error("Expected a split divider and adjacent panes"); + } + Object.defineProperty(hitTarget, "setPointerCapture", { value: vi.fn() }); + Object.defineProperty(previous, "getBoundingClientRect", { + value: () => ({ left: 0, right: 400, top: 0, bottom: 600 }), + }); + Object.defineProperty(next, "getBoundingClientRect", { + value: () => ({ left: 401, right: 800, top: 0, bottom: 600 }), + }); + const bodyStyleBefore = document.body.getAttribute("style"); + const rootStyleBefore = document.documentElement.getAttribute("style"); + + expect( + fireEvent.pointerDown(hitTarget, { clientX: 400, pointerId: 7 }), + ).toBe(false); + expect(document.body.getAttribute("style")).toBe(bodyStyleBefore); + expect(document.documentElement.getAttribute("style")).toBe( + rootStyleBefore, + ); + const overlay = screen.getByTestId("iframe-drag-guard-overlay"); + expect(overlay.className).toContain("cursor-col-resize"); + expect(separator.closest("[data-sidebar-split-container]")?.lastChild).toBe( + overlay, + ); + + fireEvent.pointerCancel(hitTarget, { clientX: 400, pointerId: 7 }); + expect(screen.queryByTestId("iframe-drag-guard-overlay")).toBeNull(); + expect(document.body.getAttribute("style")).toBe(bodyStyleBefore); + expect(document.documentElement.getAttribute("style")).toBe( + rootStyleBefore, + ); + }); + + it("uses a row-resize drag guard for stacked panes", () => { + persistState(createStackedPaneState()); + renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + + const separator = screen.getByRole("separator", { + name: "Resize stacked right panel panes", + }); + const hitTarget = separator.firstElementChild; + const previous = separator.previousElementSibling; + const next = separator.nextElementSibling; + if ( + !(hitTarget instanceof HTMLElement) || + !(previous instanceof HTMLElement) || + !(next instanceof HTMLElement) + ) { + throw new Error("Expected a stacked split divider and adjacent panes"); + } + Object.defineProperty(hitTarget, "setPointerCapture", { value: vi.fn() }); + Object.defineProperty(previous, "getBoundingClientRect", { + value: () => ({ left: 0, right: 800, top: 0, bottom: 400 }), + }); + Object.defineProperty(next, "getBoundingClientRect", { + value: () => ({ left: 0, right: 800, top: 401, bottom: 800 }), + }); + + fireEvent.pointerDown(hitTarget, { clientY: 400, pointerId: 9 }); + expect( + screen.getByTestId("iframe-drag-guard-overlay").className, + ).toContain("cursor-row-resize"); + + fireEvent.pointerCancel(hitTarget, { clientY: 400, pointerId: 9 }); + expect(screen.queryByTestId("iframe-drag-guard-overlay")).toBeNull(); + }); + + it("cancels an in-flight divider resize when the split tree unmounts", () => { + persistState(createTwoPaneState()); + const view = renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + + const separator = screen.getByRole("separator"); + const hitTarget = separator.firstElementChild; + const previous = separator.previousElementSibling; + const next = separator.nextElementSibling; + if ( + !(hitTarget instanceof HTMLElement) || + !(previous instanceof HTMLElement) || + !(next instanceof HTMLElement) + ) { + throw new Error("Expected a split divider and adjacent panes"); + } + Object.defineProperty(hitTarget, "setPointerCapture", { value: vi.fn() }); + Object.defineProperty(previous, "getBoundingClientRect", { + value: () => ({ left: 0, right: 400, top: 0, bottom: 600 }), + }); + Object.defineProperty(next, "getBoundingClientRect", { + value: () => ({ left: 401, right: 800, top: 0, bottom: 600 }), + }); + const previousFlex = previous.style.flex; + const nextFlex = next.style.flex; + + fireEvent.pointerDown(hitTarget, { clientX: 400, pointerId: 8 }); + fireEvent.pointerMove(hitTarget, { clientX: 560, pointerId: 8 }); + expect(previous.style.flex).not.toBe(previousFlex); + expect(next.style.flex).not.toBe(nextFlex); + + view.unmount(); + expect(separator.dataset.dragging).toBeUndefined(); + expect(previous.style.flex).toBe(previousFlex); + expect(next.style.flex).toBe(nextFlex); + fireEvent.pointerMove(hitTarget, { clientX: 700, pointerId: 8 }); + expect(previous.style.flex).toBe(previousFlex); + expect(next.style.flex).toBe(nextFlex); + }); + + it("does not write a canonical layout or rewrite a focused-pane no-op", () => { + const storageKey = sidebarSplitStorageKey(PANEL_STATE_ID); + const setItem = vi.spyOn(Storage.prototype, "setItem"); + + renderContainer({ + renderPane: ({ paneId }) => ( + + ), + tabs: [TABS[0] as SidebarSplitTabDescriptor], + }); + + fireEvent.pointerDown(screen.getByTestId("only-pane")); + expect( + setItem.mock.calls.filter(([key]) => key === storageKey), + ).toHaveLength(0); + expect(window.localStorage.getItem(storageKey)).toBeNull(); + }); + + it("does not rewrite an unchanged restored split", () => { + persistState(createTwoPaneState()); + window.localStorage.setItem( + getFixedPanelTabsStateStorageKey({ threadId: PANEL_STATE_ID }), + JSON.stringify({ lastUsedAt: Date.now() }), + ); + const storageKey = sidebarSplitStorageKey(PANEL_STATE_ID); + const setItem = vi.spyOn(Storage.prototype, "setItem"); + + renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + + expect( + setItem.mock.calls.filter(([key]) => key === storageKey), + ).toHaveLength(0); + }); +}); diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx new file mode 100644 index 0000000000..5b045fcd3a --- /dev/null +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx @@ -0,0 +1,749 @@ +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; +import { useAtomValue } from "jotai"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { beginSplitDrag, type SplitDropTarget } from "@/lib/split-drag"; +import { + clampSplitPairFraction, + computePaneRects, + countPanes, + listPanes, + MAX_PANES, + type LayoutNode, + type SplitPath, + type SplitSide, +} from "@/lib/split-layout"; +import { dimInactiveSplitsAtom } from "@/lib/split-layout/atoms"; +import { IframeDragGuardOverlay } from "@/lib/iframe-drag-guard"; +import { MACOS_APP_REGION_NO_DRAG_CLASS } from "@/lib/bb-desktop"; +import { + PaneContext, + type PaneContextValue, +} from "@/views/thread-detail/PaneContext"; +import { + createSidebarSplitState, + focusSidebarPane, + getSidebarGroupForPane, + isCanonicalSidebarSplitState, + moveSidebarPaneToSide, + moveSidebarTab, + parseSidebarSplitState, + pruneSidebarSplitStorage, + reconcileSidebarSplitState, + reorderSidebarTab, + replaceSidebarTab, + resizeSidebarSplit, + selectSidebarTab, + serializeSidebarSplitState, + sidebarPaneGroupId, + sidebarSplitStorageKey, + type SidebarSplitState, + type SidebarTabGroup, +} from "./sidebarSplitLayout"; +import type { SecondaryPanelTabReorderRequest } from "./secondaryPanelTab"; + +const PANE_DRAG_ENGAGE_DISTANCE_PX = 7; +type SidebarSplitResizeCursor = "col-resize" | "row-resize"; + +export interface SidebarSplitTabDescriptor { + id: string; + label: string; +} + +export interface SidebarSplitPaneRenderArgs { + group: SidebarTabGroup; + isFocused: boolean; + isLeftEdge: boolean; + isTopRow: boolean; + onBeginTabDrag: ( + tabId: string, + event: ReactPointerEvent, + ) => void; + onReorderTab: (request: SecondaryPanelTabReorderRequest) => void; + onFocusPane: () => void; + onMoveActiveTabToSide?: (side: SplitSide) => void; + onSelectTab: (tabId: string) => void; + paneId: string; + showOuterControls: boolean; +} + +interface SidebarSplitContainerProps { + activeTabId: string; + onActivateTab: (tabId: string) => void; + onGlobalTabReorder: (request: SecondaryPanelTabReorderRequest) => void; + panelStateId: string; + renderPane: (args: SidebarSplitPaneRenderArgs) => ReactNode; + tabs: readonly SidebarSplitTabDescriptor[]; +} + +export function SidebarSplitContainer({ + activeTabId, + onActivateTab, + onGlobalTabReorder, + panelStateId, + renderPane, + tabs, +}: SidebarSplitContainerProps) { + const availableTabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]); + const storageKey = sidebarSplitStorageKey(panelStateId); + const [initialStorageValue] = useState(() => + typeof window === "undefined" + ? null + : window.localStorage.getItem(storageKey), + ); + const [state, setState] = useState(() => + typeof window === "undefined" + ? createSidebarSplitState(availableTabIds, activeTabId) + : parseSidebarSplitState( + initialStorageValue, + availableTabIds, + activeTabId, + ), + ); + const stateRef = useRef(state); + const lastPersistedValueRef = useRef({ + storageKey, + value: initialStorageValue, + }); + const previousActiveTabId = useRef(activeTabId); + const dimsInactiveSplits = useAtomValue(dimInactiveSplitsAtom); + const [resizeCursor, setResizeCursor] = + useState(null); + const paneCount = countPanes(state.layout.root); + const hasMultiplePanes = paneCount > 1; + + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + const previousExternalActiveTabId = previousActiveTabId.current; + const shouldFollowExternalSelection = + previousExternalActiveTabId !== activeTabId; + previousActiveTabId.current = activeTabId; + const current = stateRef.current; + const withActiveTabReplacement = + shouldFollowExternalSelection && + !availableTabIds.includes(previousExternalActiveTabId) + ? replaceSidebarTab(current, previousExternalActiveTabId, activeTabId) + : current; + const reconciled = reconcileSidebarSplitState( + withActiveTabReplacement, + availableTabIds, + activeTabId, + ); + const activePane = shouldFollowExternalSelection + ? listPanes(reconciled.layout.root).find((pane) => + getSidebarGroupForPane(reconciled, pane.paneId)?.tabIds.includes( + activeTabId, + ), + ) + : undefined; + const next = + activePane === undefined + ? reconciled + : selectSidebarTab(reconciled, activePane.paneId, activeTabId); + if (next !== current) { + stateRef.current = next; + setState(next); + } + }, [activeTabId, availableTabIds]); + + useEffect(() => { + pruneSidebarSplitStorage({ + storage: window.localStorage, + now: Date.now(), + }); + lastPersistedValueRef.current = { + storageKey, + value: window.localStorage.getItem(storageKey), + }; + }, [storageKey]); + + useEffect(() => { + const persistedValue = isCanonicalSidebarSplitState( + state, + availableTabIds, + activeTabId, + ) + ? null + : serializeSidebarSplitState(state); + const previous = lastPersistedValueRef.current; + if ( + previous.storageKey === storageKey && + previous.value === persistedValue + ) { + return; + } + if (persistedValue === null) { + window.localStorage.removeItem(storageKey); + } else { + window.localStorage.setItem(storageKey, persistedValue); + } + lastPersistedValueRef.current = { storageKey, value: persistedValue }; + }, [activeTabId, availableTabIds, state, storageKey]); + + const commitState = useCallback( + ( + update: (current: SidebarSplitState) => SidebarSplitState, + activateFocusedTab = false, + ) => { + const current = stateRef.current; + const next = update(current); + if (next === current) return current; + stateRef.current = next; + setState(next); + if (activateFocusedTab) { + const focusedGroup = getSidebarGroupForPane( + next, + next.layout.focusedPaneId, + ); + if (focusedGroup !== null && focusedGroup.activeTabId !== activeTabId) { + onActivateTab(focusedGroup.activeTabId); + } + } + return next; + }, + [activeTabId, onActivateTab], + ); + + const selectTab = useCallback( + (paneId: string, tabId: string) => { + commitState((current) => selectSidebarTab(current, paneId, tabId)); + if (tabId !== activeTabId) onActivateTab(tabId); + }, + [activeTabId, commitState, onActivateTab], + ); + + const focusPane = useCallback( + (paneId: string) => { + commitState((current) => focusSidebarPane(current, paneId), true); + }, + [commitState], + ); + + const moveActiveTabToSide = useCallback( + (side: SplitSide) => { + commitState((current) => { + const paneId = current.layout.focusedPaneId; + const sourceGroup = getSidebarGroupForPane(current, paneId); + if (sourceGroup === null) return current; + if (sourceGroup.tabIds.length > 1) { + if (countPanes(current.layout.root) >= MAX_PANES) return current; + return moveSidebarTab( + current, + paneId, + sourceGroup.activeTabId, + { paneId, zone: side }, + { groupId: nextSidebarSplitGroupId(current) }, + ); + } + const rects = computePaneRects(current.layout.root); + const target = listPanes(current.layout.root) + .filter((pane) => pane.paneId !== paneId) + .sort((first, second) => { + const a = rects.get(first.paneId); + const b = rects.get(second.paneId); + if (a === undefined || b === undefined) return 0; + const edge = (rect: typeof a) => { + switch (side) { + case "left": + return rect.x; + case "right": + return -(rect.x + rect.w); + case "top": + return rect.y; + case "bottom": + return -(rect.y + rect.h); + } + }; + return edge(a) - edge(b); + })[0]; + return target === undefined + ? current + : moveSidebarPaneToSide(current, paneId, target.paneId, side); + }, true); + }, + [commitState], + ); + + const moveTab = useCallback( + (sourcePaneId: string, tabId: string, target: SplitDropTarget) => { + const groupId = nextSidebarSplitGroupId(stateRef.current); + commitState( + (current) => + moveSidebarTab(current, sourcePaneId, tabId, target, { + groupId, + }), + true, + ); + }, + [commitState], + ); + + const beginTabDrag = useCallback( + ( + sourcePaneId: string, + tabId: string, + event: ReactPointerEvent, + ) => { + if (event.button !== 0) return; + const sourceGroup = getSidebarGroupForPane(state, sourcePaneId); + const sourceElement = event.currentTarget; + const chrome = sourceElement.closest( + '[data-testid="thread-secondary-panel-top-chrome"]', + ); + const chromeRect = chrome?.getBoundingClientRect() ?? null; + const startX = event.clientX; + const startY = event.clientY; + const label = tabs.find((tab) => tab.id === tabId)?.label ?? "Panel tab"; + beginSplitDrag({ + ghostLabel: label, + sourceEl: sourceElement, + fallback: { + paneId: sourcePaneId, + container: sourceElement.closest("aside"), + }, + cancelSidebarReorderOnEngage: true, + shouldEngage: (x, y) => { + const dx = x - startX; + const dy = y - startY; + if (Math.hypot(dx, dy) <= PANE_DRAG_ENGAGE_DISTANCE_PX) return false; + // Horizontal motion inside the tab row remains the existing reorder + // gesture. Pulling the tab into pane content hands off to split drag. + return ( + Math.abs(dy) > Math.abs(dx) || + chromeRect === null || + y < chromeRect.top || + y > chromeRect.bottom + ); + }, + decide: (targetPaneId, zone) => { + if (targetPaneId === sourcePaneId) { + if (zone === "center" || (sourceGroup?.tabIds.length ?? 0) <= 1) { + return null; + } + } + if ( + zone !== "center" && + (sourceGroup?.tabIds.length ?? 0) > 1 && + countPanes(state.layout.root) >= MAX_PANES + ) { + return null; + } + return { + zone, + label: zone === "center" ? "Group tab here" : `Split ${zone}`, + }; + }, + onDrop: (target) => moveTab(sourcePaneId, tabId, target), + }); + }, + [moveTab, state, tabs], + ); + + const reorderTab = useCallback( + (paneId: string, request: SecondaryPanelTabReorderRequest) => { + commitState((current) => + reorderSidebarTab( + current, + paneId, + request.activeTabId, + request.overTabId, + ), + ); + onGlobalTabReorder(request); + }, + [commitState, onGlobalTabReorder], + ); + + const resize = useCallback( + (path: SplitPath, childIndex: number, fraction: number) => { + commitState((current) => + resizeSidebarSplit(current, path, childIndex, fraction), + ); + }, + [commitState], + ); + + const firstPane = listPanes(state.layout.root)[0]; + const focusedGroup = getSidebarGroupForPane( + state, + state.layout.focusedPaneId, + ); + const canMoveActiveTabToSide = + focusedGroup !== null && + (focusedGroup.tabIds.length > 1 ? paneCount < MAX_PANES : paneCount > 1); + const activeTabPositionHandler = canMoveActiveTabToSide + ? moveActiveTabToSide + : undefined; + if (!hasMultiplePanes && firstPane !== undefined) { + const group = getSidebarGroupForPane(state, firstPane.paneId); + if (group === null) return null; + // renderPane is a synchronous React render callback; its handlers read refs only after pointer events. + // oxlint-disable-next-line react/refs + return renderPane({ + group, + isFocused: true, + isLeftEdge: true, + isTopRow: true, + onBeginTabDrag: (tabId, event) => + beginTabDrag(firstPane.paneId, tabId, event), + onReorderTab: (request) => reorderTab(firstPane.paneId, request), + onFocusPane: () => focusPane(firstPane.paneId), + onMoveActiveTabToSide: activeTabPositionHandler, + onSelectTab: (tabId) => selectTab(firstPane.paneId, tabId), + paneId: firstPane.paneId, + showOuterControls: true, + }); + } + + return ( +
+ + +
+ ); +} + +interface SidebarSplitTreeProps { + dimsInactiveSplits: boolean; + focusedPaneId: string; + isLeftEdge: boolean; + isRightEdge: boolean; + isTopRow: boolean; + node: LayoutNode; + onBeginTabDrag: ( + paneId: string, + tabId: string, + event: ReactPointerEvent, + ) => void; + onFocusPane: (paneId: string) => void; + onMoveActiveTabToSide?: (side: SplitSide) => void; + onReorderTab: ( + paneId: string, + request: SecondaryPanelTabReorderRequest, + ) => void; + onResize: (path: SplitPath, childIndex: number, fraction: number) => void; + onResizeDragChange: (cursor: SidebarSplitResizeCursor | null) => void; + onSelectTab: (paneId: string, tabId: string) => void; + path: number[]; + renderPane: (args: SidebarSplitPaneRenderArgs) => ReactNode; + state: SidebarSplitState; +} + +function SidebarSplitTree(props: SidebarSplitTreeProps) { + if (props.node.type === "pane") { + return ; + } + const node = props.node; + return ( +
+ {node.children.map((child, index) => ( + + {index > 0 ? ( + + props.onResize(props.path, index - 1, fraction) + } + onResizeDragChange={props.onResizeDragChange} + /> + ) : null} +
+ +
+
+ ))} +
+ ); +} + +function SidebarSplitLeaf( + props: SidebarSplitTreeProps & { + pane: Extract; + }, +) { + const { pane } = props; + const groupId = sidebarPaneGroupId(pane); + const group = groupId === null ? undefined : props.state.groups[groupId]; + if (group === undefined) return null; + const isFocused = pane.paneId === props.focusedPaneId; + const showOuterControls = props.isTopRow && props.isRightEdge; + const context: PaneContextValue = { + paneId: pane.paneId, + isFocused, + isSplitPane: true, + secondaryPanelHost: null, + reservesWindowPanelToggle: showOuterControls, + onRequestClose: null, + isMaximized: false, + onToggleMaximize: null, + isBoundedPane: true, + isTopRow: props.isTopRow, + ownsWindowTopLeft: false, + navigateInPane: () => {}, + }; + return ( + +
props.onFocusPane(pane.paneId)} + className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden" + data-split-pane-id={pane.paneId} + data-focused={isFocused ? "true" : "false"} + > +
+ {props.renderPane({ + group, + isFocused, + isLeftEdge: props.isLeftEdge, + isTopRow: props.isTopRow, + onBeginTabDrag: (tabId, event) => + props.onBeginTabDrag(pane.paneId, tabId, event), + onReorderTab: (request) => props.onReorderTab(pane.paneId, request), + onFocusPane: () => props.onFocusPane(pane.paneId), + onMoveActiveTabToSide: props.onMoveActiveTabToSide, + onSelectTab: (tabId) => props.onSelectTab(pane.paneId, tabId), + paneId: pane.paneId, + showOuterControls, + })} +
+
+
+ + ); +} + +function SidebarSplitDivider({ + dir, + onResize, + onResizeDragChange, +}: { + dir: "row" | "col"; + onResize: (fraction: number) => void; + onResizeDragChange: (cursor: SidebarSplitResizeCursor | null) => void; +}) { + const horizontal = dir === "row"; + const finishResizeRef = useRef<(() => void) | null>(null); + useEffect( + () => () => { + finishResizeRef.current?.(); + }, + [], + ); + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + finishResizeRef.current?.(); + const hitTarget = event.currentTarget; + const divider = hitTarget.parentElement; + const previous = divider?.previousElementSibling; + const next = divider?.nextElementSibling; + if ( + !(divider instanceof HTMLElement) || + !(previous instanceof HTMLElement) || + !(next instanceof HTMLElement) + ) { + return; + } + const previousRect = previous.getBoundingClientRect(); + const nextRect = next.getBoundingClientRect(); + const pointerId = event.pointerId; + const start = horizontal ? previousRect.left : previousRect.top; + const end = horizontal ? nextRect.right : nextRect.bottom; + const pointerDownPosition = horizontal ? event.clientX : event.clientY; + const span = end - start; + if (span <= 0) return; + const pair = createSidebarSplitResizePair(previous, next); + hitTarget.setPointerCapture(pointerId); + divider.dataset.dragging = "true"; + let pendingFraction: number | null = null; + let receivedPointerMove = false; + let finished = false; + const applyPointerPosition = (pointerEvent: PointerEvent) => { + const pointer = horizontal + ? pointerEvent.clientX + : pointerEvent.clientY; + const fraction = clampSplitPairFraction((pointer - start) / span); + pendingFraction = fraction; + pair.previous.style.flex = `${pair.total * fraction} 1 0px`; + pair.next.style.flex = `${pair.total * (1 - fraction)} 1 0px`; + }; + const move = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + receivedPointerMove = true; + applyPointerPosition(moveEvent); + }; + const finish = (commit: boolean) => { + if (finished) return; + finished = true; + finishResizeRef.current = null; + delete divider.dataset.dragging; + hitTarget.removeEventListener("pointermove", move); + hitTarget.removeEventListener("pointerup", onUp); + hitTarget.removeEventListener("pointercancel", cancel); + if (hitTarget.hasPointerCapture?.(pointerId)) { + hitTarget.releasePointerCapture(pointerId); + } + onResizeDragChange(null); + if (commit && pendingFraction !== null) { + onResize(pendingFraction); + return; + } + pair.previous.style.flex = pair.previousFlex; + pair.next.style.flex = pair.nextFlex; + }; + const onUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) return; + const pointerUpPosition = horizontal + ? upEvent.clientX + : upEvent.clientY; + if (!receivedPointerMove && pointerUpPosition === pointerDownPosition) { + finish(false); + return; + } + applyPointerPosition(upEvent); + finish(true); + }; + const cancel = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId !== pointerId) return; + finish(false); + }; + hitTarget.addEventListener("pointermove", move); + hitTarget.addEventListener("pointerup", onUp); + hitTarget.addEventListener("pointercancel", cancel); + finishResizeRef.current = () => finish(false); + onResizeDragChange(horizontal ? "col-resize" : "row-resize"); + }, + [horizontal, onResize, onResizeDragChange], + ); + return ( +
+
+
+ ); +} + +interface SidebarSplitResizePair { + next: HTMLElement; + nextFlex: string; + previous: HTMLElement; + previousFlex: string; + total: number; +} + +function createSidebarSplitResizePair( + previous: HTMLElement, + next: HTMLElement, +): SidebarSplitResizePair { + const previousGrow = Number.parseFloat( + window.getComputedStyle(previous).flexGrow, + ); + const nextGrow = Number.parseFloat(window.getComputedStyle(next).flexGrow); + return { + next, + nextFlex: next.style.flex, + previous, + previousFlex: previous.style.flex, + total: + Number.isFinite(previousGrow) && + Number.isFinite(nextGrow) && + previousGrow + nextGrow > 0 + ? previousGrow + nextGrow + : 1, + }; +} + +function nextSidebarSplitGroupId(state: SidebarSplitState): string { + let sequence = 1; + while (state.groups[`group-split-${sequence}`] !== undefined) sequence += 1; + return `group-split-${sequence}`; +} + +function sidebarSplitSubtreeKey(node: LayoutNode): string { + return listPanes(node) + .map((pane) => `${pane.paneId}:${sidebarPaneGroupId(pane) ?? "unknown"}`) + .join("|"); +} diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.fixtures.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.fixtures.tsx index 9fb5ed5a51..fa1055774c 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.fixtures.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.fixtures.tsx @@ -11,12 +11,7 @@ import type { ThreadMetadataContentProps } from "./ThreadMetadataContent"; // Re-export the shared builders so per-row stories in this folder can import // from one place. -export { - makeEnvironment, - makeThread, - makeThreadListEntry, - makeWorkspaceStatus, -}; +export { makeEnvironment, makeThread, makeWorkspaceStatus }; const noop = () => {}; diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx index c7c82cc823..566ec1aba6 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx @@ -249,16 +249,12 @@ export function Branch() { - + ) : ( -
} onClose={noop} onCollapse={noop} - onFileTabReorder={noop} + onTabReorder={noop} onOpenNewTab={noop} - onPanelChange={noop} onPanelFocus={noop} onToggleConversationCollapse={noop} renderAsDrawer={false} @@ -146,15 +493,16 @@ describe("ThreadSecondaryPanel Diff eligibility", () => { { }); }); +// Every right-panel show/hide control has to disclose the edge the panel +// actually opens from, and on a compact viewport that edge is the bottom. +// Each trigger builds its own button, so the glyph is only correct as long as +// every one of them routes through getRightPanelToggleIconName. +describe("ThreadSecondaryPanel hide control glyph", () => { + it("shows the drawer glyph while the panel renders as a bottom drawer", () => { + const view = renderPanel({ + isConversationCollapsed: false, + onToggleConversationCollapse: noop, + renderAsDrawer: true, + }); + + const hideControl = view.getByRole("button", { name: "Hide right panel" }); + expect(hideControl.querySelector('[data-icon="PanelBottom"]')).toBeTruthy(); + }); + + it("shows the side-panel glyph on a wide viewport", () => { + const view = renderPanel({ + isConversationCollapsed: false, + onToggleConversationCollapse: noop, + }); + + const hideControl = view.getByRole("button", { name: "Hide right panel" }); + expect(hideControl.querySelector('[data-icon="PanelRight"]')).toBeTruthy(); + }); +}); + // The full-screen control is the ONLY way back once the conversation is hidden // — there is no standalone rail to click. Pin both halves of the same-slot // expansion pair so a full-screen tab can always restore its prior layout. @@ -221,4 +596,190 @@ describe("ThreadSecondaryPanel full-screen control", () => { fireEvent.click(control); expect(onToggleConversationCollapse).toHaveBeenCalledTimes(1); }); + + it("offers every existing split position from the right-panel control and moves the active tab", () => { + const { wrapper: Wrapper } = createQueryClientTestHarness(); + const onOpenNewTab = vi.fn(); + const fileTab = createWorkspaceFilePreviewFixedPanelTab({ + environmentId: "env-test", + projectId: "project-test", + tab: { + lineRange: null, + path: "src/index.ts", + source: { kind: "working-tree" }, + statusLabel: null, + }, + }); + + render( + + + + + + + + + , + ); + + const control = screen.getByRole("button", { name: "Full Screen" }); + fireEvent.focus(control); + expect( + screen.getByRole("menu", { name: "Pane arrangement" }), + ).not.toBeNull(); + for (const side of ["left", "right", "top", "bottom"] as const) { + expect( + screen.getByRole("menuitem", { name: `Move ${side}` }), + ).not.toBeNull(); + } + + fireEvent.click(screen.getByRole("menuitem", { name: "Move right" })); + const panes = Array.from( + document.querySelectorAll("[data-split-pane-id]"), + ); + expect(panes).toHaveLength(2); + const tabGroups = Array.from( + document.querySelectorAll("[data-sidebar-split-tab-group]"), + ); + expect(tabGroups).toHaveLength(2); + expect(tabGroups[0]?.textContent).toContain("Info"); + expect(tabGroups[1]?.textContent).toContain("index.ts"); + expect( + document.querySelectorAll( + '[data-testid="thread-secondary-panel-top-chrome"]', + ), + ).toHaveLength(2); + expect( + panes.every((pane) => + pane.querySelector('[data-testid="thread-secondary-panel-top-chrome"]'), + ), + ).toBe(true); + expect(document.querySelectorAll("header")).toHaveLength(0); + const newTabControls = screen.getAllByRole("button", { + name: "Open new tab", + }); + expect(newTabControls).toHaveLength(1); + fireEvent.click(newTabControls[0] as HTMLElement); + expect(onOpenNewTab).toHaveBeenCalledTimes(1); + }); + + it("keeps pane-local tab rows and one restore control in a stacked split", () => { + const { wrapper: Wrapper } = createQueryClientTestHarness(); + const fileTab = createWorkspaceFilePreviewFixedPanelTab({ + environmentId: "env-test", + projectId: "project-test", + tab: { + lineRange: null, + path: "src/index.ts", + source: { kind: "working-tree" }, + statusLabel: null, + }, + }); + const panelStateId = "thread-fullscreen-split"; + const initial = createSidebarSplitState( + [createThreadInfoFixedPanelTab().id, fileTab.id], + fileTab.id, + ); + const split = moveSidebarTab( + initial, + initial.layout.focusedPaneId, + fileTab.id, + { paneId: initial.layout.focusedPaneId, zone: "bottom" }, + { groupId: "group-file" }, + ); + window.localStorage.setItem( + sidebarSplitStorageKey(panelStateId), + serializeSidebarSplitState(split), + ); + const onToggleConversationCollapse = vi.fn(); + + render( + + + + + + + + + , + ); + + const restoreControls = screen.getAllByRole("button", { + name: "Exit Full Screen", + }); + expect(restoreControls).toHaveLength(1); + const panes = Array.from( + document.querySelectorAll("[data-split-pane-id]"), + ); + expect(panes).toHaveLength(2); + expect( + panes.map( + (pane) => + pane.querySelector("[data-sidebar-split-tab-group]")?.textContent, + ), + ).toEqual([ + expect.stringContaining("Info"), + expect.stringContaining("index.ts"), + ]); + expect( + panes.map( + (pane) => + pane.querySelectorAll( + '[data-testid="thread-secondary-panel-top-chrome"]', + ).length, + ), + ).toEqual([1, 1]); + expect( + screen + .getByRole("separator", { + name: "Resize stacked right panel panes", + }) + .getAttribute("aria-orientation"), + ).toBe("horizontal"); + expect( + panes.map( + (pane) => + pane.querySelectorAll('[aria-label="Exit Full Screen"]').length, + ), + ).toEqual([1, 0]); + const restoreControl = restoreControls[0]; + if (restoreControl === undefined) + throw new Error("Missing restore control"); + fireEvent.click(restoreControl); + expect(onToggleConversationCollapse).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx index 268f1dd990..7ae05053d0 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx @@ -8,17 +8,27 @@ import { import { PanelGroup } from "react-resizable-panels"; import { ThreadSecondaryPanel, - type SecondaryPanelFileTab, + type SecondaryPanelFixedTab, + type SecondaryPanelRenderableTab, } from "./ThreadSecondaryPanel"; import type { ThreadSecondaryPanel as ThreadSecondaryPanelTab } from "@/lib/thread-secondary-panel"; import { Icon } from "@bb/shared-ui/icon"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { SidebarProvider } from "@/components/ui/sidebar"; import { createGitDiffFixedPanelTab, createTerminalFixedPanelTab, createThreadInfoFixedPanelTab, type HostFilePreviewFixedPanelTab, + type SecondaryFileFixedPanelTab, type SecondaryFixedPanelTab, } from "@/lib/fixed-panel-tabs-state"; +import { + createSidebarSplitState, + moveSidebarTab, + serializeSidebarSplitState, + sidebarSplitStorageKey, +} from "./sidebarSplitLayout"; import type { WorkspaceFile } from "@bb/server-contract"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { @@ -48,13 +58,42 @@ function createStoryFixedPanelTab( : createThreadInfoFixedPanelTab(); } -function createStoryFileTab(filename: string): HostFilePreviewFixedPanelTab { +function createStoryFixedTabs( + onSelectPanel: (panel: ThreadSecondaryPanelTab) => void, + includeGitDiffTab = true, +): readonly SecondaryPanelFixedTab[] { + return [ + { + ariaLabel: "Show thread info panel", + label: "Info", + leadingVisual: , + onSelect: () => onSelectPanel("thread-info"), + tab: createThreadInfoFixedPanelTab(), + title: "Thread info", + }, + ...(includeGitDiffTab + ? [ + { + ariaLabel: "Show diff panel", + label: "Diff", + leadingVisual: , + onSelect: () => onSelectPanel("git-diff"), + tab: createGitDiffFixedPanelTab(), + title: "Diff", + }, + ] + : []), + ]; +} + +function createStoryFileTab(path: string): HostFilePreviewFixedPanelTab { return { environmentId: "env_story", - id: `host-file-preview:${encodeURIComponent(filename)}:thread%3Athr_story%3Aenvironment%3Aenv_story`, + hostId: "host_story", + id: `host-file-preview:${encodeURIComponent(path)}:thread%3Athr_story%3Aenvironment%3Aenv_story`, kind: "host-file-preview", lineRange: null, - path: filename, + path, threadId: "thr_story", }; } @@ -197,13 +236,13 @@ function RepresentativeInfoContent() { interface ShellArgs { initialPanel: ThreadSecondaryPanelTab; - showGitDiffTab?: boolean; + includeGitDiffTab?: boolean; canUseGitUi?: boolean; } function ShellRow({ initialPanel, - showGitDiffTab = true, + includeGitDiffTab = true, canUseGitUi = true, }: ShellArgs) { return ( @@ -217,12 +256,12 @@ function ShellRow({ environmentId={undefined} isOpen metadataContent={} - showGitDiffTab={showGitDiffTab} + fixedTabs={createStoryFixedTabs(setPanel, includeGitDiffTab)} + tabs={[]} onPanelFocus={noop} - onPanelChange={setPanel} onCollapse={noop} onClose={noop} - onFileTabReorder={noop} + onTabReorder={noop} onOpenNewTab={noop} isConversationCollapsed={false} onToggleConversationCollapse={noop} @@ -297,7 +336,6 @@ function FileTabsShellInner({ activeFilename === null ? activeFixedTab : createStoryFileTab(activeFilename); - const activeTabId = activeTab.id; const handleCloseFile = useCallback( (filename: string) => { @@ -308,15 +346,13 @@ function FileTabsShellInner({ [pinnedFilename], ); - const fileTabs = useMemo( + const panelTabs = useMemo( () => openFiles.map((filename) => { const tab = createStoryFileTab(filename); const visual = resolveRightPanelFileVisual({ path: filename }); return { - id: tab.id, - filename, - isActive: tab.id === activeTabId, + label: filename, isPinned: filename === pinnedFilename, leadingVisual: ( @@ -324,9 +360,11 @@ function FileTabsShellInner({ statusLabel: null, onSelect: () => setActiveFilename(filename), onClose: () => handleCloseFile(filename), + renderContent: () => representativeFileContent, + tab, }; }), - [openFiles, activeTabId, handleCloseFile, pinnedFilename], + [openFiles, handleCloseFile, pinnedFilename], ); return ( @@ -338,17 +376,15 @@ function FileTabsShellInner({ environmentId={undefined} isOpen metadataContent={} - fileTabs={fileTabs} - fileTabContent={activeFilename ? representativeFileContent : null} - showGitDiffTab - onPanelFocus={noop} - onPanelChange={(panel) => { + tabs={panelTabs} + fixedTabs={createStoryFixedTabs((panel) => { setActiveFilename(null); setActiveFixedTab(createStoryFixedPanelTab(panel)); - }} + })} + onPanelFocus={noop} onCollapse={noop} onClose={noop} - onFileTabReorder={noop} + onTabReorder={noop} onOpenNewTab={noop} isConversationCollapsed={false} onToggleConversationCollapse={noop} @@ -395,7 +431,6 @@ function TerminalTabsShellInner({ : createTerminalFixedPanelTab({ terminalId: activeTerminal.terminalId, }); - const activeTabId = activeTab.id; const handleCloseTerminal = useCallback( (terminalId: string) => { @@ -415,25 +450,28 @@ function TerminalTabsShellInner({ [openTerminals], ); - const fileTabs = useMemo( + const panelTabs = useMemo( () => openTerminals.map((terminal) => { const tab = createTerminalFixedPanelTab({ terminalId: terminal.terminalId, }); return { - id: tab.id, - filename: terminal.title, - isActive: tab.id === activeTabId, + contentFillsRegion: true, + label: terminal.title, leadingVisual: ( ), statusLabel: terminal.statusLabel, onSelect: () => setActiveTerminalId(terminal.terminalId), onClose: () => handleCloseTerminal(terminal.terminalId), + renderContent: () => ( + + ), + tab, }; }), - [activeTabId, handleCloseTerminal, openTerminals], + [handleCloseTerminal, openTerminals], ); return ( @@ -445,21 +483,15 @@ function TerminalTabsShellInner({ environmentId={undefined} isOpen metadataContent={} - fileTabs={fileTabs} - fileTabContent={ - activeTerminal ? ( - - ) : null - } - showGitDiffTab - onPanelFocus={noop} - onPanelChange={(panel) => { + tabs={panelTabs} + fixedTabs={createStoryFixedTabs((panel) => { setActiveTerminalId(""); setActiveFixedTab(createStoryFixedPanelTab(panel)); - }} + })} + onPanelFocus={noop} onCollapse={noop} onClose={noop} - onFileTabReorder={noop} + onTabReorder={noop} onOpenNewTab={noop} isConversationCollapsed={false} onToggleConversationCollapse={noop} @@ -475,6 +507,124 @@ function TerminalTabsShellRow(props: TerminalTabsShellRowProps) { return ; } +const SPLIT_STORY_PANEL_STATE_ID = "ladle-production-split-panes"; +const SPLIT_STORY_FILE = createStoryFileTab("ThreadSecondaryPanel.tsx"); +const SPLIT_STORY_TERMINAL = createTerminalFixedPanelTab({ + terminalId: "term_story_running", +}); +const SPLIT_STORY_FILE_TABS: readonly SecondaryFileFixedPanelTab[] = [ + SPLIT_STORY_FILE, + SPLIT_STORY_TERMINAL, +]; +const SPLIT_STORY_TABS: readonly SecondaryFixedPanelTab[] = [ + createThreadInfoFixedPanelTab(), + createGitDiffFixedPanelTab(), + ...SPLIT_STORY_FILE_TABS, +]; + +function createSplitStoryState() { + let state = createSidebarSplitState( + SPLIT_STORY_TABS.map((tab) => tab.id), + createThreadInfoFixedPanelTab().id, + ); + state = moveSidebarTab( + state, + "pane-primary", + SPLIT_STORY_FILE.id, + { paneId: "pane-primary", zone: "bottom" }, + { groupId: "group-file" }, + ); + return moveSidebarTab( + state, + "pane-primary", + SPLIT_STORY_TERMINAL.id, + { paneId: "pane-primary", zone: "right" }, + { groupId: "group-terminal" }, + ); +} + +function ProductionSplitPanesStory() { + const [activeTab, setActiveTab] = useState(() => { + window.localStorage.setItem( + sidebarSplitStorageKey(SPLIT_STORY_PANEL_STATE_ID), + serializeSidebarSplitState(createSplitStoryState()), + ); + return SPLIT_STORY_TERMINAL; + }); + + const panelTabs = useMemo( + () => + [SPLIT_STORY_FILE, SPLIT_STORY_TERMINAL].map((tab) => ({ + contentFillsRegion: tab.kind === "terminal", + label: + tab.kind === "terminal" ? "pnpm dev" : "ThreadSecondaryPanel.tsx", + leadingVisual: ( + + ), + statusLabel: null, + onSelect: () => setActiveTab(tab), + onClose: noop, + renderContent: () => + tab.kind === "terminal" ? ( + + ) : ( + representativeFileContent + ), + tab, + })), + [], + ); + + return ( + + + + } + tabs={panelTabs} + fixedTabs={createStoryFixedTabs((panel) => + setActiveTab(createStoryFixedPanelTab(panel)), + )} + splitPanelStateId={SPLIT_STORY_PANEL_STATE_ID} + onPanelFocus={noop} + onCollapse={noop} + onClose={noop} + onTabReorder={noop} + onOpenNewTab={noop} + isConversationCollapsed={false} + onToggleConversationCollapse={noop} + renderAsDrawer={false} + inlinePanelToggle="hidden" + showConversationCollapseControl={false} + /> + + + + ); +} + +export function SplitPanes() { + return ( + + + + + + ); +} + export function Overview() { return ( @@ -488,7 +638,7 @@ export function Overview() { label="parent thread, info tab" hint="no Diff for this parent thread; workspace tree is rendered inside the info tab body" > - + { it("uses the same sidebar background token as the primary sidebar", () => { @@ -23,15 +18,6 @@ describe("secondary panel surface tone", () => { }); }); -describe("secondary panel hide control", () => { - it("uses the existing collapse affordance in the compact drawer", () => { - expect(resolveSecondaryPanelHideControl()).toEqual({ - iconName: "PanelRight", - label: "Hide right panel", - }); - }); -}); - describe("secondary panel native browser bounds settling", () => { it("recognizes the flex transitions that move the panel back to its restored position", () => { expect(isSecondaryPanelLayoutTransition("flex-grow")).toBe(true); @@ -50,18 +36,6 @@ describe("getSecondaryPanelChromeStackClassName", () => { expect(className).not.toContain(CHROME_ROW_HEIGHT_CLASS); expect(CHROME_ROW_CLASS).toContain(CHROME_ROW_HEIGHT_CLASS); }); - - it("matches adjacent page-header chrome when requested", () => { - const className = getSecondaryPanelChromeStackClassName(false, "page"); - - for (const token of APP_PAGE_HEADER_SURFACE_CLASS.split(/\s+/)) { - expect(className).toContain(token); - } - for (const token of HEADER_SEAM_CLASS.split(/\s+/)) { - expect(className).toContain(token); - } - expect(className).not.toContain(SECONDARY_PANEL_TOP_CHROME_BACKGROUND_CLASS); - }); }); // The reserved inline-toggle slot sits under root compose's pinned right-panel diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx index 5fb121ff03..78cc4260a2 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx @@ -1,6 +1,7 @@ import { type CSSProperties, type FocusEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, type TransitionEvent, useCallback, @@ -15,12 +16,8 @@ import { Icon } from "@bb/shared-ui/icon"; import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; import { Panel, PanelResizeHandle } from "react-resizable-panels"; import { Button } from "@bb/shared-ui/button"; -import { - APP_PAGE_HEADER_SURFACE_CLASS, - HEADER_PANE_ACTION_ICON_BUTTON_CLASS, - HEADER_SEAM_CLASS, -} from "@/components/layout/AppPageHeader"; -import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@/components/ui/chromeStyleTokens"; +import { HEADER_PANE_ACTION_ICON_BUTTON_CLASS } from "@/components/layout/AppPageHeader"; +import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { COARSE_POINTER_COMPACT_ICON_BUTTON_CLASS, COARSE_POINTER_HEADER_ICON_BUTTON_CLASS, @@ -38,20 +35,23 @@ import { THREAD_SECONDARY_PANEL_MAX_SIZE_PERCENT, THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT, } from "./secondaryPanelSizing"; -import { resolveConversationCollapseControl } from "./panelToggleControlState"; +import { + getRightPanelToggleIconName, + resolveConversationCollapseControl, +} from "./panelToggleControlState"; import { SecondaryPanelHostLayoutContext } from "./SecondaryPanelHostLayoutContext"; import { SecondaryPanelTabStrip } from "./SecondaryPanelTabStrip"; import type { - SecondaryPanelFileTab, + SecondaryPanelPaneRenderContext, + SecondaryPanelRenderableTab, SecondaryPanelTabReorderHandler, -} from "./secondaryPanelFileTab"; -import { GIT_DIFF_VIEW_BASE_OPTIONS } from "../git-diff/GitDiffCard"; -import { usePreferredTheme } from "@/hooks/useTheme"; +} from "./secondaryPanelTab"; import { useEnvironmentDiffFiles } from "@/hooks/queries/environment-queries"; import { DEFAULT_CODE_OVERFLOW_MODE, type CodeOverflowMode, } from "@/lib/code-overflow-mode"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import { useGitDiffPanelState } from "./git-diff/useGitDiffPanelState"; import { useResponsiveGitDiffPanelDisplay } from "./git-diff/useResponsiveGitDiffPanelDisplay"; import { @@ -65,10 +65,7 @@ import { } from "./useSecondaryPanelResize"; import { threadSecondaryPanelResizingAtom } from "./threadSecondaryPanelAtoms"; import { GitDiffToolbar } from "./GitDiffToolbar"; -import { - GitDiffTabContent, - ThreadInfoTabContent, -} from "./ThreadSecondaryPanelTabContent"; +import { GitDiffTabContent } from "./ThreadSecondaryPanelTabContent"; import { CHROME_ROW_CLASS, getBbDesktopInfo, @@ -83,27 +80,29 @@ import { import { useDesktopWindowState } from "@/hooks/useDesktopWindowState"; import { useOptionalIsSidebarShowing } from "@/components/ui/sidebar.js"; import { IframeDragGuardOverlay } from "@/lib/iframe-drag-guard"; -import type { - FixedPanelViewTab, - SecondaryFixedPanelTab, -} from "@/lib/fixed-panel-tabs-state"; import { - createGitDiffFixedPanelTab, - createThreadInfoFixedPanelTab, + type FixedPanelViewTab, + type SecondaryFixedPanelTab, } from "@/lib/fixed-panel-tabs-state"; -import { type ThreadSecondaryPanel as ThreadSecondaryPanelTab } from "@/lib/thread-secondary-panel"; import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider"; import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint"; import type { AppShortcutPresentation } from "@/lib/app-keybindings"; import { TabPill } from "@/components/ui/tab-pill"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { dispatchBrowserViewBoundsSync } from "@/lib/browser-view-bounds-sync"; +import type { SplitSide } from "@/lib/split-layout"; +import { PaneArrangementButton } from "@/views/thread-detail/PaneMaximizeButton"; +import { + SidebarSplitContainer, + type SidebarSplitPaneRenderArgs, + type SidebarSplitTabDescriptor, +} from "./SidebarSplitContainer"; +import { SIDEBAR_FIXED_INFO_TAB_ID } from "./sidebarSplitLayout"; import type { GitDiffTabStatus } from "./gitDiffTabEligibility"; export type { - GitDiffDisplayMode, - GitDiffSelectionOption, -} from "./GitDiffToolbar"; -export type { SecondaryPanelFileTab } from "./secondaryPanelFileTab"; + SecondaryPanelPaneRenderContext, + SecondaryPanelRenderableTab, +} from "./secondaryPanelTab"; export function isSecondaryPanelLayoutTransition( propertyName: string, @@ -148,14 +147,11 @@ export function getReservedInlinePanelToggleClassName( */ export function getSecondaryPanelChromeStackClassName( hasGitDiffToolbar: boolean, - surface: "panel" | "page" = "panel", ): string { return cn( - "shrink-0", + "shrink-0 select-none", hasGitDiffToolbar && "flex flex-col", - surface === "page" - ? cn(APP_PAGE_HEADER_SURFACE_CLASS, HEADER_SEAM_CLASS) - : SECONDARY_PANEL_TOP_CHROME_BACKGROUND_CLASS, + SECONDARY_PANEL_TOP_CHROME_BACKGROUND_CLASS, ); } @@ -211,18 +207,15 @@ export function resolveCollapsedPanelTrafficLightReserveClassName({ return reserves && MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS; } -export function resolveSecondaryPanelHideControl() { - return { - iconName: "PanelRight" as const, - label: "Hide right panel", - }; -} +const HIDE_PANEL_LABEL = "Hide right panel"; export interface SecondaryPanelFixedTab { ariaLabel: string; + contentFillsRegion?: boolean; label: string; leadingVisual: ReactNode; onSelect: () => void; + renderContent?: (pane: SecondaryPanelPaneRenderContext) => ReactNode; tab: FixedPanelViewTab; title: string; } @@ -235,42 +228,23 @@ export interface ThreadSecondaryPanelProps { requestedMergeBaseBranch?: string; environmentId?: string; metadataContent: ReactNode; - fileTabs?: SecondaryPanelFileTab[]; - fileTabContent?: ReactNode; - fixedTabs?: readonly SecondaryPanelFixedTab[]; - fixedTabContent?: ReactNode; - fixedTabContentFillsRegion?: boolean; + tabs: readonly SecondaryPanelRenderableTab[]; + fixedTabs: readonly SecondaryPanelFixedTab[]; + onTabReorder: SecondaryPanelTabReorderHandler; /** - * True when the active file tab's content owns its own layout and - * scrolling (terminal-style): the slot then provides only a definite - * height instead of the padded scroll container. Set for plugin panel - * tabs registered with `layout: "flush"`. + * Builds the browser surface for the active browser tab. The unsplit + * fallback also calls this with `null` so its retained deck can hide native + * views while another tab is active. */ - fileTabContentFillsRegion?: boolean; - onFileTabReorder: SecondaryPanelTabReorderHandler; - /** - * The browser-tab deck slot. Rendered in the content region so the deck can - * own browser-view visibility and retention; absent on the web build / in - * tests with no browser tabs. - */ - browserDeck?: ReactNode; - /** - * Whether the active panel tab is a browser tab. When true the deck fills the - * content region and the normal content slot is suppressed. - */ - isBrowserTabActive?: boolean; + renderBrowserDeck?: ( + activeBrowserTabId: string | null, + pane: SecondaryPanelPaneRenderContext, + ) => ReactNode; + /** Stable thread/panel id enabling persisted tab tear-out splits. */ + splitPanelStateId?: string; isOpen: boolean; showConversationCollapseControl?: boolean; - /** Legacy thread-surface inputs normalized into `fixedTabs`. */ - showGitDiffTab?: boolean; - showInfoTab?: boolean; showNewTabButton?: boolean; - /** - * Use the app page-header surface when this panel's top row is a direct - * sibling of a page header. The default keeps thread panels on sidebar - * chrome. - */ - topChromeSurface?: "panel" | "page"; /** * How the panel's own inline hide control (top chrome, trailing edge) renders * on the wide layout: @@ -294,10 +268,6 @@ export interface ThreadSecondaryPanelProps { */ resizablePanelId?: string; onPanelFocus: () => void; - /** Reports the panel's live percentage while it resizes. */ - onPanelResize?: (sizePercent: number) => void; - /** Legacy thread-surface selector normalized into `fixedTabs`. */ - onPanelChange?: (panel: ThreadSecondaryPanelTab) => void; onCollapse: () => void; onClose: () => void; onClearPendingGitDiffIntent?: () => void; @@ -336,26 +306,17 @@ export function ThreadSecondaryPanel({ requestedMergeBaseBranch, environmentId, metadataContent, - fileTabs, - fileTabContent, + tabs, fixedTabs, - fixedTabContent, - fixedTabContentFillsRegion = false, - fileTabContentFillsRegion, - onFileTabReorder, - browserDeck, - isBrowserTabActive = false, + onTabReorder, + renderBrowserDeck, + splitPanelStateId, isOpen, showConversationCollapseControl = true, - showGitDiffTab = true, - showInfoTab = true, showNewTabButton = true, - topChromeSurface = "panel", inlinePanelToggle = "button", resizablePanelId = "thread-detail-secondary-panel", onPanelFocus, - onPanelResize, - onPanelChange, onCollapse, onClose, onClearPendingGitDiffIntent, @@ -376,51 +337,13 @@ export function ThreadSecondaryPanel({ const newTabShortcut = useAppCommandShortcut("panel.newTab"); const togglePanelShortcut = useAppCommandShortcut("panel.toggle"); const diffShortcut = useAppCommandShortcut("diff.toggle"); - const activeFileTab = fileTabs?.find((tab) => tab.isActive); - const visibleFileTabs = useMemo( - () => fileTabs?.filter((tab) => tab.isHidden !== true), - [fileTabs], + const activeRenderableTab = tabs.find((tab) => tab.tab.id === activeTab?.id); + const visibleTabs = useMemo( + () => tabs.filter((tab) => tab.isHidden !== true), + [tabs], ); - const hasActiveFileTab = activeFileTab !== undefined; - const isTerminalTabActive = - activeTab?.kind === "terminal" && hasActiveFileTab; - const hideControl = resolveSecondaryPanelHideControl(); - const resolvedFixedTabs = useMemo(() => { - if (fixedTabs !== undefined) return fixedTabs; - const selectThreadPanel = onPanelChange ?? (() => undefined); - return [ - ...(showInfoTab - ? [ - { - ariaLabel: "Show thread info panel", - label: "Info", - leadingVisual: , - onSelect: () => selectThreadPanel("thread-info"), - tab: createThreadInfoFixedPanelTab(), - title: "Thread info", - }, - ] - : []), - ...(resolvedGitDiffTabStatus !== "ineligible" && showGitDiffTab - ? [ - { - ariaLabel: "Show diff panel", - label: "Diff", - leadingVisual: , - onSelect: () => selectThreadPanel("git-diff"), - tab: createGitDiffFixedPanelTab(), - title: "Diff", - }, - ] - : []), - ]; - }, [ - fixedTabs, - onPanelChange, - resolvedGitDiffTabStatus, - showGitDiffTab, - showInfoTab, - ]); + const hasActiveRenderableTab = activeRenderableTab !== undefined; + const hidePanelIconName = getRightPanelToggleIconName(renderAsDrawer); // The conversation-collapse toggle only exists on a wide viewport; the drawer // layout fills the screen and cannot collapse the conversation. const conversationCollapseControl = @@ -466,11 +389,10 @@ export function ThreadSecondaryPanel({ (size: number) => { if (size > 0) { hasPanelExpandedRef.current = true; - onPanelResize?.(size); } handleSecondaryPanelResize(size); }, - [handleSecondaryPanelResize, onPanelResize], + [handleSecondaryPanelResize], ); const hostLayout = useContext(SecondaryPanelHostLayoutContext); const handlePanelCollapse = useCallback(() => { @@ -507,22 +429,16 @@ export function ThreadSecondaryPanel({ const isLayoutOpen = (hostLayout?.isOpen ?? isOpen) && !hostLayout?.isSuppressed; const activeFixedTab = - resolvedFixedTabs.find((fixedTab) => fixedTab.tab.id === activeTab?.id) ?? - (!hasActiveFileTab ? resolvedFixedTabs[0] : undefined); + fixedTabs.find((fixedTab) => fixedTab.tab.id === activeTab?.id) ?? + (!hasActiveRenderableTab ? fixedTabs[0] : undefined); const isDiffPanelActive = resolvedGitDiffTabStatus === "eligible" && activeFixedTab?.tab.kind === "git-diff"; - // The diff body stays mounted while the panel is closed (see the retained - // content note below), but its live queries must not: every workspace write - // invalidates the diff TOC, evicts patches, and would otherwise refetch and - // re-render pierre into an off-screen panel. Gate the diff-tab data on the - // panel actually being open; the DOM stays, the network and diff work stop. const isDiffPanelLive = isDiffPanelActive && isLayoutOpen; const isDiffEligibilityPending = activeFixedTab?.tab.kind === "git-diff" && (resolvedGitDiffTabStatus === "loading" || resolvedGitDiffTabStatus === "error"); - const showsGitDiffToolbar = isDiffPanelActive && !hasActiveFileTab; // Keep file content mounted across every close. The compact views defer the // first full panel mount, then retain it inside their persistent drawer. // Removing only this subtree would lose terminal and plugin state and move @@ -604,15 +520,13 @@ export function ThreadSecondaryPanel({ windowState: desktopWindowState, }), }); - const preferredTheme = usePreferredTheme(); - const gitDiffViewOptions = useMemo( + const gitDiffPresentation = useMemo( () => ({ - ...GIT_DIFF_VIEW_BASE_OPTIONS, - diffStyle: gitDiffDisplayMode, + view: gitDiffDisplayMode, overflow: gitDiffLineOverflowMode, - themeType: preferredTheme, + showLineNumbers: true, }), - [gitDiffDisplayMode, gitDiffLineOverflowMode, preferredTheme], + [gitDiffDisplayMode, gitDiffLineOverflowMode], ); const handlePanelFocusCapture = (event: FocusEvent) => { const previousTarget = event.relatedTarget; @@ -625,6 +539,489 @@ export function ThreadSecondaryPanel({ onPanelFocus(); }; + interface PanelSurfaceArgs { + activeSurfaceFixedTab: SecondaryPanelFixedTab | undefined; + activeSurfaceTabId: string | null; + surfaceTabs: readonly SecondaryPanelRenderableTab[]; + fixedSurfaceTabs: readonly SecondaryPanelFixedTab[]; + isFocused: boolean; + isSurfaceDiffEligibilityPending: boolean; + onBeginTabDrag?: ( + tabId: string, + event: ReactPointerEvent, + ) => void; + onMoveActiveTabToSide?: (side: SplitSide) => void; + onFocusPane: () => void; + onSurfaceTabReorder: SecondaryPanelTabReorderHandler; + paneId: string | null; + reserveLeadingChrome: boolean; + showNewTabControl: boolean; + showOuterControls: boolean; + usesPaneArrangementControl: boolean; + usesWindowChrome: boolean; + } + + interface PanelTabGroupArgs { + activeSurfaceFixedTab: SecondaryPanelFixedTab | undefined; + activeSurfaceTabId: string | null; + surfaceTabs: readonly SecondaryPanelRenderableTab[]; + fixedSurfaceTabs: readonly SecondaryPanelFixedTab[]; + onBeginTabDrag?: ( + tabId: string, + event: ReactPointerEvent, + ) => void; + onSurfaceTabReorder: SecondaryPanelTabReorderHandler; + showNewTabButton: boolean; + } + + const renderHidePanelButton = () => ( + + ); + + const renderConversationCollapseButton = ({ + onMoveActiveTabToSide, + usesPaneArrangementControl, + }: { + onMoveActiveTabToSide?: (side: SplitSide) => void; + usesPaneArrangementControl: boolean; + }) => { + if (conversationCollapseControl === null) return null; + if (usesPaneArrangementControl) { + return ( + + ); + } + return ( + + + + + {conversationCollapseControl.label} + + ); + }; + + const renderPanelTabGroup = ({ + activeSurfaceFixedTab, + activeSurfaceTabId, + surfaceTabs, + fixedSurfaceTabs, + onBeginTabDrag, + onSurfaceTabReorder, + showNewTabButton: showGroupNewTabButton, + }: PanelTabGroupArgs) => { + const activeSurfaceTab = surfaceTabs.find( + (tab) => tab.tab.id === activeSurfaceTabId, + ); + const visibleSurfaceTabs = surfaceTabs.filter( + (tab) => tab.isHidden !== true, + ); + const hasActiveSurfaceTab = activeSurfaceTab !== undefined; + + return ( + <> + {fixedSurfaceTabs.map((fixedTab) => { + const shortcut = + fixedTab.tab.kind === "git-diff" ? diffShortcut : null; + return ( + onBeginTabDrag(fixedTab.tab.id, event) + : undefined + } + title={fixedTab.title} + usesDesktopChrome={usesDesktopChrome} + /> + ); + })} + {visibleSurfaceTabs.length > 0 ? ( + + ) : null} + {showGroupNewTabButton ? ( + + ) : null} + + ); + }; + + const renderPanelSurface = ({ + activeSurfaceFixedTab, + activeSurfaceTabId, + surfaceTabs, + fixedSurfaceTabs, + isFocused, + isSurfaceDiffEligibilityPending, + onBeginTabDrag, + onFocusPane, + onMoveActiveTabToSide, + onSurfaceTabReorder, + paneId, + reserveLeadingChrome, + showNewTabControl, + showOuterControls, + usesPaneArrangementControl, + usesWindowChrome, + }: PanelSurfaceArgs) => { + const activeSurfaceTab = + surfaceTabs.find((tab) => tab.tab.id === activeSurfaceTabId) ?? null; + const activeSurfaceModel = activeSurfaceTab?.tab ?? null; + const hasActiveSurfaceTab = activeSurfaceTab !== null; + const paneRenderContext = { isFocused, onFocusPane }; + const isBrowserSurfaceActive = activeSurfaceModel?.kind === "browser"; + const browserSurface = + renderBrowserDeck === undefined || + (paneId !== null && !isBrowserSurfaceActive) + ? null + : renderBrowserDeck( + isBrowserSurfaceActive ? activeSurfaceModel.id : null, + paneRenderContext, + ); + const surfaceContent = + activeSurfaceTab === null || isBrowserSurfaceActive + ? null + : activeSurfaceTab.renderContent(paneRenderContext); + const surfaceContentFillsRegion = + activeSurfaceTab?.contentFillsRegion === true; + const fixedSurfaceContent = + activeSurfaceFixedTab?.renderContent?.(paneRenderContext); + const fixedSurfaceContentFillsRegion = + activeSurfaceFixedTab?.contentFillsRegion === true; + const isSurfaceDiffActive = + activeSurfaceFixedTab?.tab.kind === "git-diff" && + resolvedGitDiffTabStatus === "eligible"; + const showsSurfaceDiffToolbar = isSurfaceDiffActive && !hasActiveSurfaceTab; + const isSurfaceTerminalActive = + activeSurfaceModel?.kind === "terminal" && hasActiveSurfaceTab; + + return ( + <> +
+
+
+ {renderPanelTabGroup({ + activeSurfaceFixedTab, + activeSurfaceTabId, + surfaceTabs, + fixedSurfaceTabs, + onBeginTabDrag, + onSurfaceTabReorder, + showNewTabButton: showNewTabControl, + })} +
+ {showOuterControls ? ( +
event.stopPropagation()} + > + {renderConversationCollapseButton({ + onMoveActiveTabToSide, + usesPaneArrangementControl, + })} + {renderAsDrawer || inlinePanelToggle === "button" ? ( + renderHidePanelButton() + ) : inlinePanelToggle === "reserved" ? ( +
+ ) : null} +
+ ) : null} +
+ {showsSurfaceDiffToolbar ? ( + + ) : null} +
+
+ {browserSurface} + {isBrowserSurfaceActive ? null : hasActiveSurfaceTab ? ( +
+ {surfaceContent ?? ( + + No file preview content provided. + + )} +
+ ) : activeSurfaceFixedTab !== undefined && + fixedSurfaceContent !== undefined ? ( +
+ {fixedSurfaceContent} +
+ ) : isSurfaceDiffEligibilityPending ? ( + + {resolvedGitDiffTabStatus === "error" ? ( +
+ + Could not determine whether this workspace uses Git. + + {onRetryGitDiffEligibility ? ( + + ) : null} +
+ ) : ( + "Checking Git support…" + )} +
+ ) : isSurfaceDiffActive ? ( + + ) : activeSurfaceFixedTab?.tab.kind === "thread-info" ? ( +
+ {metadataContent} +
+ ) : ( + + This panel view is unavailable. + + )} +
+ + ); + }; + + const shouldEnableSidebarSplits = + !renderAsDrawer && splitPanelStateId !== undefined; + const splitTabs = shouldEnableSidebarSplits + ? ([ + ...fixedTabs.map((fixedTab) => ({ + id: fixedTab.tab.id, + label: fixedTab.label, + })), + ...visibleTabs.map((tab) => ({ + id: tab.tab.id, + label: tab.label, + })), + ] satisfies SidebarSplitTabDescriptor[]) + : []; + const globalActiveTabId = + activeRenderableTab?.tab.id ?? + activeFixedTab?.tab.id ?? + fixedTabs[0]?.tab.id ?? + SIDEBAR_FIXED_INFO_TAB_ID; + const resolveSplitPaneTabs = (pane: SidebarSplitPaneRenderArgs) => + pane.group.tabIds + .map((tabId) => tabs.find((tab) => tab.tab.id === tabId)) + .filter((tab): tab is SecondaryPanelRenderableTab => tab !== undefined) + .map((tab) => ({ + ...tab, + onSelect: () => pane.onSelectTab(tab.tab.id), + })); + const panelSurface = shouldEnableSidebarSplits ? ( + { + const fixedTab = fixedTabs.find( + (candidate) => candidate.tab.id === tabId, + ); + if (fixedTab !== undefined) fixedTab.onSelect(); + else tabs.find((tab) => tab.tab.id === tabId)?.onSelect(); + }} + onGlobalTabReorder={onTabReorder} + panelStateId={splitPanelStateId} + tabs={splitTabs} + renderPane={(pane: SidebarSplitPaneRenderArgs) => { + const activePaneTabId = pane.group.activeTabId; + const paneTabs = resolveSplitPaneTabs(pane); + const paneFixedTabs = fixedTabs + .filter((fixedTab) => pane.group.tabIds.includes(fixedTab.tab.id)) + .map((fixedTab) => ({ + ...fixedTab, + onSelect: () => pane.onSelectTab(fixedTab.tab.id), + })); + const activePaneFixedTab = paneFixedTabs.find( + (fixedTab) => fixedTab.tab.id === activePaneTabId, + ); + return renderPanelSurface({ + activeSurfaceFixedTab: activePaneFixedTab, + activeSurfaceTabId: activePaneTabId, + surfaceTabs: paneTabs, + fixedSurfaceTabs: paneFixedTabs, + isFocused: pane.isFocused, + isSurfaceDiffEligibilityPending: + activePaneFixedTab?.tab.kind === "git-diff" && + (resolvedGitDiffTabStatus === "loading" || + resolvedGitDiffTabStatus === "error"), + onBeginTabDrag: pane.onBeginTabDrag, + onFocusPane: pane.onFocusPane, + onMoveActiveTabToSide: pane.onMoveActiveTabToSide, + onSurfaceTabReorder: pane.onReorderTab, + paneId: pane.paneId, + reserveLeadingChrome: pane.isTopRow && pane.isLeftEdge, + showNewTabControl: pane.showOuterControls && showNewTabButton, + showOuterControls: pane.showOuterControls, + usesPaneArrangementControl: true, + usesWindowChrome: pane.isTopRow, + }); + }} + /> + ) : ( + renderPanelSurface({ + activeSurfaceFixedTab: activeFixedTab, + activeSurfaceTabId: activeTab?.id ?? null, + surfaceTabs: tabs, + fixedSurfaceTabs: fixedTabs, + isFocused: true, + isSurfaceDiffEligibilityPending: isDiffEligibilityPending, + onFocusPane: onPanelFocus, + onSurfaceTabReorder: onTabReorder, + paneId: null, + reserveLeadingChrome: true, + showNewTabControl: showNewTabButton, + showOuterControls: true, + usesPaneArrangementControl: false, + usesWindowChrome: true, + }) + ); + const asideMarkup = ( ); @@ -1005,25 +1154,25 @@ interface NewTabButtonProps { } interface PinnedIconTabProps { - activeTreatment: "fill" | "underline"; ariaLabel: string; ariaKeyshortcuts?: string; isActive: boolean; label: string; leadingVisual: ReactNode; onClick: () => void; + onPointerDown?: (event: ReactPointerEvent) => void; title: string; usesDesktopChrome: boolean; } function PinnedIconTab({ - activeTreatment, ariaLabel, ariaKeyshortcuts, isActive, label, leadingVisual, onClick, + onPointerDown, title, usesDesktopChrome, }: PinnedIconTabProps) { @@ -1036,6 +1185,7 @@ function PinnedIconTab({ "shrink-0", usesDesktopChrome && MACOS_WINDOW_NO_DRAG_CLASS, )} + onPointerDown={onPointerDown} > diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx index 5e8b3f8190..9fe99fa948 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx @@ -14,7 +14,7 @@ import { threadStoragePathsQueryKey, } from "@/hooks/queries/query-keys"; import { ThreadSecondaryPanel } from "./ThreadSecondaryPanel"; -import type { SecondaryPanelFileTab } from "./ThreadSecondaryPanel"; +import type { SecondaryPanelRenderableTab } from "./ThreadSecondaryPanel"; import { NewTabPage } from "./NewTabPage"; import type { FileSearchSelection } from "./useThreadFileTabs"; import { Icon } from "@bb/shared-ui/icon"; @@ -25,7 +25,7 @@ import { import { createNewTabFixedPanelTab, createTerminalFixedPanelTab, - type SecondaryFixedPanelTab, + type SecondaryFileFixedPanelTab, } from "@/lib/fixed-panel-tabs-state"; import { getFileNameFromPath, @@ -76,16 +76,6 @@ const BUILD_SERVER: Host = { const noop = () => {}; -const NEW_TAB: SecondaryPanelFileTab = { - id: "new-tab", - filename: "New tab", - isActive: true, - leadingVisual: , - statusLabel: null, - onSelect: noop, - onClose: noop, -}; - const WORKSPACE_PATH_RESULTS: WorkspacePathEntry[] = [ { kind: "file", @@ -249,7 +239,7 @@ type NewTabStoryOutcome = function createStoryActiveTab( outcome: NewTabStoryOutcome | null, currentThreadId: string, -): SecondaryFixedPanelTab { +): SecondaryFileFixedPanelTab { if (outcome === null) { return createNewTabFixedPanelTab(); } @@ -485,62 +475,6 @@ function NewTabPanelStory({ setOutcome(null); }, []); const activeTab = createStoryActiveTab(outcome, currentThreadId); - const fileTabs = useMemo(() => { - if (outcome === null) { - return [NEW_TAB]; - } - if (outcome.kind === "browser") { - return [ - { - id: "browser", - filename: "Browser", - isActive: true, - leadingVisual: , - statusLabel: null, - onSelect: noop, - onClose: () => setOutcome(null), - }, - ]; - } - if (outcome.kind === "terminal") { - const terminalTab = createTerminalFixedPanelTab({ - terminalId: STORY_TERMINAL_ID, - }); - return [ - { - id: terminalTab.id, - filename: "Terminal", - isActive: true, - leadingVisual: ( - - ), - statusLabel: null, - onSelect: noop, - onClose: () => setOutcome(null), - }, - ]; - } - const { selection } = outcome; - return [ - { - id: `${selection.source}:${selection.path}`, - filename: getFileNameFromPath({ path: selection.path }), - isActive: true, - leadingVisual: ( - - ), - statusLabel: null, - onSelect: noop, - onClose: () => setOutcome(null), - }, - ]; - }, [outcome]); const content = outcome === null ? ( @@ -598,6 +532,39 @@ function NewTabPanelStory({

); + const panelTab: SecondaryPanelRenderableTab = { + contentFillsRegion: outcome?.kind === "terminal", + label: + outcome === null + ? "New tab" + : outcome.kind === "browser" + ? "Browser" + : outcome.kind === "terminal" + ? "Terminal" + : getFileNameFromPath({ path: outcome.selection.path }), + leadingVisual: + outcome === null ? ( + + ) : outcome.kind === "browser" ? ( + + ) : outcome.kind === "terminal" ? ( + + ) : ( + + ), + onClose: outcome === null ? noop : () => setOutcome(null), + onSelect: noop, + renderContent: () => content, + statusLabel: null, + tab: activeTab, + }; return ( @@ -606,20 +573,18 @@ function NewTabPanelStory({ canUseGitUi requestedMergeBaseBranch="main" environmentId={ENVIRONMENT_ID} - fileTabs={fileTabs} - fileTabContent={content} + tabs={[panelTab]} + fixedTabs={[]} isOpen metadataContent={null} onCollapse={noop} onClose={noop} - onFileTabReorder={noop} + onTabReorder={noop} onOpenNewTab={handleOpenNewTab} - onPanelChange={noop} onPanelFocus={noop} isConversationCollapsed={false} onToggleConversationCollapse={noop} renderAsDrawer - showGitDiffTab /> ); diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx index e0d842c0bf..99df92ff3b 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx @@ -10,16 +10,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { environmentDiffFilesQueryKeyPrefix, environmentFilePreviewQueryKeyPrefix, + hostFilePreviewQueryKey, } from "@/hooks/queries/query-keys"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { GitDiffTabContent, + HostScopedFilePreviewTabContent, WorkspaceFilePreviewTabContent, } from "./ThreadSecondaryPanelTabContent"; vi.mock("@/lib/sdk", () => ({ - sdk: { environments: { diffFiles: vi.fn(), diffFile: vi.fn() } }, + sdk: { + environments: { diffFiles: vi.fn(), diffFile: vi.fn() }, + files: { createPreview: vi.fn(), read: vi.fn() }, + }, })); // The preview body is not under test; keep pierre out of jsdom. @@ -68,7 +73,11 @@ describe("GitDiffTabContent panel gating", () => { target={TARGET} isDiffPanelActive isPanelOpen={isPanelOpen} - gitDiffViewOptions={{}} + gitDiffPresentation={{ + view: "unified", + overflow: "scroll", + showLineNumbers: true, + }} /> ); @@ -137,3 +146,54 @@ describe("WorkspaceFilePreviewTabContent panel gating", () => { }); }); }); + +describe("HostScopedFilePreviewTabContent panel gating", () => { + it("does not start or refetch a host read while the retained panel is closed", async () => { + vi.mocked(sdk.files.createPreview).mockResolvedValue({ + baseUrl: "/api/v1/file-previews/lease-1", + expiresAtMs: Date.now() + 60_000, + }); + vi.mocked(sdk.files.read).mockResolvedValue({ + path: "/tmp/example.txt", + content: "hello\n", + contentEncoding: "utf8", + mimeType: "text/plain", + modifiedAtMs: 1, + sha256: "hash", + sizeBytes: 6, + }); + const { queryClient, wrapper: Wrapper } = createQueryClientTestHarness(); + const renderTab = (isPanelOpen: boolean) => ( + + + + ); + + const view = render(renderTab(false)); + expect(sdk.files.read).not.toHaveBeenCalled(); + expect(sdk.files.createPreview).not.toHaveBeenCalled(); + + view.rerender(renderTab(true)); + await waitFor(() => { + expect(sdk.files.read).toHaveBeenCalledTimes(1); + }); + + view.rerender(renderTab(false)); + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: hostFilePreviewQueryKey("host-1", "/tmp/example.txt"), + }); + }); + expect(sdk.files.read).toHaveBeenCalledTimes(1); + + view.rerender(renderTab(true)); + await waitFor(() => { + expect(sdk.files.read).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx index b38fcaaadc..c604a6408a 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx @@ -1,4 +1,5 @@ -import { useEffect, type ReactNode } from "react"; +import { useEffect } from "react"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import type { WorkspaceDiffTarget } from "@bb/domain"; import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js"; import { Skeleton } from "@bb/shared-ui/skeleton"; @@ -12,6 +13,7 @@ import { useThreadHostFilePreview, useThreadStorageFilePreview, } from "@/hooks/queries/thread-queries"; +import { useHostFilePreview } from "@/hooks/queries/host-file-preview-query"; import { buildRawFilesystemHtmlContentUrl, buildThreadWorktreeRawContentUrl, @@ -20,7 +22,7 @@ import type { EnvironmentFilePreviewSource, FilePreviewLineRange, WorkspaceFilePreviewStatusLabel, -} from "@/lib/file-preview"; +} from "@bb/client-core"; import { cn } from "@bb/shared-ui/lib/utils"; import { DiffFilesPanel } from "./git-diff/DiffFilesPanel"; import { clearDiffFileCardStates } from "./git-diff/diffFilesStore"; @@ -35,11 +37,7 @@ const GIT_DIFF_SKELETON_FILE_COUNT = 3; const PANEL_SCROLL_SLOT_CLASS = "min-h-0 flex-1 overflow-x-auto overflow-y-auto"; -interface ThreadDiffSkeletonProps { - count?: number; -} - -export interface GitDiffTabContentProps { +interface GitDiffTabContentProps { environmentId?: string; target: WorkspaceDiffTarget | undefined; isDiffPanelActive: boolean; @@ -50,7 +48,7 @@ export interface GitDiffTabContentProps { * and refetching into an off-screen panel is wasted network and diff work. */ isPanelOpen: boolean; - gitDiffViewOptions: Record; + gitDiffPresentation: DiffPresentation; onClearPendingGitDiffIntent?: () => void; onOpenFileInEditor?: (path: string) => void; onOpenFilePreview?: (path: string) => void; @@ -59,11 +57,7 @@ export interface GitDiffTabContentProps { workspaceRootPath?: string | null; } -export interface ThreadInfoTabContentProps { - metadataContent: ReactNode; -} - -export interface WorkspaceFilePreviewTabContentProps { +interface WorkspaceFilePreviewTabContentProps { activePath: string; /** * Whether the secondary panel is open. The preview stays mounted while the @@ -85,7 +79,7 @@ export interface WorkspaceFilePreviewTabContentProps { threadId?: string | null; } -export interface ProjectFilePreviewTabContentProps { +interface ProjectFilePreviewTabContentProps { activePath: string; /** * Whether the secondary panel is open. The preview stays mounted while the @@ -105,7 +99,7 @@ export interface ProjectFilePreviewTabContentProps { projectId: string; } -export interface HostFilePreviewTabContentProps { +interface HostFilePreviewTabContentProps { activePath: string; /** * Whether the secondary panel is open. The preview stays mounted while the @@ -125,7 +119,19 @@ export interface HostFilePreviewTabContentProps { threadId: string; } -export interface ThreadStorageFilePreviewTabContentProps { +interface HostScopedFilePreviewTabContentProps { + activePath: string; + hostId: string; + /** + * Whether the secondary panel is open. The retained panel body stays + * mounted while closed, but its host read must pause until it is visible. + */ + isPanelOpen: boolean; + lineRange: FilePreviewLineRange | null; + onOpenInEditor?: (path: string) => void; +} + +interface ThreadStorageFilePreviewTabContentProps { activePath: string; /** * Whether the secondary panel is open. The preview stays mounted while the @@ -144,12 +150,10 @@ export interface ThreadStorageFilePreviewTabContentProps { threadId: string; } -function ThreadDiffSkeleton({ - count = GIT_DIFF_SKELETON_FILE_COUNT, -}: ThreadDiffSkeletonProps) { +function ThreadDiffSkeleton() { return (
- {Array.from({ length: count }).map((_, index) => ( + {Array.from({ length: GIT_DIFF_SKELETON_FILE_COUNT }).map((_, index) => (
{metadataContent}
; -} - export function WorkspaceFilePreviewTabContent({ activePath, copyPath = null, @@ -481,6 +478,37 @@ export function HostFilePreviewTabContent({ ); } +export function HostScopedFilePreviewTabContent({ + activePath, + hostId, + isPanelOpen, + lineRange, + onOpenInEditor, +}: HostScopedFilePreviewTabContentProps) { + const { + data: hostFilePreview, + error, + isFetching, + isLoading, + refetch, + } = useHostFilePreview(hostId, activePath, { enabled: isPanelOpen }); + return ( + void refetch()} + statusLabel={null} + /> + ); +} + export function ThreadStorageFilePreviewTabContent({ activePath, copyPath = null, diff --git a/apps/app/src/components/secondary-panel/ThreadStorageBrowser.tsx b/apps/app/src/components/secondary-panel/ThreadStorageBrowser.tsx index 67bf73413d..01659df94a 100644 --- a/apps/app/src/components/secondary-panel/ThreadStorageBrowser.tsx +++ b/apps/app/src/components/secondary-panel/ThreadStorageBrowser.tsx @@ -1,11 +1,4 @@ -import { - useEffect, - useMemo, - useRef, - type CSSProperties, - type ReactNode, -} from "react"; -import { FileTree } from "@pierre/trees/react"; +import { useEffect, useRef, type ReactNode } from "react"; import { Button } from "@bb/shared-ui/button"; import { COARSE_POINTER_COMPACT_ICON_BUTTON_CLASS, @@ -16,59 +9,15 @@ import { EmptyState } from "@bb/shared-ui/empty-state"; import { Icon } from "@bb/shared-ui/icon"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { Input } from "@bb/shared-ui/input"; -import { usePreferredTheme } from "@/hooks/useTheme"; import { cn } from "@bb/shared-ui/lib/utils"; import { describeLifecycleError, formatLifecycleErrorDescription, } from "@/lib/lifecycle-errors"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; +import { LazyThreadStorageFileTree } from "./lazySecondaryPanelComponents"; import type { ThreadStorageBrowserController } from "./useThreadStorageBrowser"; -interface FileTreeHostStyle extends CSSProperties { - "--trees-accent-override": string; - "--trees-bg-muted-override": string; - "--trees-bg-override": string; - "--trees-border-color-override": string; - "--trees-fg-muted-override": string; - "--trees-fg-override": string; - "--trees-focus-ring-color-override": string; - "--trees-font-family-override": string; - "--trees-font-size-override": string; - "--trees-icon-width-override": string; - "--trees-item-margin-x-override": string; - "--trees-padding-inline-override": string; - "--trees-scrollbar-thumb-override": string; - "--trees-selected-bg-override": string; - "--trees-selected-fg-override": string; - "--trees-selected-focused-border-color-override": string; -} - -const FILE_TREE_BASE_HOST_STYLE: FileTreeHostStyle = { - "--trees-accent-override": "var(--ring)", - "--trees-bg-muted-override": - "color-mix(in srgb, var(--muted) 45%, transparent)", - "--trees-bg-override": "transparent", - "--trees-border-color-override": "var(--border)", - "--trees-fg-muted-override": "var(--muted-foreground)", - "--trees-fg-override": "var(--foreground)", - "--trees-focus-ring-color-override": "var(--ring)", - "--trees-font-family-override": "var(--font-sans)", - // Match the info page's compact text-xs rows and the app's smaller icon/caret - // scale (the tree's chevron caret + file icons size off --trees-icon-width). - "--trees-font-size-override": "var(--text-xs)", - "--trees-icon-width-override": "14px", - "--trees-item-margin-x-override": "0", - "--trees-padding-inline-override": "0", - "--trees-scrollbar-thumb-override": - "color-mix(in srgb, var(--muted-foreground) 35%, transparent)", - "--trees-selected-bg-override": - "color-mix(in srgb, var(--accent) 65%, transparent)", - "--trees-selected-fg-override": "var(--foreground)", - "--trees-selected-focused-border-color-override": "var(--ring)", - height: "100%", -}; - interface ThreadStorageBrowserProps { controller: ThreadStorageBrowserController; filesError?: Error | null; @@ -89,7 +38,6 @@ export function ThreadStorageBrowser({ searchQuery, setSearchQuery, } = controller; - const preferredTheme = usePreferredTheme(); const searchInputRef = useRef(null); const isPointerCoarse = usePointerCoarse(); @@ -99,14 +47,13 @@ export function ThreadStorageBrowser({ } }, [isPointerCoarse, isSearchOpen]); - const fileTreeHostStyle = useMemo( - () => ({ - ...FILE_TREE_BASE_HOST_STYLE, - colorScheme: preferredTheme, - }), - [preferredTheme], + const loadingState = ( + ); - let body: ReactNode; if (filesError) { const lifecycleErrorDescription = describeLifecycleError({ @@ -129,26 +76,17 @@ export function ThreadStorageBrowser({ /> ); } else if (isFilesLoading && loadedFiles.length === 0) { - body = ( - - ); + body = loadingState; } else if (loadedFiles.length === 0) { body = ; } else if (filteredFiles.length === 0) { body = ; + } else if (model === null) { + // The tree chunk is still on its way; the controller hands over the model + // once it lands. + body = loadingState; } else { - body = ( - - ); + body = ; } return ( diff --git a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx index 80b0abe769..4121c20f62 100644 --- a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx +++ b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx @@ -3,6 +3,7 @@ import { type FilePreviewFile, type TextFilePreviewKind, } from "./FilePreview"; +import { hashSourceContents } from "@/components/code/source-code-budget"; import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js"; import { HttpError } from "@/lib/api"; import { buildThreadStorageRawContentUrl } from "@/lib/file-content-urls"; @@ -11,12 +12,12 @@ import type { FilePreviewLineRange, TextFilePreview, WorkspaceFilePreviewStatusLabel, -} from "@/lib/file-preview"; +} from "@bb/client-core"; import { isCsvFilePreview, isHtmlFilePreviewPath, isMarkdownFilePreview, -} from "@/lib/file-preview"; +} from "@bb/client-core"; // Generic HTML comes from arbitrary worktree/storage files. Allow scripts for // realistic previews, but omit allow-same-origin so the frame gets an opaque @@ -51,15 +52,6 @@ interface BuildTextPreviewFileArgs { filePreview: TextFilePreview; } -function hashStringForPreviewCache(value: string): string { - let hash = 0x811c9dc5; - for (let index = 0; index < value.length; index += 1) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(36); -} - function buildTextPreviewCacheKey({ activePath, filePreview, @@ -70,8 +62,7 @@ function buildTextPreviewCacheKey({ filePreview.path, filePreview.name ?? activePath, filePreview.mimeType, - filePreview.content.length, - hashStringForPreviewCache(filePreview.content), + hashSourceContents(filePreview.content), ].join(":"); } diff --git a/apps/app/src/components/secondary-panel/ThreadStorageFileTree.tsx b/apps/app/src/components/secondary-panel/ThreadStorageFileTree.tsx new file mode 100644 index 0000000000..5ea17a9c57 --- /dev/null +++ b/apps/app/src/components/secondary-panel/ThreadStorageFileTree.tsx @@ -0,0 +1,101 @@ +// The lazy `@pierre/trees` chunk: the only module that imports the tree +// library at runtime. `useThreadStorageBrowser` imports this module with a +// dynamic `import()` to build the tree model the first time a thread has +// storage files to show, and `ThreadStorageBrowser` renders the tree through +// `React.lazy` (see lazySecondaryPanelComponents.tsx). That keeps ~420 KB raw +// of tree model, renderer and preact out of the thread route's static closure; +// `bundle-budget.json` names this file as the package's only gate. Keep +// anything else out of here: it rides along in the tree chunk. +import { useMemo, type CSSProperties } from "react"; +import { + FileTree as FileTreeModel, + type FileTreeSelectionChangeListener, +} from "@pierre/trees"; +import { FileTree } from "@pierre/trees/react"; +import { usePreferredTheme } from "@/hooks/useTheme"; + +export type ThreadStorageTreeModel = FileTreeModel; + +/** + * Builds the storage browser's tree model. The caller owns it and must call + * `model.cleanUp()` when done: that unsubscribes the selection listener and + * destroys the controller (see render/FileTree.ts in pierrecomputer/pierre). + */ +export function createThreadStorageTreeModel( + onSelectionChange: FileTreeSelectionChangeListener, +): ThreadStorageTreeModel { + return new FileTreeModel({ + density: "compact", + initialExpansion: "closed", + onSelectionChange, + paths: [], + search: false, + }); +} + +interface FileTreeHostStyle extends CSSProperties { + "--trees-accent-override": string; + "--trees-bg-muted-override": string; + "--trees-bg-override": string; + "--trees-border-color-override": string; + "--trees-fg-muted-override": string; + "--trees-fg-override": string; + "--trees-focus-ring-color-override": string; + "--trees-font-family-override": string; + "--trees-font-size-override": string; + "--trees-icon-width-override": string; + "--trees-item-margin-x-override": string; + "--trees-padding-inline-override": string; + "--trees-scrollbar-thumb-override": string; + "--trees-selected-bg-override": string; + "--trees-selected-fg-override": string; + "--trees-selected-focused-border-color-override": string; +} + +const FILE_TREE_BASE_HOST_STYLE: FileTreeHostStyle = { + "--trees-accent-override": "var(--ring)", + "--trees-bg-muted-override": + "color-mix(in srgb, var(--muted) 45%, transparent)", + "--trees-bg-override": "transparent", + "--trees-border-color-override": "var(--border)", + "--trees-fg-muted-override": "var(--muted-foreground)", + "--trees-fg-override": "var(--foreground)", + "--trees-focus-ring-color-override": "var(--ring)", + "--trees-font-family-override": "var(--font-sans)", + // Match the info page's compact text-xs rows and the app's smaller icon/caret + // scale (the tree's chevron caret + file icons size off --trees-icon-width). + "--trees-font-size-override": "var(--text-xs)", + "--trees-icon-width-override": "14px", + "--trees-item-margin-x-override": "0", + "--trees-padding-inline-override": "0", + "--trees-scrollbar-thumb-override": + "color-mix(in srgb, var(--muted-foreground) 35%, transparent)", + "--trees-selected-bg-override": + "color-mix(in srgb, var(--accent) 65%, transparent)", + "--trees-selected-fg-override": "var(--foreground)", + "--trees-selected-focused-border-color-override": "var(--ring)", + height: "100%", +}; + +export function ThreadStorageFileTree({ + model, +}: { + model: ThreadStorageTreeModel; +}) { + const preferredTheme = usePreferredTheme(); + const fileTreeHostStyle = useMemo( + () => ({ + ...FILE_TREE_BASE_HOST_STYLE, + colorScheme: preferredTheme, + }), + [preferredTheme], + ); + return ( + + ); +} diff --git a/apps/app/src/components/secondary-panel/browserViewVisibilityCoordinator.ts b/apps/app/src/components/secondary-panel/browserViewVisibilityCoordinator.ts index 9f4947382e..7a5ce77f8b 100644 --- a/apps/app/src/components/secondary-panel/browserViewVisibilityCoordinator.ts +++ b/apps/app/src/components/secondary-panel/browserViewVisibilityCoordinator.ts @@ -20,7 +20,11 @@ export interface BrowserViewVisibilityCoordinator { * appears at stale/zero bounds). `BrowserTabContent` calls this only after the * hidden attach has been issued, making this the first-show path too. */ - show(tabId: string, syncBounds: () => void): void; + show( + tabId: string, + syncBounds: () => void, + options?: { focus?: boolean }, + ): void; /** Hide `tabId`'s view (no-op overlay-wise if it was already hidden). */ hide(tabId: string): void; /** @@ -66,13 +70,21 @@ export function createBrowserViewVisibilityCoordinator( // The browser tab whose native view is currently shown, or null when none is. let visibleTabId: string | null = null; return { - show(tabId, syncBounds) { + show(tabId, syncBounds, options) { if (visibleTabId !== null && visibleTabId !== tabId) { desktopBrowser.setVisible({ tabId: visibleTabId, visible: false }); } visibleTabId = tabId; syncBounds(); - desktopBrowser.setVisible({ tabId, visible: true }); + const request = { tabId, visible: true }; + if ( + options?.focus === false && + desktopBrowser.setVisibleWithoutFocus !== undefined + ) { + desktopBrowser.setVisibleWithoutFocus(request); + } else { + desktopBrowser.setVisible(request); + } }, hide(tabId) { if (visibleTabId === tabId) { diff --git a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.contextExpansion.test.tsx b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.contextExpansion.test.tsx index 3c34d53a13..88e29d6fac 100644 --- a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.contextExpansion.test.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.contextExpansion.test.tsx @@ -23,6 +23,22 @@ vi.mock("@pierre/diffs/react", () => ({ }, })); +/** + * BB's diff renderer is a lazy chunk behind the host boundary (the same + * pattern `LazyTimelineFileDiffBlock` uses), so the card paints its skeleton + * until that import resolves. Testing Library's 1s default is not enough for + * a module compile while the whole suite runs in parallel. + */ +const DIFF_RENDERER_CHUNK_TIMEOUT_MS = 10_000; + +function findDiffView() { + return screen.findByTestId( + "diff-view", + {}, + { timeout: DIFF_RENDERER_CHUNK_TIMEOUT_MS }, + ); +} + const MODIFIED_PATCH = [ "diff --git a/src/file.ts b/src/file.ts", "index 1111111..2222222 100644", @@ -114,7 +130,11 @@ function renderModifiedCard(onRequestFileContents: RequestDiffFileContents) { render( {}} patchState={{ status: "loaded", patch: MODIFIED_PATCH, truncated: false }} @@ -164,7 +184,7 @@ describe("DiffFileCard context expansion", () => { renderModifiedCard(onRequestFileContents); revealCardBodies(); - await screen.findByTestId("diff-view"); + await findDiffView(); const expandButton = await screen.findByRole("button", { name: "Expand context", }); @@ -263,7 +283,11 @@ describe("DiffFileCard context expansion", () => { additions: 2, deletions: 0, })} - diffViewOptions={{}} + presentation={{ + view: "unified", + overflow: "scroll", + showLineNumbers: true, + }} isCollapsed={false} onToggleCollapsed={() => {}} patchState={{ status: "loaded", patch: ADDED_PATCH, truncated: false }} @@ -274,7 +298,7 @@ describe("DiffFileCard context expansion", () => { ); revealCardBodies(); - await screen.findByTestId("diff-view"); + await findDiffView(); await new Promise((resolve) => setTimeout(resolve, 300)); expect(onRequestFileContents).not.toHaveBeenCalled(); expect( diff --git a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.stories.tsx b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.stories.tsx index 9f6f84063c..38e014e556 100644 --- a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.stories.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.stories.tsx @@ -1,9 +1,8 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import type { DiffFileEntry } from "@bb/server-contract"; -import { GIT_DIFF_VIEW_BASE_OPTIONS } from "@/components/git-diff/GitDiffCard"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import type { RequestDiffFileContents } from "@/components/git-diff/GitDiffCardBody"; import { DEFAULT_CODE_OVERFLOW_MODE } from "@/lib/code-overflow-mode"; -import { usePreferredTheme } from "@/hooks/useTheme"; import type { DiffPatchState } from "@/hooks/queries/use-environment-diff-patches"; import { appToast } from "@/components/ui/app-toast"; import { StoryCard, StoryRow } from "../../../../.ladle/story-card"; @@ -116,22 +115,18 @@ interface CardStageProps { // Mounts a single DiffFileCard at a panel-realistic width with live theme-aware // view options, mirroring how DiffFilesPanel renders each row. +const CARD_PRESENTATION: DiffPresentation = { + view: "unified", + overflow: DEFAULT_CODE_OVERFLOW_MODE, + showLineNumbers: true, +}; + function CardStage({ entry, patchState = { status: "idle" }, collapsed = false, onRequestFileContents, }: CardStageProps) { - const preferredTheme = usePreferredTheme(); - const diffViewOptions = useMemo( - () => ({ - ...GIT_DIFF_VIEW_BASE_OPTIONS, - diffStyle: "unified", - overflow: DEFAULT_CODE_OVERFLOW_MODE, - themeType: preferredTheme, - }), - [preferredTheme], - ); const [isCollapsed, setIsCollapsed] = useState(collapsed); const toast = useCallback( (label: string) => (path: string) => @@ -142,7 +137,7 @@ function CardStage({
setIsCollapsed((value) => !value)} patchState={patchState} diff --git a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.test.tsx b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.test.tsx index fb39c56f2f..c08ac98d16 100644 --- a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.test.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.test.tsx @@ -1,14 +1,36 @@ // @vitest-environment jsdom -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginDiffRendererProps } from "@get-bb/plugin-sdk"; import type { DiffFileEntry } from "@bb/server-contract"; import type { DiffFileContentsResult, RequestDiffFileContents, } from "@/components/git-diff/GitDiffCardBody"; import type { DiffPatchState } from "@/hooks/queries/use-environment-diff-patches"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; import { DiffFileCard } from "./DiffFileCard"; +// The diff body defers its renderer until the card scrolls into view. jsdom +// has no layout, so report every observed sentinel as visible. +vi.mock("usehooks-ts", async (importOriginal) => ({ + ...(await importOriginal()), + useIntersectionObserver: () => ({ + ref: () => {}, + isIntersecting: true, + entry: undefined, + }), +})); + const IMAGE_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/Qo3AAAAAElFTkSuQmCC"; @@ -40,7 +62,11 @@ function renderCard({ render( {}} patchState={patchState} @@ -51,8 +77,19 @@ function renderCard({ ); } +const TEXT_PATCH = [ + "diff --git a/src/file.ts b/src/file.ts", + "--- a/src/file.ts", + "+++ b/src/file.ts", + "@@ -1,2 +1,2 @@", + "-const b = 2;", + "+const b = 3;", + "", +].join("\n"); + afterEach(() => { cleanup(); + resetPluginSlotStoreForTest(); }); describe("DiffFileCard", () => { @@ -121,6 +158,100 @@ describe("DiffFileCard", () => { expect(onRequestFileContents).not.toHaveBeenCalled(); }); + it("renders its text body through the shared host diff boundary", async () => { + // The point of the boundary: one `experimental_diffRenderer` registration + // has to reach BB's own diff panel, not just plugin-rendered diffs. + const seen: { patch: string; path: string; view: string }[] = []; + setPluginSlotRegistrations("demo", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + diffRenderers: [ + { + id: "diffs", + title: "Demo diffs", + component: ({ patch, path, view }) => { + seen.push({ patch, path, view }); + return
plugin diff
; + }, + }, + ], + }); + + renderCard({ + entry: buildEntry(), + patchState: { status: "loaded", patch: TEXT_PATCH }, + }); + + expect(await screen.findByTestId("plugin-diff-body")).toBeTruthy(); + // The caller had the real bytes, so the replacement gets those — not a + // reconstruction. + expect(seen.at(-1)?.patch).toBe(TEXT_PATCH); + expect(seen.at(-1)?.path).toBe("src/file.ts"); + expect(seen.at(-1)?.view).toBe("unified"); + }); + + it("forwards lazily resolved text sides to a replacement renderer", async () => { + const seen: PluginDiffRendererProps["experimental_fullFileContents"][] = []; + setPluginSlotRegistrations("demo", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + diffRenderers: [ + { + id: "diffs", + title: "Demo diffs", + component: ({ experimental_fullFileContents }) => { + seen.push(experimental_fullFileContents); + return
plugin diff
; + }, + }, + ], + }); + const onRequestFileContents = vi.fn( + async (path, side) => ({ + kind: "text", + file: { + name: path, + contents: + side === "old" + ? "const b = 2;\nconst tail = true;\n" + : "const b = 3;\nconst tail = true;\n", + }, + }), + ); + + renderCard({ + entry: buildEntry(), + patchState: { status: "loaded", patch: TEXT_PATCH }, + onRequestFileContents, + }); + + fireEvent.click( + await screen.findByRole("button", { name: "Expand context" }), + ); + await waitFor(() => { + expect(seen.at(-1)).toEqual({ + old: { + path: "src/file.ts", + content: "const b = 2;\nconst tail = true;\n", + }, + new: { + path: "src/file.ts", + content: "const b = 3;\nconst tail = true;\n", + }, + }); + }); + }); + it("falls back to the load gate when an image-looking path is not previewable", async () => { const onLoadPatch = vi.fn(); const onRequestFileContents = vi.fn( diff --git a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.tsx b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.tsx index 3dffb9146c..c429c89b50 100644 --- a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.tsx @@ -1,9 +1,11 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; import { useIntersectionObserver } from "usehooks-ts"; import type { DiffFileEntry } from "@bb/server-contract"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import { getGitDiffCardImageSizeStat, GitDiffCardBody, + GitDiffCardBodySkeleton, GitDiffCardImagePreviewBody, useGitDiffCardBody, type DiffFileContentsResult, @@ -27,7 +29,6 @@ import { } from "@/components/git-diff/git-diff-parsing"; import { Button } from "@bb/shared-ui/button"; import { FilePathLink } from "@/components/ui/file-path-link.js"; -import { Skeleton } from "@bb/shared-ui/skeleton"; import type { DiffPatchState } from "@/hooks/queries/use-environment-diff-patches"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -121,7 +122,7 @@ function buildBinaryImagePreviewPlan( export interface DiffFileCardProps { entry: DiffFileEntry; - diffViewOptions: Record; + presentation: DiffPresentation; filePathRoot?: string | null; isCollapsed: boolean; onToggleCollapsed: () => void; @@ -174,7 +175,7 @@ function areDiffFileCardPropsEqual( ): boolean { return ( previous.entry === next.entry && - previous.diffViewOptions === next.diffViewOptions && + previous.presentation === next.presentation && previous.filePathRoot === next.filePathRoot && previous.isCollapsed === next.isCollapsed && previous.onToggleCollapsed === next.onToggleCollapsed && @@ -275,7 +276,7 @@ function useBinaryImagePreview({ export const DiffFileCard = memo(function DiffFileCard({ entry, - diffViewOptions, + presentation, filePathRoot, isCollapsed, onToggleCollapsed, @@ -400,7 +401,7 @@ export const DiffFileCard = memo(function DiffFileCard({ ; + presentation: DiffPresentation; parsedFile: ParsedGitDiffFile | null; patchState: DiffPatchState; svgDisplayMode: GitDiffCardSvgDisplayMode; @@ -438,19 +439,6 @@ interface DiffFileCardBodyProps { const DIFF_FILE_CARD_NOTICE_CLASS = "flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-3 text-xs text-muted-foreground"; -function DiffFileCardBodySkeleton() { - return ( -
- - - - - - -
- ); -} - function DiffFileCardLoadDiffNotice({ changedLines, entry, @@ -483,7 +471,7 @@ function DiffFileCardLoadDiffNotice({ function DiffFileCardBody({ entry, changedLines, - diffViewOptions, + presentation, parsedFile, patchState, svgDisplayMode, @@ -499,7 +487,7 @@ function DiffFileCardBody({ binaryImagePreviewState.status === "idle" || binaryImagePreviewState.status === "loading" ) { - return ; + return ; } if (binaryImagePreviewState.status === "ready") { return ( @@ -589,7 +577,7 @@ function DiffFileCardBody({ ); } - return ; + return ; } return ( @@ -597,7 +585,7 @@ function DiffFileCardBody({ entry={entry} parsedFile={parsedFile} patchText={patchState.truncated ? undefined : patchState.patch} - diffViewOptions={diffViewOptions} + presentation={presentation} svgDisplayMode={svgDisplayMode} truncated={patchState.truncated ?? false} onOpenFilePreview={onOpenFilePreview} @@ -611,7 +599,7 @@ interface DiffFileCardRenderedBodyProps { entry: DiffFileEntry; parsedFile: ParsedGitDiffFile; patchText?: string; - diffViewOptions: Record; + presentation: DiffPresentation; svgDisplayMode: GitDiffCardSvgDisplayMode; truncated: boolean; onOpenFilePreview?: (path: string) => void; @@ -630,7 +618,7 @@ function DiffFileCardRenderedBody({ entry, parsedFile, patchText, - diffViewOptions, + presentation, svgDisplayMode, truncated, onOpenFilePreview, @@ -648,7 +636,7 @@ function DiffFileCardRenderedBody({ <> ; + presentation: DiffPresentation; filePathRoot?: string | null; /** * Whether the secondary panel is open. While closed the list stays mounted @@ -84,7 +85,7 @@ export function DiffFilesPanel({ files, initialPatches, filesUpdatedAt, - diffViewOptions, + presentation, filePathRoot, isPanelOpen, isPlaceholderData, @@ -179,7 +180,7 @@ export function DiffFilesPanel({ // refetches (`filesUpdatedAt` bumps): a content-only edit produces the same // paths but evicts the patch cache, so the same visible set must be // re-requested to fetch the fresh patch. - // eslint-disable-next-line react-hooks/exhaustive-deps + // oxlint-disable-next-line react/exhaustive-deps }, [isPanelOpen, requestPaths, visibleKey, overscanKey, filesUpdatedAt]); // Scroll a file requested from the info tab / prompt banner to the top of the @@ -232,7 +233,7 @@ export function DiffFilesPanel({ entry={entry} diffIdentity={diffIdentity} fileCount={files.length} - diffViewOptions={diffViewOptions} + presentation={presentation} filePathRoot={filePathRoot} patchState={getPatchState(entry.path)} loadPath={loadPath} @@ -257,7 +258,7 @@ interface DiffFileRowProps { entry: DiffFileEntry; diffIdentity: string; fileCount: number; - diffViewOptions: Record; + presentation: DiffPresentation; filePathRoot?: string | null; patchState: DiffPatchState; loadPath: LoadDiffPatchPath; @@ -272,7 +273,7 @@ function DiffFileRow({ entry, diffIdentity, fileCount, - diffViewOptions, + presentation, filePathRoot, patchState, loadPath, @@ -308,7 +309,7 @@ function DiffFileRow({ return ( ; - /** * Resolve a card's current collapsed flag: the per-card atom value if the user * has touched it, otherwise the initial default. The single source of truth for @@ -109,7 +105,7 @@ export function resolveCardCollapsed( ); } -export interface DiffFilesCollapseControls { +interface DiffFilesCollapseControls { /** True when every current TOC file is collapsed (none are expanded). */ areAllCollapsed: boolean; /** Collapse every file when any is expanded; otherwise expand every file. */ @@ -213,7 +209,7 @@ const DIFF_CARD_MAX_ESTIMATED_LINES = 80; * virtualizer's `measureElement` still corrects the exact height on mount and * when the user toggles the card open. */ -export interface EstimateCardHeightArgs { +interface EstimateCardHeightArgs { entry: DiffFileEntry; collapsed: boolean; } diff --git a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts new file mode 100644 index 0000000000..cb4ccc97cc --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { openAppFixedTabFromDestinations } from "@/lib/app-fixed-tab-navigation"; +import { + createGitDiffFixedTabDestination, + GIT_DIFF_FIXED_TAB_REFERENCE, +} from "./git-diff-fixed-tab-navigation"; + +describe("createGitDiffFixedTabDestination", () => { + it("routes core Changes targets through the generic controller while the owner validates them", () => { + const openCommit = vi.fn(); + const openFile = vi.fn(); + const openOrdinary = vi.fn(); + const destination = createGitDiffFixedTabDestination({ + eligible: true, + openCommit, + openFile, + openOrdinary, + }); + + const open = ( + target?: { kind: "file"; path: string } | { kind: "commit"; sha: string }, + ) => + openAppFixedTabFromDestinations([destination], { + surface: { kind: "current" }, + tab: GIT_DIFF_FIXED_TAB_REFERENCE, + ...(target === undefined ? {} : { target }), + }); + + expect(open({ kind: "file", path: "src/app.tsx" })).toBe(true); + expect(open({ kind: "commit", sha: "abc123" })).toBe(true); + expect(open({ kind: "file", path: "" })).toBe(false); + expect(open()).toBe(true); + expect(openFile).toHaveBeenCalledWith("src/app.tsx"); + expect(openCommit).toHaveBeenCalledWith("abc123"); + expect(openOrdinary).toHaveBeenCalledOnce(); + }); + + it("declines every target while Changes is ineligible", () => { + const openOrdinary = vi.fn(); + const destination = createGitDiffFixedTabDestination({ + eligible: false, + openCommit: vi.fn(), + openFile: vi.fn(), + openOrdinary, + }); + expect(destination.open(undefined)).toBe(false); + expect(openOrdinary).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts new file mode 100644 index 0000000000..2b1432421c --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts @@ -0,0 +1,71 @@ +import type { JsonValue } from "@get-bb/plugin-sdk"; +import type { AppFixedTabDestination } from "@/lib/app-fixed-tab-navigation"; +import type { AppFixedTabReference } from "@/lib/app-navigation-host"; + +type GitDiffFixedTabTarget = + | { kind: "file"; path: string } + | { kind: "commit"; sha: string }; + +export const GIT_DIFF_FIXED_TAB_REFERENCE: AppFixedTabReference = { + ownerId: "core:git-diff", + tabId: "changes", +}; + +function normalizeGitDiffFixedTabTarget( + value: JsonValue, +): GitDiffFixedTabTarget | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const keys = Object.keys(value); + if ( + value.kind === "file" && + keys.length === 2 && + keys.includes("kind") && + keys.includes("path") && + typeof value.path === "string" && + value.path.length > 0 + ) { + return { kind: value.kind, path: value.path }; + } + if ( + value.kind === "commit" && + keys.length === 2 && + keys.includes("kind") && + keys.includes("sha") && + typeof value.sha === "string" && + value.sha.length > 0 + ) { + return { kind: value.kind, sha: value.sha }; + } + return null; +} + +/** The Changes owner validates and interprets targets outside the controller. */ +export function createGitDiffFixedTabDestination({ + eligible, + openCommit, + openFile, + openOrdinary, +}: { + eligible: boolean; + openCommit: (sha: string) => void; + openFile: (path: string) => void; + openOrdinary: () => void; +}): AppFixedTabDestination { + return { + tab: GIT_DIFF_FIXED_TAB_REFERENCE, + open(target) { + if (!eligible) return false; + if (target === undefined) { + openOrdinary(); + return true; + } + const normalized = normalizeGitDiffFixedTabTarget(target); + if (normalized === null) return false; + if (normalized.kind === "file") openFile(normalized.path); + else openCommit(normalized.sha); + return true; + }, + }; +} diff --git a/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.ts b/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.ts index 5bd1f64b87..afc2fa4d8a 100644 --- a/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.ts +++ b/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.ts @@ -1,7 +1,7 @@ import type { WorkspaceCommitSummary, WorkspaceDiffTarget } from "@bb/domain"; -import type { GitDiffSelectionOption } from "../ThreadSecondaryPanel"; +import type { GitDiffSelectionOption } from "../GitDiffToolbar"; -export interface GitDiffIdentityParams { +interface GitDiffIdentityParams { environmentId?: string; mergeBaseRef: string | null; target: WorkspaceDiffTarget | undefined; @@ -59,21 +59,14 @@ export const UNCOMMITTED_GIT_DIFF_SELECTION = "uncommitted"; export type GitDiffSelectionValue = string | null; -export interface GitDiffSelectionAvailability { +interface GitDiffSelectionAvailability { hasUncommittedChanges: boolean; } -export type GitDiffTarget = - | { type: "commit"; sha: string } - | { type: "uncommitted" } - | { type: "branch_committed"; mergeBaseBranch: string } - | { type: "all"; mergeBaseBranch: string } - | undefined; - export function buildGitDiffTarget( selectedGitDiffSelection: GitDiffSelectionValue, effectiveMergeBaseBranch: string | undefined, -): GitDiffTarget { +): WorkspaceDiffTarget | undefined { if (selectedGitDiffSelection === UNCOMMITTED_GIT_DIFF_SELECTION) { return { type: "uncommitted" }; } diff --git a/apps/app/src/components/secondary-panel/git-diff/useDiffFileContentsRequester.ts b/apps/app/src/components/secondary-panel/git-diff/useDiffFileContentsRequester.ts index 728d43927b..9d63c92612 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useDiffFileContentsRequester.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useDiffFileContentsRequester.ts @@ -10,7 +10,7 @@ import type { RequestDiffFileContents, } from "@/components/git-diff/GitDiffCardBody"; -export interface UseDiffFileContentsRequesterArgs { +interface UseDiffFileContentsRequesterArgs { environmentId?: string; target?: WorkspaceDiffTarget; /** @@ -158,7 +158,7 @@ function buildDiffFileTarget( } // Browser-renderable raster image MIME types. Mirrors the extension allowlist -// in `isImageGitDiffFile`. SVG remains a text result; the card converts that +// in `isPreviewableImagePath`. SVG remains a text result; the card converts that // text into a preview data URL while keeping the raw diff toggle available. const PREVIEWABLE_IMAGE_MIME_TYPES: ReadonlySet = new Set([ "image/avif", diff --git a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts index 97373d3963..03b284ee56 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts @@ -1,4 +1,5 @@ import type { Environment, WorkspaceStatus } from "@bb/domain"; +import { makeWorkspaceStatus as makeSharedWorkspaceStatus } from "@bb/test-helpers"; import { describe, expect, it } from "vitest"; import { resolveEffectiveMergeBaseBranch, @@ -7,7 +8,6 @@ import { } from "./useEnvironmentMergeBase"; type EnvironmentOverrides = Partial; -type WorkspaceStatusOverrides = Partial; function makeEnvironment(overrides: EnvironmentOverrides = {}): Environment { return { @@ -32,29 +32,13 @@ function makeEnvironment(overrides: EnvironmentOverrides = {}): Environment { } function makeWorkspaceStatus( - overrides: WorkspaceStatusOverrides = {}, + overrides: Partial = {}, ): WorkspaceStatus { - return { - branch: { - currentBranch: "bb/thread", - defaultBranch: "main", - }, - checkout: { - kind: "branch", - branchName: "bb/thread", - headSha: null, - }, - mergeBase: null, - workingTree: { - deletions: 0, - files: [], - hasUncommittedChanges: false, - insertions: 0, - lineStatsComplete: true, - state: "clean", - }, + return makeSharedWorkspaceStatus({ + branch: { currentBranch: "bb/thread", defaultBranch: "main" }, + checkout: { kind: "branch", branchName: "bb/thread", headSha: null }, ...overrides, - }; + }); } describe("shouldSyncSelectedMergeBaseBranch", () => { diff --git a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.ts b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.ts index 1ae7f5784b..40a01787d6 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.ts @@ -75,7 +75,7 @@ export function shouldSyncSelectedMergeBaseBranch({ ); } -export function resolveImplicitMergeBaseBranch({ +function resolveImplicitMergeBaseBranch({ environment, workspaceStatus, }: ResolveImplicitMergeBaseBranchParams): string | undefined { @@ -176,13 +176,14 @@ export function useEnvironmentMergeBase({ const showBranchComparisonUi = Boolean( effectiveMergeBaseBranch || workspaceStatus?.branch.defaultBranch, ); - const mergeBaseBranch = effectiveMergeBaseBranch; const isOnDefaultBranch = workspaceStatus?.branch.currentBranch != null && workspaceStatus.branch.currentBranch === workspaceStatus.branch.defaultBranch; const showMergeBase = - showBranchComparisonUi && Boolean(mergeBaseBranch) && !isOnDefaultBranch; + showBranchComparisonUi && + Boolean(effectiveMergeBaseBranch) && + !isOnDefaultBranch; const handleMergeBaseBranchChange: MergeBaseBranchChangeHandler = useCallback( (branch) => { @@ -255,6 +256,5 @@ export function useEnvironmentMergeBase({ handleMergeBaseBranchChange, showBranchComparisonUi, showMergeBase, - mergeBaseBranch, }; } diff --git a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts index 2f16f1337d..9ee1018a12 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts @@ -123,26 +123,14 @@ export function useGitDiffPanel({ ? [selectedMergeBaseBranchRef.name, ...mergeBaseRemoteBranchList] : mergeBaseRemoteBranchList; }, [mergeBaseRemoteBranchList, selectedMergeBaseBranchRef]); - const mergeBaseBranchOptionsTruncated = Boolean( - mergeBaseBranches?.branchesTruncated || - mergeBaseBranches?.remoteBranchesTruncated, - ); - useEffect(() => { setMergeBaseBranchSearchQuery(""); setPendingGitDiffIntent(null); }, [environmentId, threadId]); - const openThreadSecondaryPanel = useCallback( - (panel: ThreadSecondaryPanelTab) => { - setThreadSecondaryPanel(panel); - }, - [setThreadSecondaryPanel], - ); - const openThreadDiffPanel = useCallback(() => { - openThreadSecondaryPanel("git-diff"); - }, [openThreadSecondaryPanel]); + setThreadSecondaryPanel("git-diff"); + }, [setThreadSecondaryPanel]); const closeThreadSecondaryPanel = useCallback(() => { setThreadSecondaryPanel(null); @@ -171,12 +159,10 @@ export function useGitDiffPanel({ clearPendingGitDiffIntent, isLoadingMergeBaseBranchOptions, mergeBaseBranchOptions, - mergeBaseBranchOptionsTruncated, mergeBaseRemoteBranchOptions, openCommitDiff, openDiffFile, openThreadDiffPanel, - openThreadSecondaryPanel, pendingGitDiffCommitSha, pendingGitDiffScrollPath, requestedMergeBaseBranch, diff --git a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanelState.ts b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanelState.ts index 7fc04bbcc6..5db0a0d3e5 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanelState.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanelState.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useEnvironmentWorkStatus } from "../../../hooks/queries/environment-queries"; -import { type GitDiffSelectionOption } from "../ThreadSecondaryPanel"; +import type { GitDiffSelectionOption } from "../GitDiffToolbar"; import { ALL_GIT_DIFF_SELECTION, buildGitDiffSelectionOptions, diff --git a/apps/app/src/components/secondary-panel/launcherRow.tsx b/apps/app/src/components/secondary-panel/launcherRow.tsx index 56926a4d21..a629a20e59 100644 --- a/apps/app/src/components/secondary-panel/launcherRow.tsx +++ b/apps/app/src/components/secondary-panel/launcherRow.tsx @@ -4,7 +4,7 @@ import { COARSE_POINTER_TEXT_SM_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; import { Icon } from "@bb/shared-ui/icon"; -import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; import { cn } from "@bb/shared-ui/lib/utils"; diff --git a/apps/app/src/components/secondary-panel/lazySecondaryPanelComponents.tsx b/apps/app/src/components/secondary-panel/lazySecondaryPanelComponents.tsx index a2b0427a9a..e624aa2f6e 100644 --- a/apps/app/src/components/secondary-panel/lazySecondaryPanelComponents.tsx +++ b/apps/app/src/components/secondary-panel/lazySecondaryPanelComponents.tsx @@ -35,6 +35,7 @@ type ThreadTerminalPanelModule = type BrowserTabDeckModule = typeof import("./BrowserTabDeck"); type NewTabPageModule = typeof import("./NewTabPage"); type FilePreviewModule = typeof import("./FilePreview"); +type ThreadStorageFileTreeModule = typeof import("./ThreadStorageFileTree"); const ThreadSecondaryPanelChunk = lazy(() => import("./ThreadSecondaryPanel").then(({ ThreadSecondaryPanel }) => ({ @@ -59,6 +60,11 @@ const FilePreviewChunk = lazy(() => default: FilePreview, })), ); +const ThreadStorageFileTreeChunk = lazy(() => + import("./ThreadStorageFileTree").then(({ ThreadStorageFileTree }) => ({ + default: ThreadStorageFileTree, + })), +); const WorkspaceFilePreviewTabContentChunk = lazy(() => import("./ThreadSecondaryPanelTabContent").then( ({ WorkspaceFilePreviewTabContent }) => ({ @@ -73,6 +79,13 @@ const HostFilePreviewTabContentChunk = lazy(() => }), ), ); +const HostScopedFilePreviewTabContentChunk = lazy(() => + import("./ThreadSecondaryPanelTabContent").then( + ({ HostScopedFilePreviewTabContent }) => ({ + default: HostScopedFilePreviewTabContent, + }), + ), +); const ProjectFilePreviewTabContentChunk = lazy(() => import("./ThreadSecondaryPanelTabContent").then( ({ ProjectFilePreviewTabContent }) => ({ @@ -89,7 +102,7 @@ const ThreadStorageFilePreviewTabContentChunk = lazy(() => ); /** Generic "content is on its way" body for a panel tab. */ -export function SecondaryPanelContentSkeleton() { +function SecondaryPanelContentSkeleton() { return (
& { /** @@ -228,6 +241,25 @@ export function LazyFilePreview( ); } +/** + * The storage browser's `@pierre/trees` tree. Its model comes from the same + * chunk (`useThreadStorageBrowser` imports it to build the model), so by the + * time a caller has a model to render the chunk is already loaded and the + * fallback shows for at most one commit. + */ +export function LazyThreadStorageFileTree({ + fallback, + ...props +}: ComponentProps & { + fallback: ReactNode; +}) { + return ( + + + + ); +} + export function LazyWorkspaceFilePreviewTabContent( props: ComponentProps< ThreadSecondaryPanelTabContentModule["WorkspaceFilePreviewTabContent"] @@ -252,6 +284,18 @@ export function LazyHostFilePreviewTabContent( ); } +export function LazyHostScopedFilePreviewTabContent( + props: ComponentProps< + ThreadSecondaryPanelTabContentModule["HostScopedFilePreviewTabContent"] + >, +) { + return ( + }> + + + ); +} + export function LazyProjectFilePreviewTabContent( props: ComponentProps< ThreadSecondaryPanelTabContentModule["ProjectFilePreviewTabContent"] diff --git a/apps/app/src/components/secondary-panel/panelToggleControlState.ts b/apps/app/src/components/secondary-panel/panelToggleControlState.ts index 094e4cb14b..324d69c883 100644 --- a/apps/app/src/components/secondary-panel/panelToggleControlState.ts +++ b/apps/app/src/components/secondary-panel/panelToggleControlState.ts @@ -1,10 +1,12 @@ -export type PanelToggleAction = "enter-full-screen" | "exit-full-screen"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; + +type PanelToggleAction = "enter-full-screen" | "exit-full-screen"; /** * Icon names the toggle can render. A subset of the Icon component's `IconName` * union; validity is enforced where the value flows into ``. */ -export type PanelToggleIconName = "Maximize2" | "Minimize2"; +type PanelToggleIconName = "Maximize2" | "Minimize2"; interface PanelToggleActionPresentation { label: string; @@ -37,7 +39,7 @@ const PANEL_TOGGLE_ACTION_PRESENTATION = { }, } as const satisfies Record; -export interface PanelToggleControlState { +interface PanelToggleControlState { action: PanelToggleAction; label: string; isFullScreen: boolean; @@ -45,7 +47,7 @@ export interface PanelToggleControlState { onClick: () => void; } -export interface ResolveConversationCollapseControlArgs { +interface ResolveConversationCollapseControlArgs { isConversationCollapsed: boolean; onToggleConversationCollapse: () => void; } @@ -68,3 +70,31 @@ export function resolveConversationCollapseControl({ onClick: onToggleConversationCollapse, }; } + +/** + * Icon names the right-panel show/hide control can render. A subset of the Icon + * component's `IconName` union; validity is enforced where the value flows into + * ``. + */ +type RightPanelToggleIconName = "PanelBottom" | "PanelRight"; + +/** + * The glyph for every control that shows or hides the right panel. Compact + * viewports present that panel as a bottom drawer (`SecondaryPanelLayout`), so + * the control has to disclose the edge the panel actually opens from. + * + * Trigger sites differ too much in chrome (tooltip, shortcut hint, macOS + * drag region) to share one button, so this resolver is what they share + * instead: pass the presentation a site already tracks, or call + * {@link useRightPanelToggleIconName} when it doesn't track one. + */ +export function getRightPanelToggleIconName( + renderAsDrawer: boolean, +): RightPanelToggleIconName { + return renderAsDrawer ? "PanelBottom" : "PanelRight"; +} + +/** {@link getRightPanelToggleIconName} against the live viewport. */ +export function useRightPanelToggleIconName(): RightPanelToggleIconName { + return getRightPanelToggleIconName(useIsCompactViewport()); +} diff --git a/apps/app/src/components/secondary-panel/rightPanelFileVisuals.ts b/apps/app/src/components/secondary-panel/rightPanelFileVisuals.ts index cd7eca8af8..484c237a32 100644 --- a/apps/app/src/components/secondary-panel/rightPanelFileVisuals.ts +++ b/apps/app/src/components/secondary-panel/rightPanelFileVisuals.ts @@ -1,15 +1,15 @@ import type { IconName } from "@bb/shared-ui/icon"; -export interface RightPanelFileVisual { +interface RightPanelFileVisual { iconName: IconName; label: string; } -export interface ResolveRightPanelFileVisualArgs { +interface ResolveRightPanelFileVisualArgs { path: string; } -export interface GetFileNameFromPathArgs { +interface GetFileNameFromPathArgs { path: string; } diff --git a/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts b/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts deleted file mode 100644 index d6bf7f1d37..0000000000 --- a/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ReactNode } from "react"; - -export interface SecondaryPanelTabReorderRequest { - activeTabId: string; - overTabId: string; -} - -export type SecondaryPanelTabReorderHandler = ( - request: SecondaryPanelTabReorderRequest, -) => void; - -/** - * A single closable tab rendered in the right panel's scrolling tab strip. - */ -export interface SecondaryPanelFileTab { - id: string; - filename: string; - isActive: boolean; - isHidden?: boolean; - isPinned?: boolean; - leadingVisual: ReactNode; - statusLabel: string | null; - onSelect: () => void; - onClose: () => void; -} diff --git a/apps/app/src/components/secondary-panel/secondaryPanelTab.ts b/apps/app/src/components/secondary-panel/secondaryPanelTab.ts new file mode 100644 index 0000000000..eae206d1aa --- /dev/null +++ b/apps/app/src/components/secondary-panel/secondaryPanelTab.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; +import type { SecondaryFileFixedPanelTab } from "@/lib/fixed-panel-tabs-state"; + +export interface SecondaryPanelTabReorderRequest { + activeTabId: string; + overTabId: string; +} + +export type SecondaryPanelTabReorderHandler = ( + request: SecondaryPanelTabReorderRequest, +) => void; + +export interface SecondaryPanelPaneRenderContext { + isFocused: boolean; + onFocusPane: () => void; +} + +/** + * One closable right-panel tab, including its persisted model, chrome, and + * pane-local content. Keeping these together prevents the panel from joining + * parallel representations by id when tabs move between split panes. + */ +export interface SecondaryPanelRenderableTab { + contentFillsRegion?: boolean; + label: string; + isHidden?: boolean; + isPinned?: boolean; + leadingVisual: ReactNode; + onClose: () => void; + onSelect: () => void; + renderContent: (pane: SecondaryPanelPaneRenderContext) => ReactNode; + statusLabel: string | null; + tab: SecondaryFileFixedPanelTab; +} diff --git a/apps/app/src/components/secondary-panel/secondaryPanelTabState.test.ts b/apps/app/src/components/secondary-panel/secondaryPanelTabState.test.ts index 43be95e3c3..cf2df5077d 100644 --- a/apps/app/src/components/secondary-panel/secondaryPanelTabState.test.ts +++ b/apps/app/src/components/secondary-panel/secondaryPanelTabState.test.ts @@ -18,7 +18,7 @@ import { openSecondaryPanelTabInState, reconcileFixedPanelViewTabsInState, replaceNewTabWithSecondaryPanelTabInState, -} from "./secondaryPanelTabState"; +} from "@bb/client-core"; function makeWorkspaceTab(environmentId: string) { return createWorkspaceFilePreviewFixedPanelTab({ diff --git a/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts b/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts new file mode 100644 index 0000000000..639918cac8 --- /dev/null +++ b/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts @@ -0,0 +1,505 @@ +import { describe, expect, it } from "vitest"; +import { MAX_PANES, countPanes, listPanes } from "@/lib/split-layout"; +import { + FIXED_PANEL_TABS_IDLE_EXPIRY_MS, + createGitDiffFixedPanelTab, + createThreadInfoFixedPanelTab, + getFixedPanelTabsStateStorageKey, +} from "@/lib/fixed-panel-tabs-state"; +import { + SIDEBAR_FIXED_DIFF_TAB_ID, + SIDEBAR_FIXED_INFO_TAB_ID, + createSidebarSplitState, + focusSidebarPane, + getSidebarGroupForPane, + isCanonicalSidebarSplitState, + moveSidebarPaneToSide, + moveSidebarTab, + parseSidebarSplitState, + pruneSidebarSplitStorage, + reconcileSidebarSplitState, + reorderSidebarTab, + replaceSidebarTab, + resizeSidebarSplit, + selectSidebarTab, + serializeSidebarSplitState, + sidebarPaneNode, + sidebarSplitStorageKey, + type SidebarSplitState, + type SidebarSplitStorage, +} from "./sidebarSplitLayout"; + +const TABS = [SIDEBAR_FIXED_INFO_TAB_ID, SIDEBAR_FIXED_DIFF_TAB_ID, "file-a"]; + +function createMemoryStorage( + initialEntries: Record, +): SidebarSplitStorage & { has(key: string): boolean } { + const entries = new Map(Object.entries(initialEntries)); + return { + get length() { + return entries.size; + }, + getItem: (key) => entries.get(key) ?? null, + has: (key) => entries.has(key), + key: (index) => [...entries.keys()][index] ?? null, + removeItem: (key) => { + entries.delete(key); + }, + }; +} + +function persistedStateWithPaneCount(count: number): SidebarSplitState { + const groups = Object.fromEntries( + Array.from({ length: count }, (_, index) => { + const groupId = `group-${index}`; + const tabId = `tab-${index}`; + return [groupId, { id: groupId, tabIds: [tabId], activeTabId: tabId }]; + }), + ); + return { + version: 1, + groups, + layout: { + root: { + type: "split", + dir: "row", + sizes: Array.from({ length: count }, () => 1 / count), + children: Array.from({ length: count }, (_, index) => + sidebarPaneNode(`pane-${index}`, `group-${index}`), + ), + }, + focusedPaneId: "pane-0", + }, + }; +} + +function splitOff( + state: ReturnType, + tabId: string, + side: "left" | "right" | "top" | "bottom" = "right", +) { + return moveSidebarTab( + state, + state.layout.focusedPaneId, + tabId, + { paneId: state.layout.focusedPaneId, zone: side }, + { groupId: `group-${tabId}` }, + ); +} + +describe("sidebar split layout", () => { + it("derives fixed sidebar identities from the canonical fixed-panel tabs", () => { + expect(SIDEBAR_FIXED_INFO_TAB_ID).toBe(createThreadInfoFixedPanelTab().id); + expect(SIDEBAR_FIXED_DIFF_TAB_ID).toBe(createGitDiffFixedPanelTab().id); + }); + + it("defaults old or invalid persisted state to the unchanged single pane", () => { + const state = parseSidebarSplitState( + JSON.stringify({ version: 0 }), + TABS, + SIDEBAR_FIXED_INFO_TAB_ID, + ); + expect(countPanes(state.layout.root)).toBe(1); + expect( + getSidebarGroupForPane(state, state.layout.focusedPaneId)?.tabIds, + ).toEqual(TABS); + }); + + it("continues to parse the original raw v1 state", () => { + const split = splitOff( + createSidebarSplitState(TABS, SIDEBAR_FIXED_INFO_TAB_ID), + "file-a", + ); + const parsed = parseSidebarSplitState( + JSON.stringify({ ...split, maximizedPaneId: null }), + TABS, + SIDEBAR_FIXED_INFO_TAB_ID, + ); + expect(parsed).toEqual(split); + expect(parsed).not.toHaveProperty("maximizedPaneId"); + }); + + it("preserves state identity for no-op reconciliation, selection, and focus", () => { + const state = createSidebarSplitState(TABS, SIDEBAR_FIXED_INFO_TAB_ID); + expect( + reconcileSidebarSplitState(state, TABS, SIDEBAR_FIXED_INFO_TAB_ID), + ).toBe(state); + expect( + selectSidebarTab( + state, + state.layout.focusedPaneId, + SIDEBAR_FIXED_INFO_TAB_ID, + ), + ).toBe(state); + expect(focusSidebarPane(state, state.layout.focusedPaneId)).toBe(state); + }); + + it("recognizes only the exact reconstructible unsplit default", () => { + const canonical = createSidebarSplitState(TABS, SIDEBAR_FIXED_INFO_TAB_ID); + expect( + isCanonicalSidebarSplitState(canonical, TABS, SIDEBAR_FIXED_INFO_TAB_ID), + ).toBe(true); + + const differentIdentity = createSidebarSplitState( + TABS, + SIDEBAR_FIXED_INFO_TAB_ID, + { groupId: "group-restored", paneId: "pane-restored" }, + ); + expect( + isCanonicalSidebarSplitState( + differentIdentity, + TABS, + SIDEBAR_FIXED_INFO_TAB_ID, + ), + ).toBe(false); + + const reordered = createSidebarSplitState( + [...TABS].reverse(), + SIDEBAR_FIXED_INFO_TAB_ID, + ); + expect( + isCanonicalSidebarSplitState(reordered, TABS, SIDEBAR_FIXED_INFO_TAB_ID), + ).toBe(false); + }); + + it("prunes split records with the fixed-tab cache's 14-day retention", () => { + const now = 50 * 24 * 60 * 60 * 1000; + const freshThreadId = "thread-fresh"; + const boundaryThreadId = "thread-boundary"; + const expiredThreadId = "thread-expired"; + const missingThreadId = "thread-missing"; + const freshSplitKey = sidebarSplitStorageKey(freshThreadId); + const boundarySplitKey = sidebarSplitStorageKey(boundaryThreadId); + const expiredSplitKey = sidebarSplitStorageKey(expiredThreadId); + const missingSplitKey = sidebarSplitStorageKey(missingThreadId); + const storage = createMemoryStorage({ + [freshSplitKey]: "fresh-layout", + [boundarySplitKey]: "boundary-layout", + [expiredSplitKey]: "expired-layout", + [missingSplitKey]: "orphaned-layout", + [getFixedPanelTabsStateStorageKey({ threadId: freshThreadId })]: + JSON.stringify({ lastUsedAt: now - 1_000 }), + [getFixedPanelTabsStateStorageKey({ threadId: boundaryThreadId })]: + JSON.stringify({ + lastUsedAt: now - FIXED_PANEL_TABS_IDLE_EXPIRY_MS, + }), + [getFixedPanelTabsStateStorageKey({ threadId: expiredThreadId })]: + JSON.stringify({ + lastUsedAt: now - FIXED_PANEL_TABS_IDLE_EXPIRY_MS - 1, + }), + unrelated: "keep-me", + }); + + pruneSidebarSplitStorage({ storage, now }); + + expect(storage.has(freshSplitKey)).toBe(true); + expect(storage.has(boundarySplitKey)).toBe(true); + expect(storage.has(expiredSplitKey)).toBe(false); + expect(storage.has(missingSplitKey)).toBe(false); + expect(storage.has("unrelated")).toBe(true); + }); + + it("rejects persisted layouts that exceed the shared pane cap", () => { + const oversized = persistedStateWithPaneCount(MAX_PANES + 1); + const availableTabIds = Array.from( + { length: MAX_PANES + 1 }, + (_, index) => `tab-${index}`, + ); + const parsed = parseSidebarSplitState( + JSON.stringify(oversized), + availableTabIds, + "tab-0", + ); + expect(isCanonicalSidebarSplitState(parsed, availableTabIds, "tab-0")).toBe( + true, + ); + }); + + it.each([ + { + name: "duplicate pane ids", + mutate: (state: SidebarSplitState) => { + if (state.layout.root.type === "split") { + const second = state.layout.root.children[1]; + if (second?.type === "pane") second.paneId = "pane-0"; + } + }, + }, + { + name: "duplicate group references", + mutate: (state: SidebarSplitState) => { + if (state.layout.root.type === "split") { + state.layout.root.children[1] = sidebarPaneNode("pane-1", "group-0"); + } + }, + }, + { + name: "a stale focused pane", + mutate: (state: SidebarSplitState) => { + state.layout.focusedPaneId = "pane-missing"; + }, + }, + { + name: "a mismatched group key and id", + mutate: (state: SidebarSplitState) => { + const group = state.groups["group-0"]; + if (group !== undefined) group.id = "group-renamed"; + }, + }, + { + name: "an orphan group", + mutate: (state: SidebarSplitState) => { + state.groups.orphan = { + id: "orphan", + tabIds: ["orphan-tab"], + activeTabId: "orphan-tab", + }; + }, + }, + { + name: "non-normalized split sizes", + mutate: (state: SidebarSplitState) => { + if (state.layout.root.type === "split") { + state.layout.root.sizes = [0.75, 0.75]; + } + }, + }, + ])("falls back safely for $name", ({ mutate }) => { + const malformed = persistedStateWithPaneCount(2); + mutate(malformed); + const availableTabIds = ["tab-0", "tab-1"]; + const parsed = parseSidebarSplitState( + JSON.stringify(malformed), + availableTabIds, + "tab-0", + ); + expect(isCanonicalSidebarSplitState(parsed, availableTabIds, "tab-0")).toBe( + true, + ); + }); + + it("round-trips a split and reconciles newly opened tabs into the focused pane", () => { + const split = splitOff( + createSidebarSplitState(TABS, SIDEBAR_FIXED_INFO_TAB_ID), + "file-a", + ); + const restored = parseSidebarSplitState( + serializeSidebarSplitState(split), + [...TABS, "terminal-a"], + "terminal-a", + ); + expect(countPanes(restored.layout.root)).toBe(2); + expect( + getSidebarGroupForPane(restored, restored.layout.focusedPaneId)?.tabIds, + ).toContain("terminal-a"); + }); + + it("keeps a New Tab replacement in its existing split pane", () => { + const newTabId = "new-tab:launcher"; + const terminalTabId = "terminal:term-a:none"; + const split = splitOff( + createSidebarSplitState([SIDEBAR_FIXED_INFO_TAB_ID, newTabId], newTabId), + newTabId, + "bottom", + ); + const replaced = replaceSidebarTab(split, newTabId, terminalTabId); + const reconciled = reconcileSidebarSplitState( + replaced, + [SIDEBAR_FIXED_INFO_TAB_ID, terminalTabId], + terminalTabId, + ); + + expect(countPanes(reconciled.layout.root)).toBe(2); + expect( + getSidebarGroupForPane(reconciled, reconciled.layout.focusedPaneId) + ?.tabIds, + ).toEqual([terminalTabId]); + }); + + it("preserves browser, terminal, and plugin tabs across persistence", () => { + const liveTabIds = [ + SIDEBAR_FIXED_INFO_TAB_ID, + "browser:docs:env-a", + "terminal:term-a:none", + "plugin-panel:side-chat:thread-a", + ]; + let state = splitOff( + createSidebarSplitState(liveTabIds, liveTabIds[1] ?? ""), + liveTabIds[1] ?? "", + "bottom", + ); + const browserPaneId = state.layout.focusedPaneId; + const sourcePane = listPanes(state.layout.root).find( + (pane) => pane.paneId !== browserPaneId, + ); + expect(sourcePane).toBeDefined(); + if (sourcePane === undefined) return; + state = moveSidebarTab( + state, + sourcePane.paneId, + liveTabIds[2] ?? "", + { paneId: browserPaneId, zone: "right" }, + { groupId: "group-terminal" }, + ); + + const restored = parseSidebarSplitState( + serializeSidebarSplitState(state), + liveTabIds, + liveTabIds[2] ?? "", + ); + const restoredTabIds = listPanes(restored.layout.root).flatMap( + (pane) => getSidebarGroupForPane(restored, pane.paneId)?.tabIds ?? [], + ); + expect(new Set(restoredTabIds)).toEqual(new Set(liveTabIds)); + expect(restoredTabIds).toHaveLength(liveTabIds.length); + }); + + it("keeps fixed tabs singleton while removing closed tabs", () => { + const split = splitOff( + createSidebarSplitState(TABS, SIDEBAR_FIXED_INFO_TAB_ID), + SIDEBAR_FIXED_DIFF_TAB_ID, + ); + const duplicate = { + ...split, + groups: Object.fromEntries( + Object.entries(split.groups).map(([id, group]) => [ + id, + { ...group, tabIds: [...group.tabIds, SIDEBAR_FIXED_INFO_TAB_ID] }, + ]), + ), + }; + const reconciled = reconcileSidebarSplitState( + duplicate, + [SIDEBAR_FIXED_INFO_TAB_ID, SIDEBAR_FIXED_DIFF_TAB_ID], + SIDEBAR_FIXED_INFO_TAB_ID, + ); + const allIds = listPanes(reconciled.layout.root).flatMap( + (pane) => getSidebarGroupForPane(reconciled, pane.paneId)?.tabIds ?? [], + ); + expect( + allIds.filter((id) => id === SIDEBAR_FIXED_INFO_TAB_ID), + ).toHaveLength(1); + expect(allIds).not.toContain("file-a"); + const activeTabId = getSidebarGroupForPane( + reconciled, + reconciled.layout.focusedPaneId, + )?.activeTabId; + expect(allIds).toContain(activeTabId); + }); + + it("repairs the surviving active tab after a stale focused pane is pruned", () => { + const split = splitOff( + createSidebarSplitState(TABS, SIDEBAR_FIXED_INFO_TAB_ID), + "file-a", + ); + const reconciled = reconcileSidebarSplitState( + split, + [SIDEBAR_FIXED_INFO_TAB_ID, SIDEBAR_FIXED_DIFF_TAB_ID], + SIDEBAR_FIXED_INFO_TAB_ID, + ); + const survivor = getSidebarGroupForPane( + reconciled, + reconciled.layout.focusedPaneId, + ); + expect(countPanes(reconciled.layout.root)).toBe(1); + expect(survivor?.activeTabId).toBe(SIDEBAR_FIXED_INFO_TAB_ID); + }); + + it("moves one tab, groups on center, and preserves pane-local reorder", () => { + let state = splitOff( + createSidebarSplitState([...TABS, "file-b"], SIDEBAR_FIXED_INFO_TAB_ID), + "file-b", + ); + const destinationPaneId = state.layout.focusedPaneId; + const sourcePaneId = listPanes(state.layout.root).find( + (pane) => pane.paneId !== destinationPaneId, + )?.paneId; + expect(sourcePaneId).toBeDefined(); + if (sourcePaneId === undefined) return; + state = moveSidebarTab( + state, + sourcePaneId, + "file-a", + { paneId: destinationPaneId, zone: "center" }, + { groupId: "unused" }, + ); + state = reorderSidebarTab(state, destinationPaneId, "file-a", "file-b"); + expect(getSidebarGroupForPane(state, destinationPaneId)?.tabIds).toEqual([ + "file-a", + "file-b", + ]); + }); + + it("does not overwrite a restored group when a split id collides", () => { + const state = splitOff( + createSidebarSplitState([...TABS, "file-b"], SIDEBAR_FIXED_INFO_TAB_ID), + "file-a", + ); + const sourcePane = listPanes(state.layout.root).find((pane) => + getSidebarGroupForPane(state, pane.paneId)?.tabIds.includes("file-b"), + ); + expect(sourcePane).toBeDefined(); + if (sourcePane === undefined) return; + const collided = moveSidebarTab( + state, + sourcePane.paneId, + "file-b", + { paneId: sourcePane.paneId, zone: "bottom" }, + { groupId: "group-file-a" }, + ); + expect(collided).toBe(state); + }); + + it("moves panes through the shared split operations", () => { + const split = splitOff( + createSidebarSplitState(TABS, SIDEBAR_FIXED_INFO_TAB_ID), + "file-a", + ); + const panes = listPanes(split.layout.root); + const sourcePaneId = panes[0]?.paneId; + const targetPaneId = panes[1]?.paneId; + expect(sourcePaneId).toBeDefined(); + expect(targetPaneId).toBeDefined(); + if (sourcePaneId === undefined || targetPaneId === undefined) return; + + const moved = moveSidebarPaneToSide( + split, + sourcePaneId, + targetPaneId, + "top", + ); + expect(moved.layout.root.type).toBe("split"); + if (moved.layout.root.type !== "split") return; + expect(moved.layout.root.dir).toBe("col"); + }); + + it("uses the existing split cap and clamps divider fractions", () => { + let state = createSidebarSplitState( + Array.from({ length: MAX_PANES + 1 }, (_, index) => `tab-${index}`), + "tab-0", + ); + for (let index = 1; index <= MAX_PANES; index += 1) { + const sourcePane = listPanes(state.layout.root).find((pane) => + getSidebarGroupForPane(state, pane.paneId)?.tabIds.includes( + `tab-${index}`, + ), + ); + if (sourcePane === undefined) continue; + state = moveSidebarTab( + state, + sourcePane.paneId, + `tab-${index}`, + { paneId: state.layout.focusedPaneId, zone: "bottom" }, + { groupId: `group-${index}` }, + ); + } + expect(countPanes(state.layout.root)).toBe(MAX_PANES); + const resized = resizeSidebarSplit(state, [], 0, 0.01); + if (resized.layout.root.type !== "split") throw new Error("expected split"); + const pairTotal = + (resized.layout.root.sizes[0] ?? 0) + (resized.layout.root.sizes[1] ?? 0); + expect( + (resized.layout.root.sizes[0] ?? 0) / pairTotal, + ).toBeGreaterThanOrEqual(0.15); + }); +}); diff --git a/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts b/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts new file mode 100644 index 0000000000..8884e2e7e9 --- /dev/null +++ b/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts @@ -0,0 +1,770 @@ +import { z } from "zod"; +import { + MAX_PANES, + countPanes, + findPane, + listPanes, + movePane, + removePane, + resizeSplit, + setFocus, + splitPane, + type LayoutNode, + type PaneContent, + type PaneNode, + type SplitLayout, + type SplitPath, + type SplitSide, +} from "@/lib/split-layout"; +import type { SplitDropTarget } from "@/lib/split-drag"; +import { + FIXED_PANEL_TABS_IDLE_EXPIRY_MS, + createGitDiffFixedPanelTab, + createThreadInfoFixedPanelTab, + getFixedPanelTabsStateStorageKey, +} from "@/lib/fixed-panel-tabs-state"; + +const SIDEBAR_SPLIT_LAYOUT_STORAGE_VERSION = 1; +const SIDEBAR_SPLIT_LAYOUT_STORAGE_PREFIX = + "bb.thread.secondaryPanelSplitLayout"; +export const SIDEBAR_FIXED_INFO_TAB_ID = createThreadInfoFixedPanelTab().id; +export const SIDEBAR_FIXED_DIFF_TAB_ID = createGitDiffFixedPanelTab().id; + +const SIDEBAR_SPLIT_PLUGIN_ID = "bb-secondary-panel-split"; +const NORMALIZED_SPLIT_SIZE_EPSILON = 1e-9; + +export interface SidebarSplitStorage { + readonly length: number; + getItem(key: string): string | null; + key(index: number): string | null; + removeItem(key: string): void; +} + +export interface SidebarTabGroup { + id: string; + tabIds: string[]; + activeTabId: string; +} + +export interface SidebarSplitState { + version: typeof SIDEBAR_SPLIT_LAYOUT_STORAGE_VERSION; + groups: Record; + layout: SplitLayout; +} + +interface SidebarSplitIds { + groupId: string; + paneId: string; +} + +function groupContent(groupId: string): PaneContent { + return { + kind: "plugin-panel", + pluginId: SIDEBAR_SPLIT_PLUGIN_ID, + panelPath: groupId, + subPath: "", + }; +} + +export function sidebarPaneNode(paneId: string, groupId: string): PaneNode { + return { type: "pane", paneId, content: groupContent(groupId) }; +} + +export function sidebarPaneGroupId(pane: PaneNode): string | null { + return pane.content.kind === "plugin-panel" && + pane.content.pluginId === SIDEBAR_SPLIT_PLUGIN_ID + ? pane.content.panelPath + : null; +} + +export function createSidebarSplitState( + tabIds: readonly string[], + activeTabId: string, + ids: SidebarSplitIds = { groupId: "group-primary", paneId: "pane-primary" }, +): SidebarSplitState { + const normalizedTabs = [...new Set(tabIds)]; + const resolvedActive = normalizedTabs.includes(activeTabId) + ? activeTabId + : (normalizedTabs[0] ?? SIDEBAR_FIXED_INFO_TAB_ID); + return { + version: SIDEBAR_SPLIT_LAYOUT_STORAGE_VERSION, + groups: { + [ids.groupId]: { + id: ids.groupId, + tabIds: normalizedTabs, + activeTabId: resolvedActive, + }, + }, + layout: { + root: sidebarPaneNode(ids.paneId, ids.groupId), + focusedPaneId: ids.paneId, + }, + }; +} + +function areStringArraysEqual( + first: readonly string[], + second: readonly string[], +): boolean { + return ( + first.length === second.length && + first.every((value, index) => value === second[index]) + ); +} + +function areLayoutNodesEqual(first: LayoutNode, second: LayoutNode): boolean { + if (first.type !== second.type) return false; + if (first.type === "pane" || second.type === "pane") { + return ( + first.type === "pane" && + second.type === "pane" && + first.paneId === second.paneId && + sidebarPaneGroupId(first) === sidebarPaneGroupId(second) + ); + } + return ( + first.dir === second.dir && + first.sizes.length === second.sizes.length && + first.sizes.every((size, index) => size === second.sizes[index]) && + first.children.length === second.children.length && + first.children.every((child, index) => { + const otherChild = second.children[index]; + return otherChild !== undefined && areLayoutNodesEqual(child, otherChild); + }) + ); +} + +function areSidebarSplitStatesEqual( + first: SidebarSplitState, + second: SidebarSplitState, +): boolean { + const firstGroupIds = Object.keys(first.groups); + const secondGroupIds = Object.keys(second.groups); + if ( + first.version !== second.version || + first.layout.focusedPaneId !== second.layout.focusedPaneId || + !areStringArraysEqual(firstGroupIds, secondGroupIds) || + !areLayoutNodesEqual(first.layout.root, second.layout.root) + ) { + return false; + } + return firstGroupIds.every((groupId) => { + const firstGroup = first.groups[groupId]; + const secondGroup = second.groups[groupId]; + return ( + firstGroup !== undefined && + secondGroup !== undefined && + firstGroup.id === secondGroup.id && + firstGroup.activeTabId === secondGroup.activeTabId && + areStringArraysEqual(firstGroup.tabIds, secondGroup.tabIds) + ); + }); +} + +function preserveSidebarSplitStateIdentity( + current: SidebarSplitState, + next: SidebarSplitState, +): SidebarSplitState { + return areSidebarSplitStatesEqual(current, next) ? current : next; +} + +/** + * True only for the exact state reconstructed when no sidebar split has ever + * been made. Single-pane states produced by recombination may carry a distinct + * tab order or identity and therefore remain persistence-worthy. + */ +export function isCanonicalSidebarSplitState( + state: SidebarSplitState, + availableTabIds: readonly string[], + activeTabId: string, +): boolean { + return areSidebarSplitStatesEqual( + state, + createSidebarSplitState(availableTabIds, activeTabId), + ); +} + +export function getSidebarGroupForPane( + state: SidebarSplitState, + paneId: string, +): SidebarTabGroup | null { + const pane = findPane(state.layout.root, paneId); + const groupId = pane === null ? null : sidebarPaneGroupId(pane); + return groupId === null ? null : (state.groups[groupId] ?? null); +} + +export function selectSidebarTab( + state: SidebarSplitState, + paneId: string, + tabId: string, +): SidebarSplitState { + const pane = findPane(state.layout.root, paneId); + const groupId = pane === null ? null : sidebarPaneGroupId(pane); + const group = groupId === null ? undefined : state.groups[groupId]; + if ( + groupId === null || + group === undefined || + !group.tabIds.includes(tabId) + ) { + return state; + } + if (group.activeTabId === tabId && state.layout.focusedPaneId === paneId) { + return state; + } + return { + ...state, + groups: { + ...state.groups, + [groupId]: { ...group, activeTabId: tabId }, + }, + layout: setFocus(state.layout, paneId), + }; +} + +export function focusSidebarPane( + state: SidebarSplitState, + paneId: string, +): SidebarSplitState { + if (findPane(state.layout.root, paneId) === null) return state; + if (state.layout.focusedPaneId === paneId) { + return state; + } + return { + ...state, + layout: setFocus(state.layout, paneId), + }; +} + +/** + * Keeps a one-for-one tab replacement in the pane that owned the old active + * tab. New Tab launchers use this when they become a Browser or Terminal tab. + */ +export function replaceSidebarTab( + state: SidebarSplitState, + previousTabId: string, + nextTabId: string, +): SidebarSplitState { + if (previousTabId === nextTabId) return state; + const groups = Object.values(state.groups); + if (groups.some((group) => group.tabIds.includes(nextTabId))) return state; + const owner = groups.find((group) => group.tabIds.includes(previousTabId)); + if (owner === undefined) return state; + return { + ...state, + groups: { + ...state.groups, + [owner.id]: { + ...owner, + tabIds: owner.tabIds.map((tabId) => + tabId === previousTabId ? nextTabId : tabId, + ), + activeTabId: + owner.activeTabId === previousTabId ? nextTabId : owner.activeTabId, + }, + }, + }; +} + +export function reorderSidebarTab( + state: SidebarSplitState, + paneId: string, + activeTabId: string, + overTabId: string, +): SidebarSplitState { + const pane = findPane(state.layout.root, paneId); + const groupId = pane === null ? null : sidebarPaneGroupId(pane); + const group = groupId === null ? undefined : state.groups[groupId]; + if ( + groupId === null || + group === undefined || + !group.tabIds.includes(activeTabId) || + !group.tabIds.includes(overTabId) || + activeTabId === overTabId + ) { + return state; + } + const from = group.tabIds.indexOf(activeTabId); + const to = group.tabIds.indexOf(overTabId); + const tabIds = [...group.tabIds]; + const [moved] = tabIds.splice(from, 1); + if (moved === undefined) return state; + tabIds.splice(to, 0, moved); + return { + ...state, + groups: { ...state.groups, [groupId]: { ...group, tabIds } }, + }; +} + +export function moveSidebarTab( + state: SidebarSplitState, + sourcePaneId: string, + tabId: string, + target: SplitDropTarget, + ids: Pick, +): SidebarSplitState { + const sourcePane = findPane(state.layout.root, sourcePaneId); + const targetPane = findPane(state.layout.root, target.paneId); + if (sourcePane === null || targetPane === null) return state; + const sourceGroupId = sidebarPaneGroupId(sourcePane); + const targetGroupId = sidebarPaneGroupId(targetPane); + const sourceGroup = + sourceGroupId === null ? undefined : state.groups[sourceGroupId]; + const targetGroup = + targetGroupId === null ? undefined : state.groups[targetGroupId]; + if ( + sourceGroupId === null || + targetGroupId === null || + sourceGroup === undefined || + targetGroup === undefined || + !sourceGroup.tabIds.includes(tabId) + ) { + return state; + } + + if (target.zone === "center") { + if (sourcePaneId === target.paneId) return state; + const groups = { ...state.groups }; + groups[targetGroupId] = { + ...targetGroup, + tabIds: [...targetGroup.tabIds.filter((id) => id !== tabId), tabId], + activeTabId: tabId, + }; + if (sourceGroup.tabIds.length === 1) { + delete groups[sourceGroupId]; + const layout = removePane(state.layout, sourcePaneId); + return { + ...state, + groups, + layout: setFocus(layout, target.paneId), + }; + } + const remainingTabs = sourceGroup.tabIds.filter((id) => id !== tabId); + groups[sourceGroupId] = { + ...sourceGroup, + tabIds: remainingTabs, + activeTabId: + sourceGroup.activeTabId === tabId + ? (remainingTabs[0] ?? targetGroup.activeTabId) + : sourceGroup.activeTabId, + }; + return { + ...state, + groups, + layout: setFocus(state.layout, target.paneId), + }; + } + + if (sourceGroup.tabIds.length === 1) { + const layout = movePane( + state.layout, + sourcePaneId, + target.paneId, + target.zone, + ); + return layout === state.layout ? state : { ...state, layout }; + } + if (countPanes(state.layout.root) >= MAX_PANES) return state; + if (state.groups[ids.groupId] !== undefined) return state; + + const remainingTabs = sourceGroup.tabIds.filter((id) => id !== tabId); + return { + ...state, + groups: { + ...state.groups, + [sourceGroupId]: { + ...sourceGroup, + tabIds: remainingTabs, + activeTabId: + sourceGroup.activeTabId === tabId + ? (remainingTabs[0] ?? sourceGroup.activeTabId) + : sourceGroup.activeTabId, + }, + [ids.groupId]: { + id: ids.groupId, + tabIds: [tabId], + activeTabId: tabId, + }, + }, + layout: splitPane( + state.layout, + target.paneId, + target.zone, + groupContent(ids.groupId), + ), + }; +} + +function removeEmptySidebarPane( + state: SidebarSplitState, + paneId: string, +): SidebarSplitState { + if (countPanes(state.layout.root) <= 1) return state; + const pane = findPane(state.layout.root, paneId); + const closedGroupId = pane === null ? null : sidebarPaneGroupId(pane); + const closedGroup = + closedGroupId === null ? undefined : state.groups[closedGroupId]; + if ( + closedGroupId === null || + closedGroup === undefined || + closedGroup.tabIds.length > 0 + ) { + return state; + } + + const groups = { ...state.groups }; + delete groups[closedGroupId]; + return { ...state, groups, layout: removePane(state.layout, paneId) }; +} + +export function moveSidebarPaneToSide( + state: SidebarSplitState, + paneId: string, + targetPaneId: string, + side: SplitSide, +): SidebarSplitState { + const layout = movePane(state.layout, paneId, targetPaneId, side); + return layout === state.layout ? state : { ...state, layout }; +} + +export function resizeSidebarSplit( + state: SidebarSplitState, + path: SplitPath, + childIndex: number, + fraction: number, +): SidebarSplitState { + const layout = resizeSplit(state.layout, path, childIndex, fraction); + return layout === state.layout ? state : { ...state, layout }; +} + +/** + * Reconciles persisted pane membership with the currently open sidebar tabs. + * Existing ownership/order wins; newly opened tabs join the focused pane; + * closed or duplicated ids disappear. A stale/invalid layout falls back to the + * unchanged single-pane treatment instead of stranding content. + */ +export function reconcileSidebarSplitState( + state: SidebarSplitState, + availableTabIds: readonly string[], + activeTabId: string, +): SidebarSplitState { + const available = [...new Set(availableTabIds)]; + if (available.length === 0) return state; + const allowed = new Set(available); + const seen = new Set(); + let next = state; + const groups = { ...state.groups }; + + for (const pane of listPanes(state.layout.root)) { + const groupId = sidebarPaneGroupId(pane); + const group = groupId === null ? undefined : groups[groupId]; + if (groupId === null || group === undefined) { + return preserveSidebarSplitStateIdentity( + state, + createSidebarSplitState(available, activeTabId), + ); + } + const tabIds = group.tabIds.filter((id) => { + if (!allowed.has(id) || seen.has(id)) return false; + seen.add(id); + return true; + }); + groups[groupId] = { + ...group, + tabIds, + activeTabId: tabIds.includes(group.activeTabId) + ? group.activeTabId + : (tabIds[0] ?? activeTabId), + }; + } + + next = { ...state, groups }; + for (const pane of listPanes(next.layout.root)) { + const groupId = sidebarPaneGroupId(pane); + const group = groupId === null ? undefined : next.groups[groupId]; + if (group !== undefined && group.tabIds.length === 0) { + if (countPanes(next.layout.root) === 1) break; + next = removeEmptySidebarPane(next, pane.paneId); + } + } + + const missing = available.filter((id) => !seen.has(id)); + const focusedGroup = getSidebarGroupForPane(next, next.layout.focusedPaneId); + if (focusedGroup === null) + return preserveSidebarSplitStateIdentity( + state, + createSidebarSplitState(available, activeTabId), + ); + if (missing.length > 0 || focusedGroup.tabIds.length === 0) { + next = { + ...next, + groups: { + ...next.groups, + [focusedGroup.id]: { + ...focusedGroup, + tabIds: [...focusedGroup.tabIds, ...missing], + activeTabId: + focusedGroup.tabIds.length === 0 + ? activeTabId + : focusedGroup.activeTabId, + }, + }, + }; + } + for (const pane of listPanes(next.layout.root)) { + const groupId = sidebarPaneGroupId(pane); + const group = groupId === null ? undefined : next.groups[groupId]; + if ( + groupId !== null && + group !== undefined && + !group.tabIds.includes(group.activeTabId) + ) { + const fallbackActiveTabId = group.tabIds[0]; + if (fallbackActiveTabId === undefined) { + return preserveSidebarSplitStateIdentity( + state, + createSidebarSplitState(available, activeTabId), + ); + } + next = { + ...next, + groups: { + ...next.groups, + [groupId]: { ...group, activeTabId: fallbackActiveTabId }, + }, + }; + } + } + return preserveSidebarSplitStateIdentity(state, next); +} + +const paneContentSchema = z + .object({ + kind: z.literal("plugin-panel"), + pluginId: z.literal(SIDEBAR_SPLIT_PLUGIN_ID), + panelPath: z.string().min(1), + subPath: z.literal(""), + }) + .strict(); +const paneNodeSchema = z + .object({ + type: z.literal("pane"), + paneId: z.string().min(1), + content: paneContentSchema, + }) + .strict(); +const layoutNodeSchema: z.ZodType = z.lazy(() => + z.union([ + paneNodeSchema, + z + .object({ + type: z.literal("split"), + dir: z.enum(["row", "col"]), + sizes: z.array(z.number().positive()).min(2), + children: z.array(layoutNodeSchema).min(2), + }) + .strict() + .refine((node) => node.sizes.length === node.children.length), + ]), +); + +function hasNormalizedSplitSizes(node: LayoutNode): boolean { + if (node.type === "pane") return true; + const total = node.sizes.reduce((sum, size) => sum + size, 0); + return ( + Math.abs(total - 1) <= NORMALIZED_SPLIT_SIZE_EPSILON && + node.children.every(hasNormalizedSplitSizes) + ); +} + +const sidebarSplitStateSchema = z + .object({ + version: z.literal(SIDEBAR_SPLIT_LAYOUT_STORAGE_VERSION), + groups: z.record( + z.string(), + z + .object({ + id: z.string().min(1), + tabIds: z.array(z.string().min(1)).min(1), + activeTabId: z.string().min(1), + }) + .strict(), + ), + layout: z + .object({ + root: layoutNodeSchema, + focusedPaneId: z.string().min(1), + }) + .strict(), + // Layouts written by the original implementation always included this + // unused field. Accept and discard it while reading existing v1 storage. + maximizedPaneId: z.string().min(1).nullable().optional(), + }) + .strict() + .superRefine((state, context) => { + const panes = listPanes(state.layout.root); + const paneIds = panes.map((pane) => pane.paneId); + const groupIds = panes.map(sidebarPaneGroupId); + const storedGroupIds = Object.keys(state.groups); + + if (panes.length > MAX_PANES) { + context.addIssue({ + code: "custom", + message: `A sidebar split supports at most ${MAX_PANES} panes`, + path: ["layout", "root"], + }); + } + if (new Set(paneIds).size !== paneIds.length) { + context.addIssue({ + code: "custom", + message: "Sidebar pane IDs must be unique", + path: ["layout", "root"], + }); + } + if (!paneIds.includes(state.layout.focusedPaneId)) { + context.addIssue({ + code: "custom", + message: "The focused sidebar pane must exist", + path: ["layout", "focusedPaneId"], + }); + } + if (!hasNormalizedSplitSizes(state.layout.root)) { + context.addIssue({ + code: "custom", + message: "Sidebar split sizes must be normalized", + path: ["layout", "root"], + }); + } + + const referencedGroupIds = groupIds.filter( + (groupId): groupId is string => groupId !== null, + ); + if ( + referencedGroupIds.length !== panes.length || + new Set(referencedGroupIds).size !== referencedGroupIds.length + ) { + context.addIssue({ + code: "custom", + message: "Each sidebar pane must reference one unique tab group", + path: ["layout", "root"], + }); + } + if ( + referencedGroupIds.length !== storedGroupIds.length || + referencedGroupIds.some((groupId) => state.groups[groupId] === undefined) + ) { + context.addIssue({ + code: "custom", + message: "Sidebar tab groups must map one-to-one to panes", + path: ["groups"], + }); + } + for (const [groupKey, group] of Object.entries(state.groups)) { + if (group.id !== groupKey) { + context.addIssue({ + code: "custom", + message: "Sidebar tab group keys must match their IDs", + path: ["groups", groupKey, "id"], + }); + } + if (!group.tabIds.includes(group.activeTabId)) { + context.addIssue({ + code: "custom", + message: "A sidebar group's active tab must belong to that group", + path: ["groups", groupKey, "activeTabId"], + }); + } + } + }) + .transform( + (storedState): SidebarSplitState => ({ + version: storedState.version, + groups: storedState.groups, + layout: storedState.layout, + }), + ); + +export function sidebarSplitStorageKey(panelStateId: string): string { + return `${SIDEBAR_SPLIT_LAYOUT_STORAGE_PREFIX}.${panelStateId}`; +} + +function getFixedPanelTabsLastUsedAt( + storedValue: string | null, +): number | null { + if (storedValue === null) return null; + try { + const parsed: unknown = JSON.parse(storedValue); + if (typeof parsed !== "object" || parsed === null) return null; + const lastUsedAt = Reflect.get(parsed, "lastUsedAt"); + return typeof lastUsedAt === "number" && + Number.isInteger(lastUsedAt) && + lastUsedAt >= 0 + ? lastUsedAt + : null; + } catch { + return null; + } +} + +/** + * Removes sidebar layouts when their owning fixed-tab record is absent, + * malformed, or older than the fixed-tab cache's established idle lifetime. + * The layout stays in its current raw v1 format; retention metadata continues + * to have one owner in the fixed-tab record. + */ +export function pruneSidebarSplitStorage({ + storage, + now, +}: { + storage: SidebarSplitStorage; + now: number; +}): void { + const splitKeys: string[] = []; + const keyPrefix = `${SIDEBAR_SPLIT_LAYOUT_STORAGE_PREFIX}.`; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith(keyPrefix)) splitKeys.push(key); + } + + for (const splitKey of splitKeys) { + const panelStateId = splitKey.slice(keyPrefix.length); + const fixedPanelTabsKey = getFixedPanelTabsStateStorageKey({ + threadId: panelStateId, + }); + const lastUsedAt = getFixedPanelTabsLastUsedAt( + storage.getItem(fixedPanelTabsKey), + ); + if ( + panelStateId.length === 0 || + lastUsedAt === null || + now - lastUsedAt > FIXED_PANEL_TABS_IDLE_EXPIRY_MS + ) { + storage.removeItem(splitKey); + } + } +} + +export function parseSidebarSplitState( + storedValue: string | null, + availableTabIds: readonly string[], + activeTabId: string, +): SidebarSplitState { + if (storedValue !== null) { + try { + const parsed = sidebarSplitStateSchema.safeParse(JSON.parse(storedValue)); + if (parsed.success) { + return reconcileSidebarSplitState( + parsed.data, + availableTabIds, + activeTabId, + ); + } + } catch { + // Corrupt or pre-versioned state falls through to the compatible default. + } + } + return createSidebarSplitState(availableTabIds, activeTabId); +} + +export function serializeSidebarSplitState(state: SidebarSplitState): string { + return JSON.stringify(state); +} diff --git a/apps/app/src/components/secondary-panel/terminalPanelTabs.test.ts b/apps/app/src/components/secondary-panel/terminalPanelTabs.test.ts index fa80a17c45..4bc95f33aa 100644 --- a/apps/app/src/components/secondary-panel/terminalPanelTabs.test.ts +++ b/apps/app/src/components/secondary-panel/terminalPanelTabs.test.ts @@ -7,7 +7,6 @@ import { } from "@/lib/fixed-panel-tabs-state"; import { buildTerminalSyncedSecondaryFileTabs, - findActiveTerminalIdInSecondaryFileTabs, getRetainedTerminalTabId, pruneTerminalTabsForSessions, syncTerminalTabsInFixedPanelState, @@ -216,37 +215,6 @@ describe("terminalPanelTabs", () => { ]); }); - it("finds the active terminal id only for displayed terminal tabs", () => { - const terminalTab = createTerminalFixedPanelTab({ terminalId: "term_1" }); - const fileTab = createHostFilePreviewFixedPanelTab({ - environmentId: "env_1", - tab: { - lineRange: null, - path: "/workspace/file.ts", - }, - threadId: "thr_1", - }); - - expect( - findActiveTerminalIdInSecondaryFileTabs({ - activeTabId: terminalTab.id, - tabs: [fileTab, terminalTab], - }), - ).toBe("term_1"); - expect( - findActiveTerminalIdInSecondaryFileTabs({ - activeTabId: fileTab.id, - tabs: [fileTab, terminalTab], - }), - ).toBeNull(); - expect( - findActiveTerminalIdInSecondaryFileTabs({ - activeTabId: "terminal:term_stale", - tabs: [fileTab, terminalTab], - }), - ).toBeNull(); - }); - it("syncs missing server terminal sessions into fixed panel state", () => { const fileTab = createHostFilePreviewFixedPanelTab({ environmentId: "env_1", diff --git a/apps/app/src/components/secondary-panel/terminalPanelTabs.ts b/apps/app/src/components/secondary-panel/terminalPanelTabs.ts index 3d55dec060..67424301a5 100644 --- a/apps/app/src/components/secondary-panel/terminalPanelTabs.ts +++ b/apps/app/src/components/secondary-panel/terminalPanelTabs.ts @@ -14,11 +14,6 @@ interface BuildTerminalSyncedSecondaryFileTabsArgs { terminalSessions: readonly TerminalSession[]; } -interface FindActiveTerminalIdInSecondaryFileTabsArgs { - activeTabId: string | null; - tabs: readonly SecondaryFileFixedPanelTab[]; -} - interface SyncTerminalTabsInFixedPanelStateArgs { retainedTerminalId: string | null; state: FixedPanelTabsState; @@ -117,23 +112,6 @@ export function buildTerminalSyncedSecondaryFileTabs({ return syncedTabs; } -export function findActiveTerminalIdInSecondaryFileTabs({ - activeTabId, - tabs, -}: FindActiveTerminalIdInSecondaryFileTabsArgs): string | null { - if (activeTabId === null) { - return null; - } - - for (const tab of tabs) { - if (tab.id === activeTabId && tab.kind === "terminal") { - return tab.terminalId; - } - } - - return null; -} - export function syncTerminalTabsInFixedPanelState({ retainedTerminalId, state, diff --git a/apps/app/src/components/secondary-panel/thread-info-fixed-tab-navigation.ts b/apps/app/src/components/secondary-panel/thread-info-fixed-tab-navigation.ts new file mode 100644 index 0000000000..357be3a214 --- /dev/null +++ b/apps/app/src/components/secondary-panel/thread-info-fixed-tab-navigation.ts @@ -0,0 +1,20 @@ +import type { AppFixedTabDestination } from "@/lib/app-fixed-tab-navigation"; +import type { AppFixedTabReference } from "@/lib/app-navigation-host"; + +export const THREAD_INFO_FIXED_TAB_REFERENCE: AppFixedTabReference = { + ownerId: "core:thread-info", + tabId: "info", +}; + +export function createThreadInfoFixedTabDestination( + open: () => void, +): AppFixedTabDestination { + return { + tab: THREAD_INFO_FIXED_TAB_REFERENCE, + open(target) { + if (target !== undefined) return false; + open(); + return true; + }, + }; +} diff --git a/apps/app/src/components/secondary-panel/threadRecentItems.ts b/apps/app/src/components/secondary-panel/threadRecentItems.ts index 795810bb83..50fe7155af 100644 --- a/apps/app/src/components/secondary-panel/threadRecentItems.ts +++ b/apps/app/src/components/secondary-panel/threadRecentItems.ts @@ -5,10 +5,10 @@ import { atomFamily } from "jotai-family"; import { z } from "zod"; import { createLocalStorageSyncStorage } from "@/lib/browser-storage"; -export const THREAD_RECENT_ITEMS_STORAGE_PREFIX = "bb.thread.recentItems"; -export const THREAD_RECENT_ITEMS_STORAGE_VERSION = 1; +const THREAD_RECENT_ITEMS_STORAGE_PREFIX = "bb.thread.recentItems"; +const THREAD_RECENT_ITEMS_STORAGE_VERSION = 1; /** How many recent items we persist per thread before dropping the oldest. */ -export const THREAD_RECENT_ITEMS_MAX_STORED = 24; +const THREAD_RECENT_ITEMS_MAX_STORED = 24; /** How many recent rows the launcher shows before the "Show more" toggle. */ export const THREAD_RECENT_ITEMS_VISIBLE_LIMIT = 6; @@ -18,7 +18,7 @@ export const THREAD_RECENT_ITEMS_VISIBLE_LIMIT = 6; * a previewable file path, so a recent row reopens through the exact same * open-in-panel path as a file-search result. */ -export type RecentItemSource = "workspace" | "thread-storage"; +type RecentItemSource = "workspace" | "thread-storage"; export interface ThreadRecentItem { source: RecentItemSource; diff --git a/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.ts b/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.ts index 78e36aed5d..85eed4f3c9 100644 --- a/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.ts +++ b/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.ts @@ -11,24 +11,12 @@ type ThreadSecondaryPanelThreadId = | null | undefined; -interface ThreadSecondaryPanelStorageKeyArgs { - prefix: string; - threadId: ResolvedThreadSecondaryPanelThreadId; -} - -function getThreadSecondaryPanelStorageKey({ - prefix, - threadId, -}: ThreadSecondaryPanelStorageKeyArgs): string { - return `${prefix}-${encodeURIComponent(threadId)}`; -} - /** * User's preferred secondary panel width as a percentage of the surrounding * PanelGroup. Persisted across reloads. The default (50) is used when the * panel opens for the first time. */ -export const DEFAULT_SECONDARY_PANEL_WIDTH_PERCENT = 50; +const DEFAULT_SECONDARY_PANEL_WIDTH_PERCENT = 50; const secondaryPanelWidthStorage = createLocalStorageSyncStorage({ parse: (storedValue, initialValue) => { if (storedValue === null) return initialValue; @@ -74,27 +62,12 @@ const THREAD_CONVERSATION_COLLAPSED_STORAGE_PREFIX = * Persisted per thread; only takes effect while the secondary panel is open on * a wide viewport — see ThreadDetailSecondaryContent for the gating. */ -interface ThreadConversationCollapsedStorageKeyArgs { - threadId: ResolvedThreadSecondaryPanelThreadId; -} - -export function getThreadConversationCollapsedStorageKey({ - threadId, -}: ThreadConversationCollapsedStorageKeyArgs): string { - return getThreadSecondaryPanelStorageKey({ - prefix: THREAD_CONVERSATION_COLLAPSED_STORAGE_PREFIX, - threadId, - }); -} - -const conversationCollapsedStorage = threadSecondaryPanelBooleanStorage; - const threadConversationCollapsedAtomFamily = atomFamily( (threadId: ResolvedThreadSecondaryPanelThreadId) => atomWithStorage( - getThreadConversationCollapsedStorageKey({ threadId }), + `${THREAD_CONVERSATION_COLLAPSED_STORAGE_PREFIX}-${encodeURIComponent(threadId)}`, false, - conversationCollapsedStorage, + threadSecondaryPanelBooleanStorage, { getOnInit: true }, ), ); diff --git a/apps/app/src/components/secondary-panel/useSecondaryPanelResize.ts b/apps/app/src/components/secondary-panel/useSecondaryPanelResize.ts index e64494d29d..5f510cb389 100644 --- a/apps/app/src/components/secondary-panel/useSecondaryPanelResize.ts +++ b/apps/app/src/components/secondary-panel/useSecondaryPanelResize.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useAtomValue, useSetAtom } from "jotai"; import type { ImperativePanelHandle } from "react-resizable-panels"; import { useResizeObserver } from "usehooks-ts"; -import { applyResizeCursor, clearResizeCursor } from "@/lib/resizeCursor"; import { secondaryPanelWidthPercentAtom, threadSecondaryPanelResizingAtom, @@ -70,7 +69,6 @@ export function useSecondaryPanelResize({ isSecondaryPanelDraggingRef.current = false; setIsSecondaryPanelDragging(false); setIsResizing(false); - clearResizeCursor(); // Drag finished — persist the user's chosen width. if (lastSecondaryPanelSizeRef.current > 0) { @@ -84,8 +82,9 @@ export function useSecondaryPanelResize({ if (isDragging) { isSecondaryPanelDraggingRef.current = true; setIsSecondaryPanelDragging(true); + // The drag-guard overlay that `isResizing` mounts carries the + // resize cursor; nothing is written on body. setIsResizing(true); - applyResizeCursor("horizontal"); return; } @@ -101,7 +100,6 @@ export function useSecondaryPanelResize({ } isSecondaryPanelDraggingRef.current = false; setIsResizing(false); - clearResizeCursor(); }, [setIsResizing], ); diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index 4ecbdf198c..f34d29a863 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -15,6 +15,7 @@ import { serializeFixedPanelTabsState, FIXED_PANEL_TABS_STATE_STORAGE_VERSION, } from "@/lib/fixed-panel-tabs-state"; +import { buildFileOpenerPanelTab } from "@/components/plugin/file-opener-tabs"; import { useThreadFileTabs } from "./useThreadFileTabs"; import { resetPluginSlotStoreForTest, @@ -202,6 +203,77 @@ describe("useThreadFileTabs terminal pruning", () => { }); describe("useThreadFileTabs active owners", () => { + it("restores a project opener from its persisted file source", () => { + const panelStateId = "restored-project-file-opener"; + const openerTab = buildFileOpenerPanelTab( + { id: "pdf", pluginId: "pdf-preview" }, + { + path: "reports/quarterly.pdf", + source: { + kind: "workspace", + threadId: null, + environmentId: null, + projectId: "proj_opened", + experimental_hostId: "host_opened", + }, + }, + { + environmentId: null, + kind: "workspace-file-preview", + projectId: "proj_opened", + tab: { + lineRange: null, + path: "reports/quarterly.pdf", + source: { kind: "working-tree" }, + statusLabel: null, + }, + threadId: null, + }, + ); + window.localStorage.setItem( + getFixedPanelTabsStateStorageKey({ threadId: panelStateId }), + serializeFixedPanelTabsState({ + state: createEmptyFixedPanelTabsState({ + secondary: { + activeTabId: openerTab.id, + isOpen: true, + tabs: [openerTab], + }, + lastUsedAt: Date.now(), + }), + }), + ); + + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId, + syncThreadId: null, + environmentId: "env_selected", + preserveWorkspaceTabsAcrossContexts: true, + projectHostId: "host_selected", + projectId: "proj_selected", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + expect(result.current.activeFileOpenerFile).toEqual({ + path: "reports/quarterly.pdf", + source: { + kind: "workspace", + threadId: null, + environmentId: null, + projectId: "proj_opened", + experimental_hostId: "host_opened", + }, + }); + expect(result.current.activeWorkspaceFileEnvironmentId).toBeNull(); + expect(result.current.activeWorkspaceFileProjectId).toBe("proj_opened"); + expect(result.current.activeWorkspaceFilePath).toBe( + "reports/quarterly.pdf", + ); + }); + it("returns owner ids for an active restored host file tab", () => { const threadId = "root-compose-ownerful"; const hostTab = createHostFilePreviewFixedPanelTab({ @@ -535,6 +607,7 @@ describe("useThreadFileTabs file opener diversion", () => { expect(result.current.activeFileOpenerOwner).toEqual({ kind: "host-file-preview", environmentId: "env_1", + hostId: null, tab: { lineRange: { startLineNumber: 11, endLineNumber: 12 }, path: "/tmp/readme.md", @@ -615,6 +688,139 @@ describe("useThreadFileTabs file opener diversion", () => { expect(result.current.activeWorkspaceFilePath).toBe("src/index.ts"); }); + // File search replaces the new-tab screen rather than appending a tab, but + // it must use the same opener resolution as links and `bb thread open`. + it("diverts a workspace file picked from the file search", () => { + registerNotesOpener(); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "opener-search", + syncThreadId: "opener-search", + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + act(() => result.current.openTab({ kind: "new-tab" })); + act(() => + result.current.selectFileSearchResult({ + source: "workspace", + path: "notes/todo.md", + }), + ); + + expect(result.current.activePluginPanelTab).toMatchObject({ + kind: "plugin-panel", + pluginId: "notes", + actionId: "file-opener:editor", + title: "todo.md", + }); + const params = JSON.parse( + result.current.activePluginPanelTab?.paramsJson ?? "null", + ) as { path: string; source: { kind: string; environmentId: string | null } }; + expect(params.path).toBe("notes/todo.md"); + expect(params.source).toMatchObject({ + kind: "workspace", + environmentId: "env_1", + }); + // The new-tab screen is replaced, not appended to. + expect(result.current.isNewTabActive).toBe(false); + expect( + result.current.orderedSecondaryFileTabs.map((tab) => tab.kind), + ).toEqual(["plugin-panel"]); + }); + + it("diverts a thread-storage file picked from the file search", () => { + registerNotesOpener(); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "opener-storage-search", + syncThreadId: "thr_storage_search", + environmentId: "env_1", + storageFiles: [{ path: "artifacts/notes.md" }], + terminalSessions: undefined, + }), + ); + + act(() => result.current.openTab({ kind: "new-tab" })); + act(() => + result.current.selectFileSearchResult({ + source: "thread-storage", + path: "artifacts/notes.md", + }), + ); + + expect(result.current.activePluginPanelTab).toMatchObject({ + kind: "plugin-panel", + pluginId: "notes", + actionId: "file-opener:editor", + title: "notes.md", + fileOpenerOwner: { + kind: "thread-storage-file-preview", + environmentId: "env_1", + threadId: "thr_storage_search", + tab: { path: "artifacts/notes.md" }, + }, + }); + expect(result.current.isNewTabActive).toBe(false); + expect( + result.current.orderedSecondaryFileTabs.map((tab) => tab.kind), + ).toEqual(["plugin-panel"]); + }); + + it("keeps the built-in preview for an unmatched file search extension", () => { + registerNotesOpener(); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "opener-search-unmatched", + syncThreadId: "opener-search-unmatched", + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + act(() => result.current.openTab({ kind: "new-tab" })); + act(() => + result.current.selectFileSearchResult({ + source: "workspace", + path: "src/main.rs", + }), + ); + + expect(result.current.activePluginPanelTab).toBeNull(); + expect(result.current.activeWorkspaceFilePath).toBe("src/main.rs"); + }); + + it("honors a pinned built-in preference from the file search", () => { + window.localStorage.setItem( + "bb.fileOpenerByExtension", + JSON.stringify({ md: "__builtin__" }), + ); + registerNotesOpener(); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "opener-search-pinned", + syncThreadId: "opener-search-pinned", + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + act(() => result.current.openTab({ kind: "new-tab" })); + act(() => + result.current.selectFileSearchResult({ + source: "workspace", + path: "notes/todo.md", + }), + ); + + expect(result.current.activePluginPanelTab).toBeNull(); + expect(result.current.activeWorkspaceFilePath).toBe("notes/todo.md"); + }); + it("falls back to the built-in preview when no opener is registered", () => { const { result } = renderThreadHook(() => useThreadFileTabs({ diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index 0a8f61d76e..7fd94a841e 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -24,19 +24,20 @@ import { useFileOpenerPreferenceValue } from "@/lib/file-opener-preference"; import { createFileOpenerTabForRequest, fileOpenerIdFromActionId, - type FileTabViewerOverride, + parseFileOpenerParams, } from "@/components/plugin/file-opener-tabs"; +import type { FileOpenerOverride } from "@/lib/plugin-slot-resolvers"; import type { OpenPluginPanelArgs } from "@/components/plugin/PluginPanelActions"; import type { HostFileTabState, ThreadStorageFileTabState, WorkspaceFileTabState, -} from "@/lib/file-preview"; +} from "@bb/client-core"; import { useRecordThreadRecentItem } from "./threadRecentItems"; import type { SecondaryPanelTabReorderHandler, SecondaryPanelTabReorderRequest, -} from "./secondaryPanelFileTab"; +} from "./secondaryPanelTab"; import { activateSecondaryPanelTabInState, buildOrderedSecondaryPanelFileTabs, @@ -53,7 +54,7 @@ import { reorderSecondaryPanelFileTabInState, setSecondaryPanelTabsInState, updateSecondaryPanelTabInState, -} from "./secondaryPanelTabState"; +} from "@bb/client-core"; import { pruneTerminalTabsForSessions } from "./terminalPanelTabs"; interface UseThreadFileTabsParams { @@ -62,6 +63,7 @@ interface UseThreadFileTabsParams { environmentId: string | null | undefined; fileOwnerThreadId?: string | null; preserveWorkspaceTabsAcrossContexts?: boolean; + projectHostId?: string | null; projectId?: string | null; retainedTerminalId?: string | null; storageFiles: readonly ThreadStorageFileListItem[] | undefined; @@ -72,12 +74,12 @@ interface ThreadStorageFileListItem { path: string; } -export interface FileSearchWorkspaceSelection { +interface FileSearchWorkspaceSelection { source: "workspace"; path: string; } -export interface FileSearchThreadStorageSelection { +interface FileSearchThreadStorageSelection { source: "thread-storage"; path: string; } @@ -93,9 +95,24 @@ export interface UpdateBrowserTabArgs { } export type OpenSecondaryPanelTabRequest = - | { kind: "workspace-file-preview"; tab: WorkspaceFileTabState } - | { kind: "host-file-preview"; tab: HostFileTabState } - | { kind: "thread-storage-file-preview"; tab: ThreadStorageFileTabState } + | { + kind: "workspace-file-preview"; + tab: WorkspaceFileTabState; + /** Explicit identity; omission preserves the surface-context adapter. */ + environmentId?: string; + } + | { + kind: "host-file-preview"; + tab: HostFileTabState; + /** Explicit identity; omission preserves the thread-context adapter. */ + hostId?: string; + } + | { + kind: "thread-storage-file-preview"; + tab: ThreadStorageFileTabState; + /** Explicit identity; omission preserves the thread-context adapter. */ + threadId?: string; + } | { kind: "browser"; url: string } | { kind: "new-tab" }; @@ -106,16 +123,8 @@ interface CreateTabForOpenRequestArgs { threadId: string | null | undefined; } -interface CreateTabForFileSearchSelectionArgs { - projectId: string | null; - resolvedEnvironmentId: string | null | undefined; - selection: FileSearchSelection; - threadId: string | null | undefined; -} - interface PruneSecondaryTabsArgs { activeTabId: string | null; - stateTabs: readonly FixedPanelTab[]; tabs: readonly FixedPanelTab[]; } @@ -127,6 +136,8 @@ type SecondaryPanelTab = | NewTabFixedPanelTab | PluginPanelFixedPanelTab; +type OpenResolvedTabBehavior = "open" | "replace-new-tab"; + // Every side chat uses a constant tab title; the message it was triggered from // is shown inside the panel ("Replying to" bubble), so the tab needn't echo it. @@ -151,13 +162,28 @@ function createTabForOpenRequest({ }: CreateTabForOpenRequestArgs): SecondaryPanelTab | null { switch (request.kind) { case "workspace-file-preview": - if (resolvedEnvironmentId === undefined) return null; + if ( + request.environmentId === undefined && + resolvedEnvironmentId === undefined + ) { + return null; + } + const workspaceEnvironmentId = + request.environmentId ?? resolvedEnvironmentId ?? null; return createWorkspaceFilePreviewFixedPanelTab({ - environmentId: resolvedEnvironmentId, - projectId: resolvedEnvironmentId === null ? projectId : null, + environmentId: workspaceEnvironmentId, + projectId: workspaceEnvironmentId === null ? projectId : null, tab: request.tab, }); case "host-file-preview": + if (request.hostId !== undefined) { + return createHostFilePreviewFixedPanelTab({ + environmentId: null, + hostId: request.hostId, + tab: request.tab, + threadId: null, + }); + } if (!threadId || !resolvedEnvironmentId) return null; return createHostFilePreviewFixedPanelTab({ environmentId: resolvedEnvironmentId, @@ -165,11 +191,12 @@ function createTabForOpenRequest({ threadId, }); case "thread-storage-file-preview": - if (!threadId) return null; + const storageThreadId = request.threadId ?? threadId; + if (!storageThreadId) return null; return createStorageTab( resolvedEnvironmentId ?? null, request.tab, - threadId, + storageThreadId, ); case "browser": return createBrowserFixedPanelTab({ @@ -181,43 +208,32 @@ function createTabForOpenRequest({ } } -function createTabForFileSearchSelection({ - projectId, - resolvedEnvironmentId, - selection, - threadId, -}: CreateTabForFileSearchSelectionArgs): - | WorkspaceFilePreviewFixedPanelTab - | ThreadStorageFilePreviewFixedPanelTab - | null { +function openRequestForFileSearchSelection( + selection: FileSearchSelection, +): OpenSecondaryPanelTabRequest { if (selection.source === "workspace") { - if (resolvedEnvironmentId === undefined) return null; - return createWorkspaceFilePreviewFixedPanelTab({ - environmentId: resolvedEnvironmentId, - projectId: resolvedEnvironmentId === null ? projectId : null, + return { + kind: "workspace-file-preview", tab: { lineRange: null, path: selection.path, source: { kind: "working-tree" }, statusLabel: null, }, - }); + }; } - if (!threadId) return null; - return createStorageTab( - resolvedEnvironmentId ?? null, - { + return { + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: selection.path, }, - threadId, - ); + }; } function setPrunedSecondaryTabs({ activeTabId, - stateTabs, tabs, }: PruneSecondaryTabsArgs): { activeTabId: string | null; @@ -225,7 +241,7 @@ function setPrunedSecondaryTabs({ } { return { activeTabId: getActiveTabIdAfterPrune(tabs, activeTabId), - tabs: tabs === stateTabs ? stateTabs : tabs, + tabs, }; } @@ -235,6 +251,7 @@ export function useThreadFileTabs({ environmentId, fileOwnerThreadId, preserveWorkspaceTabsAcrossContexts = false, + projectHostId = null, projectId = null, retainedTerminalId = null, storageFiles, @@ -275,6 +292,7 @@ export function useThreadFileTabs({ ) { nextTab = createHostFilePreviewFixedPanelTab({ environmentId: resolvedEnvironmentId, + hostId: tab.hostId, tab: { lineRange: tab.lineRange, path: tab.path, @@ -332,7 +350,6 @@ export function useThreadFileTabs({ updateFixedPanelTabsState((state) => { const pruned = setPrunedSecondaryTabs({ activeTabId: state.secondary.activeTabId, - stateTabs: state.secondary.tabs, tabs: removeWorkspaceTabsForOtherEnvironments( state.secondary.tabs, resolvedEnvironmentId, @@ -357,7 +374,6 @@ export function useThreadFileTabs({ const knownPaths = new Set(storageFiles.map((file) => file.path)); const pruned = setPrunedSecondaryTabs({ activeTabId: state.secondary.activeTabId, - stateTabs: state.secondary.tabs, tabs: pruneStorageTabs({ knownPaths, tabs: state.secondary.tabs, @@ -383,7 +399,6 @@ export function useThreadFileTabs({ updateFixedPanelTabsState((state) => { const pruned = setPrunedSecondaryTabs({ activeTabId: state.secondary.activeTabId, - stateTabs: state.secondary.tabs, tabs: pruneTerminalTabsForSessions({ retainedTerminalId, tabs: state.secondary.tabs, @@ -407,24 +422,21 @@ export function useThreadFileTabs({ const { fileOpeners } = usePluginSlots(); const fileOpenerPreference = useFileOpenerPreferenceValue(); - const openTab = useCallback( + const openResolvedTab = useCallback( ( request: OpenSecondaryPanelTabRequest, - options?: { viewer?: FileTabViewerOverride }, + behavior: OpenResolvedTabBehavior, + viewer?: FileOpenerOverride, ): SecondaryPanelTab | null => { - // Opener diversion (plugin design §5.2): every file-open flow - // funnels through here (links, file search, `bb thread open`), so a - // matching plugin opener applies uniformly. Falls through to the - // built-in tab when no opener matches; a link menu's per-open viewer - // choice overrides automatic or pinned resolution in either direction. const openerTab = createFileOpenerTabForRequest({ fileOpeners, preference: fileOpenerPreference, + projectHostId, projectId, request, resolvedEnvironmentId, threadId: resolvedFileOwnerThreadId, - ...(options?.viewer !== undefined ? { viewer: options.viewer } : {}), + ...(viewer !== undefined ? { viewer } : {}), }); const tab = openerTab ?? @@ -447,7 +459,7 @@ export function useThreadFileTabs({ } updateFixedPanelTabsState((state) => { - if (request.kind === "browser") { + if (behavior === "replace-new-tab") { return replaceNewTabWithSecondaryPanelTabInState({ state, tab }); } return openSecondaryPanelTabInState({ state, tab }); @@ -457,6 +469,7 @@ export function useThreadFileTabs({ [ fileOpenerPreference, fileOpeners, + projectHostId, recordRecentItem, projectId, resolvedEnvironmentId, @@ -465,6 +478,23 @@ export function useThreadFileTabs({ ], ); + const openTab = useCallback( + ( + request: OpenSecondaryPanelTabRequest, + options?: { viewer?: FileOpenerOverride }, + ): SecondaryPanelTab | null => { + // Browser navigation replaces the transient new-tab launcher. Other + // ordinary opens append or focus a tab. Both paths still share the + // same opener-or-built-in resolution above. + return openResolvedTab( + request, + request.kind === "browser" ? "replace-new-tab" : "open", + options?.viewer, + ); + }, + [openResolvedTab], + ); + const activateTab = useCallback( (tabId: string) => { updateFixedPanelTabsState((state) => @@ -516,31 +546,12 @@ export function useThreadFileTabs({ const selectFileSearchResult = useCallback( (selection: FileSearchSelection) => { - const tab = createTabForFileSearchSelection({ - projectId, - resolvedEnvironmentId, - selection, - threadId: resolvedFileOwnerThreadId, - }); - if (tab === null) return; - - if (selection.source === "workspace") { - recordRecentItem({ source: "workspace", path: selection.path }); - } else { - recordRecentItem({ source: "thread-storage", path: selection.path }); - } - - updateFixedPanelTabsState((state) => - replaceNewTabWithSecondaryPanelTabInState({ state, tab }), + openResolvedTab( + openRequestForFileSearchSelection(selection), + "replace-new-tab", ); }, - [ - projectId, - recordRecentItem, - resolvedEnvironmentId, - resolvedFileOwnerThreadId, - updateFixedPanelTabsState, - ], + [openResolvedTab], ); const updateBrowserTab = useCallback( @@ -567,7 +578,7 @@ export function useThreadFileTabs({ updateFixedPanelTabsState(clearActiveSecondaryFileTabInState); }, [updateFixedPanelTabsState]); - const reorderFileTab = useCallback( + const reorderTab = useCallback( (request: SecondaryPanelTabReorderRequest) => { updateFixedPanelTabsState((state) => reorderSecondaryPanelFileTabInState({ ...request, state }), @@ -605,51 +616,63 @@ export function useThreadFileTabs({ fileOpenerIdFromActionId(activePluginPanelTab.actionId) !== null ? (activePluginPanelTab.fileOpenerOwner ?? null) : null; + // Params own the routed file identity; the owner only restores native + // presentation state such as line range and workspace status. + const activeFileOpenerFile = + activeFileOpenerOwner === null || activePluginPanelTab === null + ? null + : parseFileOpenerParams(activePluginPanelTab.paramsJson); + const activeWorkspaceFileOpener = + activeFileOpenerOwner?.kind === "workspace-file-preview" && + activeFileOpenerFile?.source.kind === "workspace" + ? activeFileOpenerFile + : null; + const activeHostFileOpener = + activeFileOpenerOwner?.kind === "host-file-preview" && + activeFileOpenerFile?.source.kind === "host" + ? activeFileOpenerFile + : null; + const activeStorageFileOpener = + activeFileOpenerOwner?.kind === "thread-storage-file-preview" && + activeFileOpenerFile?.source.kind === "thread-storage" + ? activeFileOpenerFile + : null; return { activateTab, activeBrowserTab, + activeFileOpenerFile, activeFileOpenerOwner, activeHostFileEnvironmentId: activeHostFileTab?.environmentId ?? - (activeFileOpenerOwner?.kind === "host-file-preview" - ? activeFileOpenerOwner.environmentId - : null), + activeHostFileOpener?.source.environmentId ?? + null, activeHostFileLineRange: activeHostFileTab?.lineRange ?? (activeFileOpenerOwner?.kind === "host-file-preview" ? activeFileOpenerOwner.tab.lineRange : null), activeHostFilePath: - activeHostFileTab?.path ?? - (activeFileOpenerOwner?.kind === "host-file-preview" - ? activeFileOpenerOwner.tab.path - : null), + activeHostFileTab?.path ?? activeHostFileOpener?.path ?? null, activeHostFileThreadId: activeHostFileTab?.threadId ?? - (activeFileOpenerOwner?.kind === "host-file-preview" - ? activeFileOpenerOwner.threadId - : null), + activeHostFileOpener?.source.threadId ?? + null, activeStorageFileEnvironmentId: activeStorageFileTab?.environmentId ?? - (activeFileOpenerOwner?.kind === "thread-storage-file-preview" - ? activeFileOpenerOwner.environmentId - : null), + activeStorageFileOpener?.source.environmentId ?? + null, activeStorageFileLineRange: activeStorageFileTab?.lineRange ?? (activeFileOpenerOwner?.kind === "thread-storage-file-preview" ? activeFileOpenerOwner.tab.lineRange : null), activeStorageFilePath: - activeStorageFileTab?.path ?? - (activeFileOpenerOwner?.kind === "thread-storage-file-preview" - ? activeFileOpenerOwner.tab.path - : null), + activeStorageFileTab?.path ?? activeStorageFileOpener?.path ?? null, activeStorageFileThreadId: activeStorageFileTab?.threadId ?? - (activeFileOpenerOwner?.kind === "thread-storage-file-preview" - ? activeFileOpenerOwner.threadId - : null), + activeStorageFileOpener?.source.threadId ?? + null, activeWorkspaceFileLineRange: activeWorkspaceFileTab?.lineRange ?? (activeFileOpenerOwner?.kind === "workspace-file-preview" @@ -657,29 +680,14 @@ export function useThreadFileTabs({ : null), activeWorkspaceFileEnvironmentId: activeWorkspaceFileTab?.environmentId ?? - (activeFileOpenerOwner?.kind === "workspace-file-preview" - ? activeFileOpenerOwner.environmentId - : null), + activeWorkspaceFileOpener?.source.environmentId ?? + null, activeWorkspaceFilePath: - activeWorkspaceFileTab?.path ?? - (activeFileOpenerOwner?.kind === "workspace-file-preview" - ? activeFileOpenerOwner.tab.path - : null), + activeWorkspaceFileTab?.path ?? activeWorkspaceFileOpener?.path ?? null, activeWorkspaceFileProjectId: activeWorkspaceFileTab?.projectId ?? - (activeFileOpenerOwner?.kind === "workspace-file-preview" - ? activeFileOpenerOwner.projectId - : null), - activeWorkspaceFileSource: - activeWorkspaceFileTab?.source ?? - (activeFileOpenerOwner?.kind === "workspace-file-preview" - ? activeFileOpenerOwner.tab.source - : null), - activeWorkspaceFileStatusLabel: - activeWorkspaceFileTab?.statusLabel ?? - (activeFileOpenerOwner?.kind === "workspace-file-preview" - ? activeFileOpenerOwner.tab.statusLabel - : null), + activeWorkspaceFileOpener?.source.projectId ?? + null, activePluginPanelTab, browserTabs, clearActiveFileTabs, @@ -688,7 +696,7 @@ export function useThreadFileTabs({ openPluginPanel, openTab, orderedSecondaryFileTabs, - reorderFileTab, + reorderTab, selectFileSearchResult, updateBrowserTab, }; diff --git a/apps/app/src/components/secondary-panel/useThreadOpenFileSignal.ts b/apps/app/src/components/secondary-panel/useThreadOpenFileSignal.ts index d0bf51d555..3734e86f2c 100644 --- a/apps/app/src/components/secondary-panel/useThreadOpenFileSignal.ts +++ b/apps/app/src/components/secondary-panel/useThreadOpenFileSignal.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import type { ThreadOpenFile } from "@bb/server-contract"; -import { createFilePreviewLineRange } from "@/lib/file-preview"; +import { createFilePreviewLineRange } from "@bb/client-core"; import { wsManager } from "@/lib/ws"; import type { OpenSecondaryPanelTabRequest } from "./useThreadFileTabs"; diff --git a/apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.tsx b/apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.tsx new file mode 100644 index 0000000000..7cf85efc21 --- /dev/null +++ b/apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { WorkspaceFile } from "@bb/server-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useThreadStorageBrowser } from "./useThreadStorageBrowser"; + +// Records when the tree library is first evaluated. A static import anywhere +// on the hook's path would fire this while the test file loads, before any +// thread has files to show; the route budget forbids exactly that. Hoisted so +// it exists even when such a static import runs before this module body. +const { treesModuleEvaluated } = vi.hoisted(() => ({ + treesModuleEvaluated: vi.fn<(specifier: string) => void>(), +})); +vi.mock("@pierre/trees", async (importOriginal) => { + treesModuleEvaluated("@pierre/trees"); + return importOriginal(); +}); +vi.mock("@pierre/trees/react", async (importOriginal) => { + treesModuleEvaluated("@pierre/trees/react"); + return importOriginal(); +}); + +const FILES: readonly WorkspaceFile[] = [ + { name: "notes.md", path: "docs/notes.md" }, + { name: "main.ts", path: "src/main.ts" }, +]; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useThreadStorageBrowser", () => { + it("loads the tree library only once there are files to show", async () => { + const onSelectPath = vi.fn(); + const initialProps: { files: readonly WorkspaceFile[] | undefined } = { + files: undefined, + }; + const { result, rerender } = renderHook( + ({ files }: typeof initialProps) => + useThreadStorageBrowser({ files, onSelectPath, selectedPath: null }), + { initialProps }, + ); + + expect(treesModuleEvaluated).not.toHaveBeenCalled(); + expect(result.current.model).toBeNull(); + + rerender({ files: [] }); + await Promise.resolve(); + expect(treesModuleEvaluated).not.toHaveBeenCalled(); + expect(result.current.model).toBeNull(); + + rerender({ files: FILES }); + await waitFor(() => { + expect(result.current.model).not.toBeNull(); + }); + expect(treesModuleEvaluated).toHaveBeenCalled(); + }); + + it("syncs files and selection into the model once it arrives, then destroys it on unmount", async () => { + const onSelectPath = vi.fn(); + const { result, rerender, unmount } = renderHook( + ({ selectedPath }: { selectedPath: string | null }) => + useThreadStorageBrowser({ files: FILES, onSelectPath, selectedPath }), + { initialProps: { selectedPath: "src/main.ts" } }, + ); + + await waitFor(() => { + expect(result.current.model).not.toBeNull(); + }); + const model = result.current.model; + if (model === null) throw new Error("model should be loaded"); + // The files and the selection that arrived before the chunk did are + // applied to the model as soon as it exists, not only on the next change. + expect(model.getItem("src/main.ts")).not.toBeNull(); + expect(model.getItem("docs/notes.md")).not.toBeNull(); + expect(model.getSelectedPaths()).toEqual(["src/main.ts"]); + + rerender({ selectedPath: "docs/notes.md" }); + expect(model.getSelectedPaths()).toEqual(["docs/notes.md"]); + // Reconciling React state into the tree must not echo back as a user + // selection. + expect(onSelectPath).not.toHaveBeenCalled(); + + const cleanUp = vi.spyOn(model, "cleanUp"); + unmount(); + expect(cleanUp).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/components/secondary-panel/useThreadStorageBrowser.ts b/apps/app/src/components/secondary-panel/useThreadStorageBrowser.ts index bed701bd5a..477e62ab62 100644 --- a/apps/app/src/components/secondary-panel/useThreadStorageBrowser.ts +++ b/apps/app/src/components/secondary-panel/useThreadStorageBrowser.ts @@ -5,11 +5,26 @@ import { useRef, useState, } from "react"; -import { useFileTree, type UseFileTreeResult } from "@pierre/trees/react"; import type { WorkspaceFile } from "@bb/server-contract"; +import { createRetryingModuleLoader } from "@/lib/plugin-frontend-lazy"; +// Type-only: the runtime edge to `@pierre/trees` is the dynamic `import()` +// below, so the tree library stays out of the thread route's static closure +// (bundle-budget.json forbids it there). +import type { ThreadStorageTreeModel } from "./ThreadStorageFileTree"; const EMPTY_STORAGE_FILES: readonly WorkspaceFile[] = []; +type ThreadStorageFileTreeModule = typeof import("./ThreadStorageFileTree"); + +/** + * Loads the tree chunk once and re-tries after a failed fetch, so a flaky + * network cannot leave the storage browser without a tree for good. + */ +const loadThreadStorageFileTree = + createRetryingModuleLoader( + () => import("./ThreadStorageFileTree"), + ); + export type ThreadStoragePathSelectHandler = (path: string) => void; interface UseThreadStorageBrowserArgs { @@ -23,7 +38,8 @@ export interface ThreadStorageBrowserController { filteredFiles: readonly WorkspaceFile[]; isSearchOpen: boolean; loadedFiles: readonly WorkspaceFile[]; - model: UseFileTreeResult["model"]; + /** `null` until the lazily loaded tree chunk has created the model. */ + model: ThreadStorageTreeModel | null; openSearch: () => void; searchQuery: string; setSearchQuery: (query: string) => void; @@ -48,13 +64,17 @@ function buildDirectoryPaths(paths: readonly string[]): string[] { /** * Owns the thread storage browser's tree model and related UI state. * - * Pierre tree's `useFileTree` destroys its model on the owning component's - * unmount (`model.cleanUp()` unsubscribes the selection listener and destroys - * the controller — see packages/trees/src/react/useFileTree.ts and - * render/FileTree.ts in pierrecomputer/pierre). The storage tab content - * unmounts whenever a file tab covers it, so this hook must live in a parent - * that survives that toggle (e.g., ThreadDetailView), with the model and - * search state passed down to the presentational browser. + * The tree model is destroyed when this hook's owner unmounts + * (`model.cleanUp()` unsubscribes the selection listener and destroys the + * controller — see render/FileTree.ts in pierrecomputer/pierre). The storage + * tab content unmounts whenever a file tab covers it, so this hook must live + * in a parent that survives that toggle (e.g., ThreadDetailView), with the + * model and search state passed down to the presentational browser. + * + * The model arrives asynchronously: the tree chunk is imported on demand, + * and only once there are files to show (with no files the browser + * renders an empty state and never mounts a tree). `model` is `null` until + * then and the sync effects below wait for it. */ export function useThreadStorageBrowser({ files, @@ -107,13 +127,31 @@ export function useThreadStorageBrowser({ [], ); - const { model } = useFileTree({ - density: "compact", - initialExpansion: "closed", - onSelectionChange: handleTreeSelectionChange, - paths: [], - search: false, - }); + const [model, setModel] = useState(null); + const shouldLoadTree = loadedFiles.length > 0; + useEffect(() => { + if (!shouldLoadTree) return; + let cancelled = false; + let createdModel: ThreadStorageTreeModel | null = null; + void loadThreadStorageFileTree().then( + ({ createThreadStorageTreeModel }) => { + if (cancelled) return; + createdModel = createThreadStorageTreeModel(handleTreeSelectionChange); + setModel(createdModel); + }, + (error: unknown) => { + if (cancelled) return; + console.warn( + `thread storage tree load failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }, + ); + return () => { + cancelled = true; + createdModel?.cleanUp(); + setModel(null); + }; + }, [handleTreeSelectionChange, shouldLoadTree]); const isSearching = searchQuery.trim().length > 0; const expandedDirectoryPaths = useMemo( @@ -121,12 +159,14 @@ export function useThreadStorageBrowser({ [isSearching, filePaths], ); useEffect(() => { + if (model === null) return; model.resetPaths(filePaths, { initialExpandedPaths: expandedDirectoryPaths, }); }, [expandedDirectoryPaths, filePaths, model]); useEffect(() => { + if (model === null) return; const currentSelectedPaths = model.getSelectedPaths(); const selectedPathIsVisible = selectedPath !== null && filePathSet.has(selectedPath); diff --git a/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts b/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts index 8fc7f986ff..1cb062c5d9 100644 --- a/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts +++ b/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts @@ -1,26 +1,14 @@ import type { FixedPanelTab } from "@/lib/fixed-panel-tabs-state"; -import { - DEFAULT_THREAD_STORAGE_FILE_LIST_OPTIONS, - type ThreadStorageFileListOptions, -} from "@/lib/thread-storage-files"; -import { - useThreadStorageFilePreview, - useThreadStorageFiles, -} from "../../hooks/queries/thread-queries"; +import { DEFAULT_THREAD_STORAGE_FILE_LIST_OPTIONS } from "@/lib/thread-storage-files"; +import { useThreadStorageFiles } from "../../hooks/queries/thread-queries"; interface UseThreadStorageViewerParams { - activePath: string | null; fileListEnabled?: boolean; - fileListOptions?: ThreadStorageFileListOptions; - filePreviewEnabled?: boolean; threadId?: string; } export function useThreadStorageViewer({ - activePath, fileListEnabled = true, - fileListOptions = DEFAULT_THREAD_STORAGE_FILE_LIST_OPTIONS, - filePreviewEnabled = true, threadId, }: UseThreadStorageViewerParams) { const hasThread = Boolean(threadId); @@ -29,22 +17,16 @@ export function useThreadStorageViewer({ isLoading: isThreadStorageFilesLoading, error: threadStorageFilesError, refetch: refetchThreadStorageFiles, - } = useThreadStorageFiles(threadId ?? "", fileListOptions, { - enabled: hasThread && fileListEnabled, - }); - const { - data: threadStorageFilePreview, - isLoading: isThreadStorageFilePreviewLoading, - error: threadStorageFilePreviewError, - } = useThreadStorageFilePreview(threadId ?? "", activePath, { - enabled: hasThread && filePreviewEnabled && activePath !== null, - }); + } = useThreadStorageFiles( + threadId ?? "", + DEFAULT_THREAD_STORAGE_FILE_LIST_OPTIONS, + { + enabled: hasThread && fileListEnabled, + }, + ); return { - isThreadStorageFilePreviewLoading, isThreadStorageFilesLoading, - threadStorageFilePreview, - threadStorageFilePreviewError, threadStorageFilesError, threadStorageFiles, threadStorageRootPath: threadStorageFiles?.storageRootPath ?? null, diff --git a/apps/app/src/components/settings/CliSkillsSettingsSection.tsx b/apps/app/src/components/settings/CliSkillsSettingsSection.tsx index 4f0e6e9afc..cfe1ce3a35 100644 --- a/apps/app/src/components/settings/CliSkillsSettingsSection.tsx +++ b/apps/app/src/components/settings/CliSkillsSettingsSection.tsx @@ -18,7 +18,7 @@ import { useCliSkillsStatus } from "@/hooks/queries/system-queries"; const CLI_SKILLS_SETTING_LABEL = "bb CLI skills"; -export interface CliSkillsSettingsSectionContentProps { +interface CliSkillsSettingsSectionContentProps { /** False while no machine is connected, so nothing could receive the files. */ hasConnectedMachine: boolean; onOpenPicker: () => void; @@ -89,9 +89,7 @@ export function CliSkillsSettingsSectionContent({ * Report the per-machine outcome. The route installs machines independently, * so a partial success is a real outcome and both halves get surfaced. */ -export function reportInstallResults( - result: SystemInstallCliSkillsResponse, -): void { +function reportInstallResults(result: SystemInstallCliSkillsResponse): void { const installed = result.results.filter((entry) => entry.ok); const failed = result.results.filter((entry) => !entry.ok); if (installed.length > 0) { diff --git a/apps/app/src/components/settings/CodeRendererSettings.test.tsx b/apps/app/src/components/settings/CodeRendererSettings.test.tsx new file mode 100644 index 0000000000..0a708d840f --- /dev/null +++ b/apps/app/src/components/settings/CodeRendererSettings.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { createStore, Provider as JotaiProvider } from "jotai"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { + diffRendererProviderAtom, + sourceCodeRendererProviderAtom, +} from "@/components/code/codeRendererProvider"; +import { + AUTOMATIC_REPLACEMENT_PROVIDER, + BUILT_IN_REPLACEMENT_PROVIDER, +} from "@/lib/plugin-replacement-preference"; +import { CodeRendererSettings } from "./CodeRendererSettings"; + +const EMPTY_REGISTRATIONS = { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], +}; + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + resetPluginSlotStoreForTest(); +}); + +describe("CodeRendererSettings", () => { + it("shows no control until a plugin supplies a renderer", () => { + render( + + + , + ); + + expect(screen.queryByRole("button", { name: "Source code" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Diffs" })).toBeNull(); + }); + + it("pins BB's diff renderer without touching the source-code choice", async () => { + setPluginSlotRegistrations("inkwell", { + ...EMPTY_REGISTRATIONS, + sourceCodeRenderers: [ + { id: "source", title: "Inkwell source", component: () => null }, + ], + diffRenderers: [ + { id: "diffs", title: "Inkwell diffs", component: () => null }, + ], + }); + const store = createStore(); + render( + + + , + ); + + const diffTrigger = screen.getByRole("button", { name: "Diffs" }); + expect(diffTrigger.textContent).toContain("Automatic"); + + fireEvent.pointerDown(diffTrigger, { button: 0 }); + fireEvent.click(await screen.findByRole("menuitem", { name: /built-in/u })); + + expect(store.get(diffRendererProviderAtom)).toBe( + BUILT_IN_REPLACEMENT_PROVIDER, + ); + // The two renderers are pinned independently — turning off plugin diffs + // must not silently turn off its source viewer too. + expect(store.get(sourceCodeRendererProviderAtom)).toBe( + AUTOMATIC_REPLACEMENT_PROVIDER, + ); + }); + + it("offers each registered provider by name", () => { + setPluginSlotRegistrations("inkwell", { + ...EMPTY_REGISTRATIONS, + diffRenderers: [ + { id: "diffs", title: "Inkwell diffs", component: () => null }, + ], + }); + setPluginSlotRegistrations("zed", { + ...EMPTY_REGISTRATIONS, + diffRenderers: [ + { + id: "zed-diffs", + title: "Zed diffs", + description: "Side-by-side with word highlights.", + component: () => null, + }, + ], + }); + const store = createStore(); + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Diffs" }); + // Plugin ids sort, so "inkwell" is the automatic winner and the label has + // to name it rather than whichever plugin loaded first. + expect(trigger.textContent).toContain("Automatic"); + fireEvent.pointerDown(trigger, { button: 0 }); + expect( + screen.getByRole("menuitem", { name: /Currently using Inkwell diffs/u }), + ).toBeTruthy(); + // Both providers stay individually pinnable, and each carries its own + // description rather than the generic "From the plugin" fallback. + const items = screen + .getAllByRole("menuitem") + .map((item) => item.textContent ?? ""); + expect(items).toHaveLength(4); + expect(items.some((text) => text.includes("From the inkwell plugin"))).toBe( + true, + ); + expect( + items.some((text) => + text.includes("Side-by-side with word highlights."), + ), + ).toBe(true); + }); +}); diff --git a/apps/app/src/components/settings/CodeRendererSettings.tsx b/apps/app/src/components/settings/CodeRendererSettings.tsx new file mode 100644 index 0000000000..58143be793 --- /dev/null +++ b/apps/app/src/components/settings/CodeRendererSettings.tsx @@ -0,0 +1,145 @@ +import { useAtom, type PrimitiveAtom } from "jotai"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { Button } from "@bb/shared-ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; +import { SettingsWithControl } from "@/components/ui/settings-section"; +import { + diffRendererProviderAtom, + sourceCodeRendererProviderAtom, +} from "@/components/code/codeRendererProvider"; +import { + AUTOMATIC_REPLACEMENT_PROVIDER, + BUILT_IN_REPLACEMENT_PROVIDER, + replacementProviderKey, +} from "@/lib/plugin-replacement-preference"; +import { usePluginSlots } from "@/lib/plugin-slots"; + +interface CodeRendererProviderSlot { + pluginId: string; + id: string; + title: string; + description?: string; +} + +interface CodeRendererSettingProps { + label: string; + description: string; + builtInDescription: string; + preferenceAtom: PrimitiveAtom; + slots: readonly CodeRendererProviderSlot[]; +} + +/** + * The per-client pin for one code renderer, mirroring the sidebar thread list + * control. A renderer takes over surfaces the user has no other way back + * from — the file preview, every diff — so pinning has to be reachable + * without uninstalling the plugin that supplied it. + */ +function CodeRendererSetting({ + label, + description, + builtInDescription, + preferenceAtom, + slots, +}: CodeRendererSettingProps) { + const [preference, setPreference] = useAtom(preferenceAtom); + + const automaticProvider = slots[0]; + if (automaticProvider === undefined) return null; + const builtInOption = { + key: BUILT_IN_REPLACEMENT_PROVIDER, + title: "bb (built-in)", + description: builtInDescription, + }; + const options = [ + { + key: AUTOMATIC_REPLACEMENT_PROVIDER, + title: "Automatic", + description: `Currently using ${automaticProvider.title} from ${automaticProvider.pluginId}.`, + }, + builtInOption, + ...slots.map((slot) => ({ + key: replacementProviderKey(slot), + title: slot.title, + description: slot.description ?? `From the ${slot.pluginId} plugin.`, + })), + ]; + // An unavailable explicit provider renders BB's renderer until it returns. + const selected = + options.find((option) => option.key === preference) ?? builtInOption; + + return ( + + + + + + + {options.map((option) => ( + setPreference(option.key)} + className="flex items-start gap-2" + > + + {option.title} + + {option.description} + + + + + ))} + + + + ); +} + +/** Both code-renderer pins; each row hides itself when no plugin supplies one. */ +export function CodeRendererSettings() { + const { sourceCodeRenderers, diffRenderers } = usePluginSlots(); + return ( + <> + + + + ); +} diff --git a/apps/app/src/components/settings/CommunitySettingsSection.tsx b/apps/app/src/components/settings/CommunitySettingsSection.tsx index 5882e4c888..01d7d859c2 100644 --- a/apps/app/src/components/settings/CommunitySettingsSection.tsx +++ b/apps/app/src/components/settings/CommunitySettingsSection.tsx @@ -6,8 +6,8 @@ import { } from "@/components/ui/settings-section.js"; import { openUrlInExternalBrowser } from "@/lib/url-open-routing"; -export const DISCORD_INVITE_URL = "https://discord.gg/kvBU6tJhcJ"; -export const GITHUB_REPO_URL = "https://github.com/get-bb/bb"; +const DISCORD_INVITE_URL = "https://discord.gg/kvBU6tJhcJ"; +const GITHUB_REPO_URL = "https://github.com/get-bb/bb"; interface CommunityLinkRowProps { description: string; diff --git a/apps/app/src/components/settings/InstallCliSkillsDialog.tsx b/apps/app/src/components/settings/InstallCliSkillsDialog.tsx index a1f97e52cb..8ac9308b7b 100644 --- a/apps/app/src/components/settings/InstallCliSkillsDialog.tsx +++ b/apps/app/src/components/settings/InstallCliSkillsDialog.tsx @@ -13,7 +13,7 @@ import { } from "@bb/shared-ui/dialog"; import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; -export interface InstallCliSkillsDialogContentProps { +interface InstallCliSkillsDialogContentProps { hosts: readonly Host[]; onCancel: () => void; onInstall: (hostIds: string[]) => void; @@ -47,7 +47,7 @@ function machineStatusLabel(args: { * single machine there is nothing to choose, so the list is dropped and the * machine is named in the description instead. */ -export function InstallCliSkillsDialogContent({ +function InstallCliSkillsDialogContent({ hosts, onCancel, onInstall, @@ -72,7 +72,7 @@ export function InstallCliSkillsDialogContent({ {choosable ? "Choose the machines to install them onto. Each one gets the skills in ~/.agents/skills and ~/.claude/skills, replacing any copy already there." - : `The skills go in ~/.agents/skills and ~/.claude/skills on ${hosts[0]?.name ?? "this machine"}, replacing any copy already there.`} + : `The skills go in ~/.agents/skills and ~/.claude/skills on ${hosts[0]?.name ?? "the selected machine"}, replacing any copy already there.`} @@ -135,7 +135,7 @@ export function InstallCliSkillsDialogContent({ ); } -export interface InstallCliSkillsDialogProps extends InstallCliSkillsDialogContentProps { +interface InstallCliSkillsDialogProps extends InstallCliSkillsDialogContentProps { onOpenChange: (open: boolean) => void; open: boolean; } diff --git a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx index e03af52851..e74323feba 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx @@ -8,6 +8,7 @@ import { waitFor, } from "@testing-library/react"; import type { Host } from "@bb/domain"; +import { RETRY_ACTION_ICON } from "@bb/domain/update-state"; import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract"; import { defaultAppSettings, @@ -16,7 +17,7 @@ import { } from "@bb/domain"; import type { SystemConfigResponse } from "@bb/server-contract"; import { MemoryRouter } from "react-router-dom"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { MachinesSettingsSection } from "./MachinesSettingsSection"; @@ -37,6 +38,18 @@ vi.mock("@/lib/ws", () => ({ wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() }, })); +const hostDaemon = vi.hoisted(() => ({ + localDaemonHostId: "host_primary" as string | null, + platform: "darwin" as "darwin" | "linux" | "wsl" | "unknown" | null, +})); + +vi.mock("@/hooks/useHostDaemon", () => ({ + useHostDaemon: () => ({ + localDaemonHostId: hostDaemon.localDaemonHostId, + platform: hostDaemon.platform, + }), +})); + const NOW = Date.now(); function host(overrides: Partial & Pick): Host { @@ -72,6 +85,7 @@ function systemConfig(): SystemConfigResponse { pluginThemes: [], featureFlags: { placeholder: false, timelineWindowEventBudget: 1_500 }, hostDaemonPort: null, + localHelperPorts: [], serverUrl: "http://localhost:38886", primaryHostId: "host_primary", primaryHostPlatform: "darwin", @@ -126,6 +140,11 @@ async function openHostMenu(hostName: string): Promise { ); } +beforeEach(() => { + hostDaemon.localDaemonHostId = "host_primary"; + hostDaemon.platform = "darwin"; +}); + afterEach(() => { cleanup(); vi.unstubAllGlobals(); @@ -133,7 +152,7 @@ afterEach(() => { }); describe("MachinesSettingsSection", () => { - it("renders each machine with status, primary badge, and project counts", async () => { + it("renders machine status, project, and permission metadata as visible text", async () => { vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); stubSidebarBootstrapFetch(); @@ -143,12 +162,74 @@ describe("MachinesSettingsSection", () => { expect(await screen.findByText("MacBook Pro")).toBeDefined(); expect(screen.getByText("dev-vm")).toBeDefined(); expect(screen.getByText("this machine")).toBeDefined(); - await waitFor(() => { - expect(screen.getByText("Online · macOS · 2 projects")).toBeDefined(); + expect(screen.getByText("primary")).toBeDefined(); + expect(screen.getByText("Online")).toBeDefined(); + expect(screen.getByText(/^Offline · last seen/u)).toBeDefined(); + expect(await screen.findByText("2 projects")).toBeDefined(); + expect(screen.getByText("1 project")).toBeDefined(); + expect(screen.getAllByText("Full Access")).toHaveLength(2); + expect(screen.getByText("macOS")).toBeDefined(); + expect( + screen + .getByRole("link", { name: "Open MacBook Pro" }) + .querySelector("[data-icon]"), + ).toBeNull(); + }); + + it("distinguishes the client-local daemon from the primary machine", async () => { + hostDaemon.localDaemonHostId = "host_remote"; + hostDaemon.platform = "linux"; + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + const primaryName = await screen.findByText("MacBook Pro"); + const localName = screen.getByText("dev-vm"); + expect(primaryName.parentElement?.textContent).toContain("primary"); + expect(primaryName.parentElement?.textContent).not.toContain( + "this machine", + ); + expect(localName.parentElement?.textContent).toContain("this machine"); + expect(localName.parentElement?.textContent).not.toContain("primary"); + expect(screen.getByText("Linux")).toBeDefined(); + }); + + it("does not infer client-local identity when no daemon is reachable", async () => { + hostDaemon.localDaemonHostId = null; + hostDaemon.platform = null; + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + await screen.findByText("MacBook Pro"); + expect(screen.queryByText("this machine")).toBeNull(); + expect(screen.getByText("primary")).toBeDefined(); + }); + + it("does not promote a fallback host to primary policy", async () => { + hostDaemon.localDaemonHostId = null; + vi.mocked(sdk.system.config).mockResolvedValue({ + ...systemConfig(), + primaryHostId: null, + primaryHostPlatform: null, }); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + await screen.findByText("MacBook Pro"); + expect(screen.queryByText("primary")).toBeNull(); + await openHostMenu("MacBook Pro"); expect( - screen.getByText("Offline · last seen 2h ago · 1 project"), - ).toBeDefined(); + screen + .getByRole("menuitem", { name: "Remove machine" }) + .getAttribute("aria-disabled"), + ).toBeNull(); }); it("shows protocol versions when a machine needs an update", async () => { @@ -164,15 +245,79 @@ describe("MachinesSettingsSection", () => { renderSection(); - expect( - await screen.findByText( - `Needs update · daemon protocol ${HOST_DAEMON_PROTOCOL_VERSION - 1} · server protocol ${HOST_DAEMON_PROTOCOL_VERSION} · 1 project`, - ), - ).toBeDefined(); + const updateStatus = await screen.findByText( + `Needs update · daemon protocol ${HOST_DAEMON_PROTOCOL_VERSION - 1} · server protocol ${HOST_DAEMON_PROTOCOL_VERSION}`, + ); + expect(updateStatus.className).toContain("min-w-0"); + expect(updateStatus.className).not.toContain("shrink-0"); // The action lives in the row menu so the rows keep one shape. await openHostMenu("dev-vm"); + const renameItem = await screen.findByRole("menuitem", { name: "Rename" }); + const retryItem = await screen.findByRole("menuitem", { + name: "Retry update", + }); + const removeItem = await screen.findByRole("menuitem", { + name: "Remove machine", + }); + const menu = screen.getByRole("menu"); + expect(menu.className).toContain("w-max"); + expect(menu.className).toContain("min-w-0"); + for (const item of [renameItem, retryItem, removeItem]) { + expect(item.className).toContain("min-h-9"); + expect(item.className).toContain("px-2.5"); + expect(item.className).toContain("py-2"); + } + expect(renameItem.querySelector('[data-icon="Edit"]')).not.toBeNull(); expect( - await screen.findByRole("menuitem", { name: "Retry update" }), + retryItem.querySelector(`[data-icon="${RETRY_ACTION_ICON}"]`), + ).not.toBeNull(); + expect(removeItem.querySelector('[data-icon="Trash2"]')).not.toBeNull(); + }); + + it("opens the row menu from the keyboard and focuses its first action", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + const trigger = await screen.findByRole("button", { + name: "dev-vm actions", + }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + trigger.focus(); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + + const renameItem = await screen.findByRole("menuitem", { name: "Rename" }); + await waitFor(() => { + expect(document.activeElement).toBe(renameItem); + }); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + fireEvent.keyDown(renameItem, { key: "Escape" }); + await waitFor(() => { + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + }); + + it("uses a labeled Add a machine action", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + const addMachine = await screen.findByRole("button", { + name: "Add a machine", + }); + expect(addMachine.textContent).toBe("Add a machine"); + expect(addMachine.querySelector('[data-icon="Plus"]')).not.toBeNull(); + const action = addMachine.parentElement; + expect(action?.className).toContain("self-start"); + expect(action?.parentElement?.className).toContain("flex-col"); + expect(action?.parentElement?.className).toContain("sm:flex-row"); + fireEvent.click(addMachine); + expect( + await screen.findByRole("heading", { name: "Add a machine" }), ).toBeDefined(); }); @@ -203,7 +348,7 @@ describe("MachinesSettingsSection", () => { }); }); - it("shows each machine's limit read-only and links the row to its page", async () => { + it("shows permission metadata as text and reserves a hover caret", async () => { vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); vi.mocked(sdk.hosts.list).mockResolvedValue([ primaryHost, @@ -213,16 +358,40 @@ describe("MachinesSettingsSection", () => { renderSection(); - // Readable without opening anything, so a capped machine is obvious. expect(await screen.findByText("Accept Edits")).toBeDefined(); expect(screen.getByText("Full Access")).toBeDefined(); // The control itself lives on the machine page. expect( screen.queryByRole("button", { name: /Permission limit for/ }), ).toBeNull(); + const machineLink = screen.getByRole("link", { name: "Open dev-vm" }); + expect(machineLink.getAttribute("href")).toBe( + "/settings/machines/host_remote", + ); + const row = machineLink.closest("[data-machine-row]"); + expect(row?.className).toContain("hover:bg-state-hover"); + expect(row?.className).toContain("focus-within:bg-state-hover"); + expect(row?.className).toContain("px-2"); + expect(row?.className).toContain("py-2"); + const caret = row?.querySelector('[data-icon="ChevronRight"]'); + expect(caret?.classList.contains("opacity-0")).toBe(true); + expect(caret?.classList.contains("size-3.5")).toBe(true); + expect(caret?.classList.contains("text-subtle-foreground")).toBe(true); + expect(caret?.classList.contains("group-hover:opacity-100")).toBe(true); + expect(caret?.classList.contains("group-focus-within:opacity-100")).toBe( + true, + ); + const overflow = row?.querySelector('[data-icon="MoreHorizontal"]'); + expect(overflow).not.toBeNull(); + expect(caret).not.toBeNull(); expect( - screen.getByRole("link", { name: "Open dev-vm" }).getAttribute("href"), - ).toBe("/settings/machines/host_remote"); + overflow && caret + ? Boolean( + overflow.compareDocumentPosition(caret) & + Node.DOCUMENT_POSITION_FOLLOWING, + ) + : false, + ).toBe(true); }); it("renames a machine through the row menu", async () => { @@ -288,9 +457,20 @@ describe("MachinesSettingsSection", () => { await openHostMenu("MacBook Pro"); const removeItem = await screen.findByRole("menuitem", { - name: /Remove machine/, + name: "Remove machine", }); expect(removeItem.getAttribute("aria-disabled")).toBe("true"); + expect(removeItem.textContent).toBe("Remove machine"); + fireEvent.focus(removeItem); + expect( + await screen.findByRole("tooltip", { + name: "bb's primary machine can't be removed.", + }), + ).toBeDefined(); + fireEvent.click(removeItem); + expect( + screen.queryByRole("heading", { name: "Remove MacBook Pro?" }), + ).toBeNull(); expect(vi.mocked(sdk.hosts.delete)).not.toHaveBeenCalled(); }); }); diff --git a/apps/app/src/components/settings/MachinesSettingsSection.tsx b/apps/app/src/components/settings/MachinesSettingsSection.tsx index 785d274b60..1afd5371c9 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import type { Host, PermissionMode } from "@bb/domain"; +import { RETRY_ACTION_ICON } from "@bb/domain/update-state"; import type { HostPlatform } from "@bb/host-daemon-contract"; import { Button } from "@bb/shared-ui/button"; import { @@ -17,6 +18,13 @@ import { } from "@bb/shared-ui/dropdown-menu"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; +import { ResourceRowDetailChevron } from "@bb/shared-ui/resource-list"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@bb/shared-ui/tooltip"; import { AddMachineDialog } from "@/components/dialogs/AddMachineDialog"; import { ConfirmDeleteDialog } from "@/components/dialogs/ConfirmDeleteDialog"; import { appToast } from "@/components/ui/app-toast"; @@ -33,10 +41,10 @@ import { useRenameHost, useRetryHostUpdate, } from "@/hooks/mutations/host-mutations"; -import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; +import { useHosts } from "@/hooks/queries/host-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useSystemConfig } from "@/hooks/queries/system-queries"; -import { PersistentHostIconName } from "@/lib/host-display"; +import { useHostDaemon } from "@/hooks/useHostDaemon"; import { getSettingsMachineRoutePath } from "@/lib/route-paths"; import { PERMISSION_MODE_OPTIONS } from "@/lib/permission-mode-options"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; @@ -46,19 +54,19 @@ import { hostCanRetryUpdate, } from "@/lib/host-update-status"; -/** Fixed column so every machine's limit lands on the same vertical line. */ -const MACHINE_LIMIT_COLUMN = "w-36 shrink-0 truncate"; - -const PERMISSION_MODE_LABELS: Record = - Object.fromEntries( - PERMISSION_MODE_OPTIONS.map((option) => [option.value, option.label]), - ) as Record; +const PERMISSION_MODE_PRESENTATION: Record< + PermissionMode, + (typeof PERMISSION_MODE_OPTIONS)[number] +> = Object.fromEntries( + PERMISSION_MODE_OPTIONS.map((option) => [option.value, option]), +) as Record; const MACHINES_SECTION_DESCRIPTION = "Computers that can run your tasks. Pair a machine to run projects and threads on it."; -const PRIMARY_REMOVE_DISABLED_REASON = - "This machine runs bb and can't be removed."; +const PRIMARY_REMOVE_DISABLED_REASON = "bb's primary machine can't be removed."; + +const MACHINE_MENU_ITEM_CLASS = "min-h-9 px-2.5 py-2"; const PLATFORM_LABELS: Record = { darwin: "macOS", @@ -67,40 +75,11 @@ const PLATFORM_LABELS: Record = { unknown: null, }; -function machineMetaLine({ - host, - platformLabel, - projectCount, - now, -}: { - host: Host; - platformLabel: string | null; - projectCount: number; - now: number; -}): string { - const parts: string[] = []; - const updateStatus = formatHostUpdateStatus(host); - if (updateStatus !== null) { - parts.push(updateStatus); - } else if (host.status === "connected") { - parts.push("Online"); - } else if (host.lastSeenAt !== null) { - parts.push( - `Offline · last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`, - ); - } else { - parts.push("Offline"); - } - if (platformLabel !== null) { - parts.push(platformLabel); - } - parts.push(`${projectCount} ${projectCount === 1 ? "project" : "projects"}`); - return parts.join(" · "); -} - interface MachineRowProps { host: Host; isPrimary: boolean; + isThisMachine: boolean; + showPrimaryBadge: boolean; platformLabel: string | null; projectCount: number; now: number; @@ -113,6 +92,8 @@ interface MachineRowProps { function MachineRow({ host, isPrimary, + isThisMachine, + showPrimaryBadge, platformLabel, projectCount, now, @@ -121,74 +102,131 @@ function MachineRow({ onRetryUpdate, retryUpdatePending, }: MachineRowProps) { + const permission = PERMISSION_MODE_PRESENTATION[host.maxPermissionMode]; + const projectLabel = `${projectCount} ${projectCount === 1 ? "project" : "projects"}`; + const connectionLabel = + host.status === "connected" + ? "Online" + : host.lastSeenAt === null + ? "Offline" + : `Offline · last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`; + const updateStatus = formatHostUpdateStatus(host); + const removeItem = ( + { + if (isPrimary) { + event.preventDefault(); + return; + } + onRemove(); + }} + > + + Remove machine + + ); + return ( - - {/* Stretched link: the whole row opens the machine page, while the - controls above it keep their own click targets. */} - - -
-
- - - {host.name} - - {isPrimary ? this machine : null} + +
+ +
+
+ + {host.name} + + {isThisMachine ? ( + this machine + ) : null} + {showPrimaryBadge ? primary : null} +
+
+ + + {connectionLabel} + + {platformLabel === null ? null : ( + {platformLabel} + )} + {projectLabel} + + {permission.label} + + {updateStatus === null ? null : ( + + {updateStatus} + + )} +
+
+ +
+ + + + + + + + + Rename + + {hostCanRetryUpdate(host) ? ( + + + + {retryUpdatePending ? "Retrying update…" : "Retry update"} + + + ) : null} + {isPrimary ? ( + + {removeItem} + + {PRIMARY_REMOVE_DISABLED_REASON} + + + ) : ( + removeItem + )} + + + +
-

- {machineMetaLine({ host, platformLabel, projectCount, now })} -

- {/* Read-only here on purpose: the machine page owns the control, but the - list still has to answer "which machines are capped?" at a glance. */} - - {PERMISSION_MODE_LABELS[host.maxPermissionMode]} - - - - - - - Rename - {hostCanRetryUpdate(host) ? ( - - {retryUpdatePending ? "Retrying update…" : "Retry update"} - - ) : null} - - - Remove machine - {isPrimary ? ( - - {PRIMARY_REMOVE_DISABLED_REASON} - - ) : null} - - - -
); } @@ -200,6 +238,7 @@ function MachineRow({ export function MachinesSettingsSection() { const systemConfig = useSystemConfig(); const hostsQuery = useHosts(); + const { localDaemonHostId, platform: localDaemonPlatform } = useHostDaemon(); const sidebarNavigationQuery = useSidebarNavigation(); const renameHost = useRenameHost(); const removeHost = useRemoveHost(); @@ -210,10 +249,6 @@ export function MachinesSettingsSection() { const hosts = hostsQuery.data; const serverPrimaryHostId = systemConfig.data?.primaryHostId ?? null; - const primaryHostId = useMemo( - () => selectPrimaryHost(hosts, serverPrimaryHostId)?.id ?? null, - [hosts, serverPrimaryHostId], - ); const projects = sidebarNavigationQuery.data?.projects; const projectCountByHostId = useMemo(() => { const counts = new Map(); @@ -228,6 +263,7 @@ export function MachinesSettingsSection() { const now = Date.now(); const primaryHostPlatform = systemConfig.data?.primaryHostPlatform ?? null; + const showMachineIdentityBadges = (hosts?.length ?? 0) > 1; return ( <> @@ -250,53 +286,52 @@ export function MachinesSettingsSection() { ) : hosts.length === 0 ? (

No machines yet.

) : ( - <> -
- - Machine - Permission limit - -
-
- - {hosts.map((host) => ( - { - renameHost.reset(); - setRenameTarget(host); - }} - onRemove={() => { - removeHost.reset(); - setRemoveTarget(host); - }} - onRetryUpdate={() => - retryHostUpdate.mutate(host.id, { - onSuccess: () => { - appToast.success( - `Update retry requested for ${host.name}`, - ); - }, - }) - } - retryUpdatePending={ - retryHostUpdate.isPending && - retryHostUpdate.variables === host.id - } - /> - ))} - -
- + + {hosts.map((host) => ( + { + renameHost.reset(); + setRenameTarget(host); + }} + onRemove={() => { + removeHost.reset(); + setRemoveTarget(host); + }} + onRetryUpdate={() => + retryHostUpdate.mutate(host.id, { + onSuccess: () => { + appToast.success( + `Update retry requested for ${host.name}`, + ); + }, + }) + } + retryUpdatePending={ + retryHostUpdate.isPending && + retryHostUpdate.variables === host.id + } + /> + ))} + )} diff --git a/apps/app/src/components/settings/ProvidersSettingsSection.test.tsx b/apps/app/src/components/settings/ProvidersSettingsSection.test.tsx new file mode 100644 index 0000000000..fae886a39b --- /dev/null +++ b/apps/app/src/components/settings/ProvidersSettingsSection.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProviderInfo } from "@bb/domain"; +import { defaultAppSettings } from "@bb/domain"; +import { ProvidersSettingsSection } from "./ProvidersSettingsSection"; + +const mocks = vi.hoisted(() => ({ + providers: [] as ProviderInfo[], +})); + +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemProviders: () => ({ data: mocks.providers, isPending: false }), +})); + +function provider(id: string, displayName: string): ProviderInfo { + return { + id, + displayName, + logoUrl: null, + available: true, + experimental_providerHealth: false, + experimental_providerUsage: false, + experimental_providerInstallation: false, + capabilities: { + supportsThreadArchive: false, + supportsThreadRename: false, + supportsServiceTier: false, + supportsNativeUserQuestion: false, + supportsFork: false, + supportsSessionRewind: false, + permissionModes: ["full"], + }, + composerActions: [], + }; +} + +afterEach(cleanup); + +describe("ProvidersSettingsSection", () => { + it("writes the full picker order and the default as user settings", () => { + // The server lists providers in effective order; the section must write + // the COMPLETE order back (not just the moved id), so the server's + // pinned-then-install-order overlay cannot reshuffle the unmoved rows. + mocks.providers = [ + provider("alpha", "Alpha"), + provider("beta", "Beta"), + provider("gamma", "Gamma"), + ]; + const onChange = vi.fn(); + render( + , + ); + + // No explicit default: the first row reads as the default. + const rows = screen.getAllByText(/Alpha|Beta|Gamma/); + expect(rows.map((row) => row.textContent)).toEqual([ + "Alpha", + "Beta", + "Gamma", + ]); + expect(screen.getAllByText("Default")).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: "Move Gamma up" })); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaultAppSettings, + providerOrder: ["alpha", "gamma", "beta"], + }); + + fireEvent.click(screen.getAllByRole("button", { name: "Make default" })[1]!); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaultAppSettings, + defaultProviderId: "gamma", + }); + }); + + it("disables the edges and marks an unavailable provider", () => { + mocks.providers = [ + provider("alpha", "Alpha"), + { ...provider("beta", "Beta"), available: false }, + ]; + render( + , + ); + expect( + (screen.getByRole("button", { name: "Move Alpha up" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole("button", { name: "Move Beta down" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(screen.getByText("Unavailable")).toBeTruthy(); + // An unavailable provider cannot become the default. + expect( + (screen.getByRole("button", { name: "Make default" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); +}); diff --git a/apps/app/src/components/settings/ProvidersSettingsSection.tsx b/apps/app/src/components/settings/ProvidersSettingsSection.tsx new file mode 100644 index 0000000000..5902ac462b --- /dev/null +++ b/apps/app/src/components/settings/ProvidersSettingsSection.tsx @@ -0,0 +1,134 @@ +import type { AppSettings, ProviderInfo } from "@bb/domain"; +import { Button } from "@bb/shared-ui/button"; +import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { + SettingsBadge, + SettingsRow, + SettingsRowList, + SettingsSection, +} from "@/components/ui/settings-section"; +import { useSystemProviders } from "@/hooks/queries/system-queries"; +import { + getProviderIconColorClass, + getProviderIconInfo, +} from "@/lib/provider-icon"; + +interface ProvidersSettingsSectionProps { + disabled: boolean; + generalSettings: AppSettings; + onGeneralSettingsChange: (next: AppSettings) => void; +} + +/** + * The generic provider directory: every registered provider in picker order. + * Order and the default are user settings; each provider's own options + * (memory, native subagents, …) live on its plugin's settings page, never + * here — core knows no provider by name. + */ +export function ProvidersSettingsSection({ + disabled, + generalSettings, + onGeneralSettingsChange, +}: ProvidersSettingsSectionProps) { + const providersQuery = useSystemProviders(); + // The server already applies `providerOrder`; the list arrives in the + // order the picker shows. + const providers: ProviderInfo[] = providersQuery.data ?? []; + const ids = providers.map((provider) => provider.id); + + const move = (providerId: string, delta: -1 | 1): void => { + const index = ids.indexOf(providerId); + const target = index + delta; + if (index === -1 || target < 0 || target >= ids.length) return; + const next = [...ids]; + next.splice(index, 1); + next.splice(target, 0, providerId); + onGeneralSettingsChange({ ...generalSettings, providerOrder: next }); + }; + + return ( + + {providersQuery.isPending ? ( +

Loading providers…

+ ) : providers.length === 0 ? ( +

+ No agent provider is enabled. Enable a provider plugin under Plugins. +

+ ) : ( + + {providers.map((provider, index) => { + const ProviderIcon = getProviderIconInfo( + provider.id, + provider.logoUrl, + )?.icon; + const isDefault = + generalSettings.defaultProviderId === provider.id || + (generalSettings.defaultProviderId === null && index === 0); + return ( + + + {ProviderIcon ? ( + + ) : ( + + )} + + + {provider.displayName} + + {!provider.available ? ( + Unavailable + ) : null} + {isDefault ? ( + Default + ) : ( + + )} + + + + ); + })} + + )} +
+ ); +} diff --git a/apps/app/src/components/settings/SettingsSidebar.tsx b/apps/app/src/components/settings/SettingsSidebar.tsx index 4de8a15a4f..ff00a2d18b 100644 --- a/apps/app/src/components/settings/SettingsSidebar.tsx +++ b/apps/app/src/components/settings/SettingsSidebar.tsx @@ -6,15 +6,13 @@ import { SectionSidebarLabel, SectionSidebarRow, } from "@/components/sidebar/SectionSidebar"; -import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { SETTINGS_ROUTE_PATH, getPluginConfigurationRoutePath, - getSettingsProviderRoutePath, getSettingsRoutePath, } from "@/lib/route-paths"; -import { getProviderIconInfo } from "@/lib/provider-icon"; import { useSettingsNavState } from "./settings-nav"; +import type { SettingsNavState } from "./settings-nav"; interface SettingsSidebarProps { onResizeMouseDown: (event: ReactMouseEvent) => void; @@ -25,22 +23,31 @@ interface SettingsSidebarProps { mobileHosted?: boolean; } -/** Focused Settings navigation using the shared section-sidebar shell. */ -export function SettingsSidebar({ +type SettingsSidebarNavigation = Pick< + SettingsNavState, + | "activePluginId" + | "activeSection" + | "pluginEntries" + | "sections" +>; + +interface SettingsSidebarContentProps extends SettingsSidebarProps { + navigation: SettingsSidebarNavigation; + testIdPrefix?: string; +} + +/** Shared Settings navigation renderer for production and full-page stories. */ +export function SettingsSidebarContent({ onResizeMouseDown, isResizing, showTopReserve, appRoutePath, mobileHosted, -}: SettingsSidebarProps) { - const { - activePluginId, - activeProviderId, - activeSection, - pluginEntries, - providerEntries, - sections, - } = useSettingsNavState(); + navigation, + testIdPrefix = "settings", +}: SettingsSidebarContentProps) { + const { activePluginId, activeSection, pluginEntries, sections } = + navigation; return ( Settings
@@ -71,28 +78,6 @@ export function SettingsSidebar({ ))}
-
- Providers -
-
- {providerEntries.map((provider) => { - const ProviderIcon = getProviderIconInfo(provider.id)?.icon; - return ( - - {ProviderIcon ? ( - - ) : ( - - )} - - ); - })} -
{pluginEntries.length > 0 ? ( <>
@@ -140,3 +125,25 @@ export function SettingsSidebar({ ); } + +/** Focused Settings navigation using the shared section-sidebar shell. */ +export function SettingsSidebar({ + onResizeMouseDown, + isResizing, + showTopReserve, + appRoutePath, + mobileHosted, +}: SettingsSidebarProps) { + const navigation = useSettingsNavState(); + + return ( + + ); +} diff --git a/apps/app/src/components/settings/SidebarThreadListSetting.test.tsx b/apps/app/src/components/settings/SidebarThreadListSetting.test.tsx index 21f4787c2b..71295d46ae 100644 --- a/apps/app/src/components/settings/SidebarThreadListSetting.test.tsx +++ b/apps/app/src/components/settings/SidebarThreadListSetting.test.tsx @@ -7,11 +7,11 @@ import { resetPluginSlotStoreForTest, setPluginSlotRegistrations, } from "@/lib/plugin-slots"; +import { threadListProviderAtom } from "@/components/sidebar/threadListProvider"; import { - AUTOMATIC_THREAD_LIST_PROVIDER, - BUILT_IN_THREAD_LIST_PROVIDER, - threadListProviderAtom, -} from "@/components/sidebar/threadListProvider"; + AUTOMATIC_REPLACEMENT_PROVIDER, + BUILT_IN_REPLACEMENT_PROVIDER, +} from "@/lib/plugin-replacement-preference"; import { SidebarThreadListSetting } from "./SidebarThreadListSetting"; afterEach(() => { @@ -46,7 +46,7 @@ describe("SidebarThreadListSetting", () => { ); expect(store.get(threadListProviderAtom)).toBe( - AUTOMATIC_THREAD_LIST_PROVIDER, + AUTOMATIC_REPLACEMENT_PROVIDER, ); const trigger = screen.getByRole("button", { name: "Sidebar thread list", @@ -57,7 +57,7 @@ describe("SidebarThreadListSetting", () => { fireEvent.click(await screen.findByRole("menuitem", { name: /built-in/u })); expect(store.get(threadListProviderAtom)).toBe( - BUILT_IN_THREAD_LIST_PROVIDER, + BUILT_IN_REPLACEMENT_PROVIDER, ); }); }); diff --git a/apps/app/src/components/settings/SidebarThreadListSetting.tsx b/apps/app/src/components/settings/SidebarThreadListSetting.tsx index 3a1a447a5c..524007c096 100644 --- a/apps/app/src/components/settings/SidebarThreadListSetting.tsx +++ b/apps/app/src/components/settings/SidebarThreadListSetting.tsx @@ -10,16 +10,16 @@ import { DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { SettingsWithControl } from "@/components/ui/settings-section"; +import { threadListProviderAtom } from "@/components/sidebar/threadListProvider"; import { - AUTOMATIC_THREAD_LIST_PROVIDER, - BUILT_IN_THREAD_LIST_PROVIDER, - threadListProviderAtom, - threadListProviderKey, -} from "@/components/sidebar/threadListProvider"; + AUTOMATIC_REPLACEMENT_PROVIDER, + BUILT_IN_REPLACEMENT_PROVIDER, + replacementProviderKey, +} from "@/lib/plugin-replacement-preference"; import { usePluginSlots } from "@/lib/plugin-slots"; const BUILT_IN_OPTION = { - key: BUILT_IN_THREAD_LIST_PROVIDER, + key: BUILT_IN_REPLACEMENT_PROVIDER, title: "bb (built-in)", description: "Projects, sections, and nested threads.", } as const; @@ -35,7 +35,7 @@ export function SidebarThreadListSetting() { const automaticProvider = threadLists[0]; if (automaticProvider === undefined) return null; const automaticOption = { - key: AUTOMATIC_THREAD_LIST_PROVIDER, + key: AUTOMATIC_REPLACEMENT_PROVIDER, title: "Automatic", description: `Currently using ${automaticProvider.title} from ${automaticProvider.pluginId}.`, }; @@ -43,7 +43,7 @@ export function SidebarThreadListSetting() { automaticOption, BUILT_IN_OPTION, ...threadLists.map((slot) => ({ - key: threadListProviderKey(slot), + key: replacementProviderKey(slot), title: slot.title, description: slot.description ?? `From the ${slot.pluginId} plugin.`, })), diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.stories.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.stories.tsx index e783ce10e0..b0563986fc 100644 --- a/apps/app/src/components/settings/UpdatesSettingsSection.stories.tsx +++ b/apps/app/src/components/settings/UpdatesSettingsSection.stories.tsx @@ -1,15 +1,30 @@ import type { ReactNode } from "react"; import type { Host } from "@bb/domain"; -import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; -import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract"; +import { UPDATE_ACTION_ICON } from "@bb/domain/update-state"; +import { + HOST_DAEMON_PROTOCOL_VERSION, + type ProviderCliKey, +} from "@bb/host-daemon-contract"; import type { ProviderCliIssue } from "@/components/provider-cli/provider-cli-install"; import type { UpdateInventoryMachine } from "@/hooks/useUpdateInventory"; +import { SettingsStoryChrome } from "../../../.ladle/story-settings-chrome"; +import { + makeHost, + makeProviderCliStatus, +} from "../../../.ladle/story-fixtures"; +import { + StoryState as State, + StoryStateGroup as Group, + StoryStates as Story, +} from "../../../.ladle/story-states"; import { BbAppUpdateRows, + BbDaemonUpdateRow, + ChangelogPreviewCard, MachineUpdatesRows, - UpdatesRowList, - UpdatesSection, + MachineUpdatesSection, + ProviderCliCheckRow, + UpdateActionButton, } from "./UpdatesSettingsSection"; export default { @@ -18,541 +33,570 @@ export default { const noop = () => {}; const NO_JOBS: ReadonlySet = new Set(); +const STORY_NOW = 1_800_000_000_000; -function Stage({ children }: { children: ReactNode }) { - return
{children}
; -} - -function makeHost(overrides: Partial & Pick): Host { - return { - type: "persistent", - status: "connected", - lastSeenAt: 1_700_000_000_000, - maxPermissionMode: "full", - lastRejectedProtocolVersion: null, - createdAt: 1, - updatedAt: 2, - ...overrides, - }; -} - -interface ProviderStatusOverrides { - installed?: boolean; - currentVersion?: string | null; - latestVersion?: string | null; - needsUpdate?: boolean; - versionUnsupported?: boolean; - withAction?: boolean; -} +const NPM_VERSION = { + currentVersion: "0.38.0", + latestVersion: "0.38.0", + source: "npm" as const, + updateAvailable: false, + isDevelopment: false, + upgradeCommand: "npx bb-app@latest", +}; -function providerStatus( - provider: "codex" | "claudeCode", - overrides: ProviderStatusOverrides = {}, -) { - const displayName = provider === "codex" ? "Codex" : "Claude Code"; - const executableName = provider === "codex" ? "codex" : "claude"; - const installed = overrides.installed ?? true; - const needsUpdate = overrides.needsUpdate ?? false; - const withAction = overrides.withAction ?? (needsUpdate || !installed); - return { - displayName, - executableName, - executablePath: installed ? `/usr/local/bin/${executableName}` : null, - installed, - installSource: installed - ? ("npmGlobal" as const) - : ("notInstalled" as const), - currentVersion: - overrides.currentVersion !== undefined - ? overrides.currentVersion - : installed - ? "1.0.0" - : null, - latestVersion: - overrides.latestVersion !== undefined ? overrides.latestVersion : "1.0.1", - minimumSupportedVersion: null, - npmPackageName: null, - npmGlobalPackageVersion: null, - installAction: withAction - ? { - kind: installed ? ("update" as const) : ("install" as const), - label: installed ? ("Update" as const) : ("Install" as const), - commandKind: "exec" as const, - command: installed - ? `${executableName} update` - : `npm install -g ${executableName}`, - } - : null, - needsUpdate, - versionUnsupported: overrides.versionUnsupported ?? false, - }; -} +const DESKTOP_UPDATE = { + lastCheckedAt: "2026-07-19T00:00:00.000Z", + latestVersion: "0.39.0", + pendingVersion: "0.39.0", + platform: "macos" as const, + updateAvailable: true, + updateDownloaded: true, + downloadState: "downloaded" as const, + version: "0.38.0", +}; -function issueFor( - provider: "codex" | "claudeCode", - overrides: ProviderStatusOverrides = {}, +function updateIssue( + provider: ProviderCliKey, + currentVersion: string, + latestVersion: string, ): ProviderCliIssue { - const status = providerStatus(provider, overrides); + const base = makeProviderCliStatus(provider); + const action = { + kind: "update" as const, + label: "Update" as const, + command: `${base.executableName} update`, + }; return { provider, - status, - action: status.installAction, - title: `${status.displayName} update available`, - description: "story", - fingerprint: `${provider}:story`, + status: { + ...base, + currentVersion, + latestVersion, + installAction: action, + needsUpdate: true, + }, + action, + title: `${base.displayName} update available`, + description: `${currentVersion} -> ${latestVersion}`, + fingerprint: `${provider}:${currentVersion}:${latestVersion}`, }; } -function machineOf(args: { +function machineOf({ + host, + isPrimary = false, + issues = [], + statusError = false, + canRetryDaemonUpdate = false, +}: { host: Host; - statuses?: { - codex: ReturnType; - claudeCode: ReturnType; - }; + isPrimary?: boolean; issues?: ProviderCliIssue[]; - statusPending?: boolean; statusError?: boolean; canRetryDaemonUpdate?: boolean; }): UpdateInventoryMachine { + const statusFor = (provider: ProviderCliKey) => + issues.find((issue) => issue.provider === provider)?.status ?? + makeProviderCliStatus(provider); return { - host: args.host, - isPrimary: args.host.id === "host-primary", + host, + isPrimary, providerStatus: - args.statuses === undefined - ? null - : { - ...args.statuses, - cursor: { - ...providerStatus("codex", { installed: false }), - displayName: "Cursor", - executableName: "agent", - latestVersion: null, - installAction: null, - }, - }, - statusPending: args.statusPending ?? false, - statusError: args.statusError ?? false, - issues: args.issues ?? [], - canRetryDaemonUpdate: args.canRetryDaemonUpdate ?? false, + host.status === "connected" + ? { + codex: statusFor("codex"), + "claude-code": statusFor("claude-code"), + "acp-cursor": statusFor("acp-cursor"), + } + : null, + statusPending: false, + statusFetching: false, + statusError, + issues, + canRetryDaemonUpdate, }; } -function MachineSection({ machine }: { machine: UpdateInventoryMachine }) { +function StoryPage({ children }: { children: ReactNode }) { return ( - - - - - + +
{children}
+
); } -function StoryActionButton({ children }: { children: ReactNode }) { - return ( - +/** The default-off changelog preview experiment in its enabled state. */ +export function ChangelogPreviewExperiment() { + // A review story must always expose the initial state, even when this + // browser already exercised dismissal for the same bundled release. + window.localStorage.removeItem( + "bb.settings.updates.dismissed-changelog-version", ); -} - -export function HealthyFleet() { - const statuses = { - codex: providerStatus("codex", { - currentVersion: "0.146.0", - latestVersion: "0.146.0", - }), - claudeCode: providerStatus("claudeCode", { - currentVersion: "2.1.0", - latestVersion: "2.1.0", - }), - }; return ( - - - - Checked 2m ago - - What's new - - } - > - - - - - - 2 machines, all in sync - - } - > - - - - - - + + + ); } -export function MixedFleet() { - const codex = providerStatus("codex", { - currentVersion: "0.145.0", - latestVersion: "0.146.0", - needsUpdate: true, - }); - const claude = providerStatus("claudeCode", { - currentVersion: "2.1.0", - latestVersion: "2.1.0", - }); +function StoryMachineSection({ + machine, + app = false, + appUpdate = false, + action, +}: { + machine: UpdateInventoryMachine; + app?: boolean; + appUpdate?: boolean; + action?: ReactNode; +}) { + const showDaemon = + machine.canRetryDaemonUpdate || machine.host.status !== "connected"; return ( - - - - Checked just now - - What's new - - } - > - - - - - - - 1 machine can't connect - - Update all (1) - - } - > - - - - - - + + {app ? ( + + ) : null} + {showDaemon ? ( + + ) : null} + {machine.statusError ? ( + + ) : null} + + ); } -export function WebAppUpdateAvailable() { +function Why({ items }: { items: readonly string[] }) { return ( - - - - - - - +
    + {items.map((item) => ( +
  • + + • + + {item} +
  • + ))} +
); } -export function DesktopUpdateReady() { +function StoryAppState({ children }: { children: ReactNode }) { + const machine = machineOf({ + host: makeHost({ id: "state-app", name: "workstation" }), + isPrimary: true, + }); return ( - - - - - - - + + {children} + ); } -export function DesktopDownloading() { - return ( - - - - - - - - ); +function manualUpdateIssue( + provider: ProviderCliKey, + currentVersion: string, + latestVersion: string, +): ProviderCliIssue { + const status = makeProviderCliStatus(provider, { + currentVersion, + latestVersion, + installAction: null, + needsUpdate: true, + }); + return { + provider, + status, + action: null, + title: `${status.displayName} update available`, + description: `${currentVersion} -> ${latestVersion}`, + fingerprint: `${provider}:${currentVersion}:${latestVersion}:manual`, + }; } -export function MachineWithUpdateAndInstall() { - const codex = providerStatus("codex", { - currentVersion: "0.140.0", - latestVersion: "0.141.0", - needsUpdate: true, +function missingProviderIssue(provider: ProviderCliKey): ProviderCliIssue { + const status = makeProviderCliStatus(provider, { + executablePath: null, + installed: false, + installSource: "notInstalled", + currentVersion: null, + latestVersion: "2.1.0", + installAction: { + kind: "install", + label: "Install", + command: "npm install -g @anthropic-ai/claude-code", + }, + needsUpdate: false, }); - const claude = providerStatus("claudeCode", { installed: false }); - return ( - - - - ); + return { + provider, + status, + action: status.installAction, + title: `${status.displayName} CLI not installed`, + description: "Not installed", + fingerprint: `${provider}:not-installed`, + }; } -export function MachineRunningAndQueued() { - const codex = providerStatus("codex", { - currentVersion: "0.140.0", - latestVersion: "0.141.0", - needsUpdate: true, +/** + * Every state Settings → Updates can reach, once, using the production rows. + * Keep this separate from the representative page stories: this is the + * reviewed vocabulary catalogue, while those stories exercise page density. + */ +export function UpdateStates() { + const providerUpdate = machineOf({ + host: makeHost({ id: "state-provider-update", name: "workstation" }), + issues: [updateIssue("codex", "0.145.0", "0.146.0")], }); - const claude = providerStatus("claudeCode", { - currentVersion: "2.0.9", - latestVersion: "2.1.0", - needsUpdate: true, + const providerInstalling = machineOf({ + host: makeHost({ id: "state-provider-installing", name: "studio-mac" }), + issues: [updateIssue("claude-code", "2.0.1", "2.1.0")], }); - const machine = machineOf({ - host: makeHost({ id: "host-primary", name: "workstation" }), - statuses: { codex, claudeCode: claude }, + const providerManual = machineOf({ + host: makeHost({ id: "state-provider-manual", name: "homelab" }), + issues: [manualUpdateIssue("codex", "0.145.0", "0.146.0")], + }); + const providerMissing = machineOf({ + host: makeHost({ id: "state-provider-missing", name: "workstation" }), issues: [ - issueFor("codex", { needsUpdate: true }), - issueFor("claudeCode", { needsUpdate: true }), + updateIssue("codex", "0.145.0", "0.146.0"), + missingProviderIssue("claude-code"), ], }); - return ( - - - - - - - - ); -} + const daemonUpdating = machineOf({ + host: makeHost({ + id: "state-daemon-updating", + name: "studio-mac", + status: "disconnected", + lastRejectedProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION - 1, + updatedAt: STORY_NOW - 30_000, + }), + canRetryDaemonUpdate: true, + }); + const daemonStalled = machineOf({ + host: makeHost({ + id: "state-daemon-stalled", + name: "ci-runner-3", + status: "disconnected", + lastRejectedProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION - 1, + updatedAt: STORY_NOW - 6 * 60_000, + }), + canRetryDaemonUpdate: true, + }); + const daemonOffline = machineOf({ + host: makeHost({ + id: "state-daemon-offline", + name: "old-laptop", + status: "disconnected", + }), + }); + const providerCheckFailed = machineOf({ + host: makeHost({ id: "state-provider-check", name: "workstation" }), + statusError: true, + }); -export function MachineOffline() { return ( - - - - ); -} + + + -export function MachineDaemonNeedsUpdate() { - return ( - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + ); } -export function MachineStatusFailed() { +/** Multiple machines, each owning its app, daemon, or provider update rows. */ +export function MultiMachine() { + const workstation = machineOf({ + host: makeHost({ id: "host-primary", name: "workstation" }), + isPrimary: true, + issues: [ + updateIssue("codex", "0.145.0", "0.146.0"), + updateIssue("acp-cursor", "0.48.0", "0.49.0"), + ], + }); + const studioMac = machineOf({ + host: makeHost({ id: "host-studio", name: "studio-mac" }), + issues: [updateIssue("claude-code", "2.0.1", "2.1.0")], + }); + const ciRunner = machineOf({ + host: makeHost({ + id: "host-ci", + name: "ci-runner-3", + status: "disconnected", + lastRejectedProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION - 1, + updatedAt: STORY_NOW - 6 * 60_000, + }), + canRetryDaemonUpdate: true, + }); + return ( - - + + +
+ } /> - + + + ); } -export function MachineChecking() { +/** The same hierarchy without a redundant all-machines wrapper. */ +export function SingleMachine() { + const workstation = machineOf({ + host: makeHost({ id: "host-primary", name: "workstation" }), + isPrimary: true, + issues: [updateIssue("claude-code", "2.0.1", "2.1.0")], + }); return ( - - - + + + ); } -export function SidebarBadge() { +/** A settled machine keeps the existing explicit bb app confirmation. */ +export function NoUpdatesAvailable() { + const workstation = machineOf({ + host: makeHost({ id: "host-primary", name: "workstation" }), + isPrimary: true, + }); return ( - -
- - - - - Updates - -
-
+ + + ); } diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx index 95001843d3..5744a578ce 100644 --- a/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, @@ -8,11 +9,16 @@ import { waitFor, within, } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Host } from "@bb/domain"; import type { BbDesktopApi, BbDesktopInfo } from "@bb/desktop-contract"; -import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract"; +import { + HOST_DAEMON_PROTOCOL_VERSION, + type ProviderCliKey, +} from "@bb/host-daemon-contract"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; import type { ProviderCliIssue, ProviderCliActionableIssue, @@ -55,6 +61,22 @@ vi.mock("@/hooks/useDesktopUpdateInfo", () => ({ useDesktopUpdateInfo: vi.fn(), })); +const hostDaemon = vi.hoisted(() => ({ + localDaemonHostId: null as string | null, +})); + +vi.mock("@/hooks/useHostDaemon", () => ({ + useHostDaemon: () => ({ + localDaemonHostId: hostDaemon.localDaemonHostId, + }), +})); + +const openUrlInExternalBrowserMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/url-open-routing", () => ({ + openUrlInExternalBrowser: openUrlInExternalBrowserMock, +})); + const retryHostUpdateMutateMock = vi.hoisted(() => vi.fn()); vi.mock("@/hooks/mutations/host-mutations", () => ({ @@ -97,14 +119,18 @@ function makeHost(overrides: Partial & Pick): Host { } function makeUpdateIssue(args: { - provider: "codex" | "claudeCode"; + provider: ProviderCliKey; }): ProviderCliActionableIssue { - const displayName = args.provider === "codex" ? "Codex" : "Claude Code"; - const executableName = args.provider === "codex" ? "codex" : "claude"; + const identity = + args.provider === "codex" + ? { displayName: "Codex", executableName: "codex" } + : args.provider === "claude-code" + ? { displayName: "Claude Code", executableName: "claude" } + : { displayName: "Cursor", executableName: "agent" }; + const { displayName, executableName } = identity; const action = { kind: "update" as const, label: "Update" as const, - commandKind: "exec" as const, command: `${executableName} update`, }; return { @@ -132,7 +158,7 @@ function makeUpdateIssue(args: { } function makeManualUpdateIssue(args: { - provider: "codex" | "claudeCode"; + provider: "codex" | "claude-code"; }): ProviderCliIssue { const issue = makeUpdateIssue(args); return { @@ -155,7 +181,7 @@ function makeMachine(args: { canRetryDaemonUpdate?: boolean; }): UpdateInventoryMachine { const issues = args.issues ?? []; - const upToDate = (provider: "codex" | "claudeCode") => { + const upToDate = (provider: ProviderCliKey) => { const issue = issues.find((entry) => entry.provider === provider); if (issue !== undefined) { return issue.status; @@ -167,16 +193,17 @@ function makeMachine(args: { needsUpdate: false, }; }; - const cursorStatus = { - ...makeUpdateIssue({ provider: "codex" }).status, - displayName: "Cursor", - executableName: "agent", - installed: false, - currentVersion: null, - latestVersion: null, - needsUpdate: false, - installAction: null, - }; + const cursorIssue = issues.find((entry) => entry.provider === "acp-cursor"); + const cursorStatus = + cursorIssue?.status ?? + ({ + ...makeUpdateIssue({ provider: "acp-cursor" }).status, + installed: false, + currentVersion: null, + latestVersion: null, + needsUpdate: false, + installAction: null, + } as const); return { host: args.host, isPrimary: args.isPrimary ?? false, @@ -184,11 +211,12 @@ function makeMachine(args: { args.host.status === "connected" ? { codex: upToDate("codex"), - claudeCode: upToDate("claudeCode"), - cursor: cursorStatus, + "claude-code": upToDate("claude-code"), + "acp-cursor": cursorStatus, } : null, statusPending: args.statusPending ?? false, + statusFetching: args.statusPending ?? false, statusError: args.statusError ?? false, issues, canRetryDaemonUpdate: args.canRetryDaemonUpdate ?? false, @@ -209,7 +237,12 @@ function makeInventory(overrides: Partial): UpdateInventory { desktopInfo: null, appUpdateAvailable: false, desktopUpdateReady: false, - machines: [], + machines: [ + makeMachine({ + host: makeHost({ id: "host_primary", name: "workstation" }), + isPrimary: true, + }), + ], pluginAttentionCount: 0, actionableCount: 0, hasAttention: false, @@ -218,15 +251,21 @@ function makeInventory(overrides: Partial): UpdateInventory { }; } -function renderSection(): void { +function renderSection({ + showChangelogPreview = false, +}: { showChangelogPreview?: boolean } = {}): void { render( - - - , + + + + + + + , ); } @@ -235,6 +274,11 @@ const useDesktopUpdateInfoMock = vi.mocked(useDesktopUpdateInfo); const useProviderCliInstallRunnerMock = vi.mocked(useProviderCliInstallRunner); beforeEach(() => { + hostDaemon.localDaemonHostId = null; + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("Changelog unavailable offline")), + ); useProviderCliInstallRunnerMock.mockReturnValue({ failuresByJobKey: new Map(), queuedJobKeys: new Set(), @@ -244,14 +288,130 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); cleanup(); + window.localStorage.clear(); resetAppUpdateCheckStoreForTests(); resetProviderCliInstallStoreForTests(); vi.clearAllMocks(); + vi.unstubAllGlobals(); }); describe("UpdatesSettingsSection", () => { - it("keeps a recently checked healthy fleet quiet and accessible", () => { + it("checks for updates once when the view mounts", async () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const host = makeHost({ id: "host_1", name: "workstation" }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ machines: [makeMachine({ host })] }), + ); + vi.mocked(sdk.system.version).mockResolvedValue( + makeInventory({}).systemVersion!, + ); + + renderSection(); + + // Visiting the page is the request to check — there is no button for it. + expect(screen.queryByRole("button", { name: /check/i })).toBeNull(); + await waitFor(() => { + expect(sdk.system.version).toHaveBeenCalledWith({ force: true }); + }); + // Exactly one: re-renders must not re-fire it, and the store's own + // single-flight guard must not be the only thing preventing a loop. + expect(sdk.system.version).toHaveBeenCalledTimes(1); + }); + + it("aligns Update all with the first machine heading", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [ + makeMachine({ + host: makeHost({ id: "host_1", name: "workstation" }), + issues: [makeUpdateIssue({ provider: "codex" })], + }), + makeMachine({ + host: makeHost({ id: "host_2", name: "homelab" }), + issues: [makeUpdateIssue({ provider: "claude-code" })], + }), + ], + }), + ); + + renderSection(); + + const bulkActions = screen.getByRole("toolbar", { + name: "Bulk update actions", + }); + const updateAll = bulkActions.querySelector( + '[aria-label="Update all 2 CLI tools"]', + ); + expect(updateAll).not.toBeNull(); + expect(updateAll?.className).toContain("bg-foreground"); + expect(updateAll?.className).toContain("text-background"); + expect(updateAll?.textContent).toBe("Update all"); + expect(updateAll?.lastElementChild?.getAttribute("data-icon")).toBe( + "Download", + ); + const workstationHeading = screen.getByRole("heading", { + name: "workstation", + }); + const homelabHeading = screen.getByRole("heading", { name: "homelab" }); + const workstationSection = workstationHeading.closest( + "[data-updates-machine]", + ); + const homelabSection = homelabHeading.closest("[data-updates-machine]"); + expect(workstationSection?.contains(bulkActions)).toBe(true); + expect(homelabSection?.contains(bulkActions)).toBe(false); + expect(bulkActions.querySelector('[data-icon="Download"]')).not.toBeNull(); + expect(bulkActions.parentElement?.className).toContain("pr-4"); + }); + + it("keeps the changelog preview behind its experiment", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + useUpdateInventoryMock.mockReturnValue(makeInventory({})); + + renderSection(); + + expect( + document.querySelector('[data-updates-domain="changelog"]'), + ).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("keeps a recently checked healthy fleet quiet and accessible", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => + Promise.resolve( + new Response(`# Changelog + +## 9.9.9 + +The canonical release summary. + +### New features + +- One current feature. + +### Fixes + +- One current fix. +`), + ), + ), + ); useDesktopUpdateInfoMock.mockReturnValue({ desktopApi: null, desktopInfo: null, @@ -272,18 +432,210 @@ describe("UpdatesSettingsSection", () => { }), ); - renderSection(); + renderSection({ showChangelogPreview: true }); - expect(screen.getByText("Checked 2m ago")).toBeDefined(); - expect(screen.getByText("2 machines, all in sync")).toBeDefined(); - expect(screen.queryByText("Up to date")).toBeNull(); + // A settled row is a mark, named only to a screen reader and on hover. + // Opening the page runs the check, so there is no freshness stamp: the age + // of the claim is always "since you got here". + await waitFor(() => { + expect( + screen + .getAllByText("Up to date") + .every((label) => label.className.includes("sr-only")), + ).toBe(true); + }); + expect(screen.queryByText("2 up to date")).toBeNull(); + expect(screen.getByRole("heading", { name: /workstation/ })).toBeDefined(); + expect(screen.queryByText("Primary")).toBeNull(); + expect(screen.queryByText("This machine")).toBeNull(); + expect(screen.getByRole("heading", { name: /studio-mac/ })).toBeDefined(); + expect(screen.getAllByText("Codex")).toHaveLength(2); + expect(screen.getAllByText("Claude Code")).toHaveLength(2); + expect(screen.queryByText(/Checked/)).toBeNull(); + expect(screen.queryByText(/ago$/)).toBeNull(); expect(screen.queryByText(/^In sync$/)).toBeNull(); + expect(screen.queryByText("workstation, studio-mac")).toBeNull(); + // Opening the page is the request to check, so there is no button to press + // and no freshness stamp to justify one. + expect(screen.queryByRole("button", { name: /check/i })).toBeNull(); + // Nothing needs updating, so the page drops the "Updates" title entirely + // and the settled sentence is the heading. + expect(screen.queryByRole("heading", { name: "Updates" })).toBeNull(); + // The changelog is a preview card at the top of the page, not a row + // action: it is about the release, not about any one row. It stays + // reachable with nothing to install, and every word in it is the + // changelog's own. + expect( + screen.getByRole("button", { name: /^Open the full bb .* changelog$/ }), + ).toBeDefined(); + const changelog = document.querySelector( + '[data-updates-domain="changelog"]', + ); + await waitFor(() => { + expect(changelog?.textContent).toContain("9.9.9"); + }); expect( - screen.getByRole("group", { name: /workstation, Connected/ }), + within(changelog as HTMLElement).getByRole("heading", { + level: 2, + name: "What's new", + }), + ).toBeDefined(); + expect( + within(changelog as HTMLElement).getByRole("heading", { + level: 3, + name: "9.9.9", + }), ).toBeDefined(); + expect(changelog?.textContent).toContain("The canonical release summary."); + expect( + changelog?.querySelector('[data-changelog-version="9.9.9"]'), + ).not.toBeNull(); + const changelogLabel = changelog?.querySelector("[data-changelog-label]"); + expect(changelogLabel?.className).toContain("rounded-sm"); + expect(changelogLabel?.className).not.toContain("rounded-full"); + expect(changelogLabel?.className).toContain("bg-muted/40"); + const changelogPreview = changelog?.querySelector( + "[data-changelog-preview]", + ); + expect(changelogPreview?.className).toContain("p-4"); + expect(changelogPreview?.className).not.toContain("grid"); + expect( + changelog?.querySelector("[data-changelog-release-scroll]")?.className, + ).toContain("max-h-56"); + expect( + changelog?.querySelector("[data-changelog-footer]")?.className, + ).toContain("border-t"); + expect( + changelog?.querySelector("[data-changelog-footer]")?.className, + ).toContain("bg-foreground"); + expect( + changelog?.querySelector("[data-changelog-footer]")?.className, + ).toContain("text-background"); + // The footer is one fixed label, so it cannot change length with whatever + // release happens to be bundled. + expect(changelog?.textContent).toContain("Full changelog"); + expect( + screen.getByRole("button", { + name: "Open the full bb 9.9.9 changelog", + }).className, + ).toContain("font-semibold"); + // The card carries the whole release, not a fixed three: a truncated list + // reads as the complete set unless the reader already knows to doubt it. + for (const highlight of ["New features", "Fixes"]) { + expect( + within(changelog as HTMLElement).getByRole("heading", { + level: 4, + name: highlight, + }), + ).toBeDefined(); + } + expect(changelog?.textContent).toContain("One current feature."); + expect(changelog?.textContent).toContain("One current fix."); + const dismissChangelog = screen.getByRole("button", { + name: "Dismiss bb 9.9.9 changelog preview", + }); + expect(dismissChangelog.querySelector('[data-icon="X"]')).not.toBeNull(); + fireEvent.click( + screen.getByRole("button", { + name: "Open the full bb 9.9.9 changelog", + }), + ); + expect(openUrlInExternalBrowserMock).toHaveBeenCalledWith( + "https://getbb.app/changelog#9-9-9", + ); + vi.useFakeTimers(); + fireEvent.click(dismissChangelog); + expect(screen.getByRole("status").textContent).toContain( + "You're all caught up", + ); + expect( + screen.queryByRole("button", { + name: "Open the full bb 9.9.9 changelog", + }), + ).toBeNull(); + expect(changelog?.getAttribute("data-changelog-dismiss-phase")).toBe( + "confirming", + ); + expect( + changelog?.querySelector("[data-changelog-release-panel]")?.className, + ).toContain("grid-rows-[0fr]"); + const confirmation = changelog?.querySelector( + "[data-changelog-dismiss-confirmation]", + ); + expect(confirmation?.className).toContain("grid-rows-[1fr]"); + expect(confirmation?.className).not.toContain("absolute"); + expect(changelog?.className).toContain("motion-reduce:transition-none"); + expect( + window.localStorage.getItem( + "bb.settings.updates.dismissed-changelog-version", + ), + ).toBe("9.9.9"); + + act(() => vi.advanceTimersByTime(1_999)); + expect(changelog?.getAttribute("data-changelog-dismiss-phase")).toBe( + "confirming", + ); + act(() => vi.advanceTimersByTime(1)); + expect(changelog?.getAttribute("data-changelog-dismiss-phase")).toBe( + "exiting", + ); + expect(changelog?.className).toContain("grid-rows-[0fr]"); + act(() => vi.advanceTimersByTime(180)); + expect( + document.querySelector('[data-updates-domain="changelog"]'), + ).toBeNull(); + vi.useRealTimers(); + + cleanup(); + renderSection({ showChangelogPreview: true }); + await waitFor(() => { + expect(fetch).toHaveBeenCalledTimes(2); + }); + expect( + document.querySelector('[data-updates-domain="changelog"]'), + ).toBeNull(); + + cleanup(); + window.localStorage.setItem( + "bb.settings.updates.dismissed-changelog-version", + "9.9.8", + ); + renderSection({ showChangelogPreview: true }); + await waitFor(() => { + expect( + screen.getByRole("button", { + name: "Dismiss bb 9.9.9 changelog preview", + }), + ).toBeDefined(); + }); + + // Being up to date is the state every row is expected to be in, so it + // carries no indicator: the page spends its dots on exceptions only. + const settledRows = screen.getAllByText(/^Up to date/); + expect( + settledRows.every( + (settled) => + settled.parentElement?.querySelector(".bg-success") === null, + ), + ).toBe(true); + expect(document.querySelector(".bg-success")).toBeNull(); + expect( + document + .querySelector( + '[data-update-state="up-to-date"] [data-icon="CircleCheck"]', + ) + ?.getAttribute("class"), + ).toContain("text-input"); + expect( + document + .querySelector( + '[data-update-state="up-to-date"] [data-icon="CircleCheck"]', + ) + ?.getAttribute("class"), + ).not.toContain("opacity-"); }); - it("does not call an offline fleet all in sync", () => { + it("does not call an offline fleet all in sync", async () => { useDesktopUpdateInfoMock.mockReturnValue({ desktopApi: null, desktopInfo: null, @@ -305,16 +657,91 @@ describe("UpdatesSettingsSection", () => { renderSection(); - expect(screen.getByText("1 machine was not checked")).toBeDefined(); + expect(screen.getByText("homelab")).toBeDefined(); + expect(screen.queryByText("1 offline")).toBeNull(); + expect(screen.getByText("Offline")).toBeDefined(); + const offlineIcon = document.querySelector( + '[data-update-state="offline"] [data-icon="CircleX"]', + ); + expect(offlineIcon?.getAttribute("class")).toContain( + "text-subtle-foreground", + ); + expect(offlineIcon?.getAttribute("class")).not.toContain("text-input"); + const daemonRow = screen + .getByText("bb daemon") + .closest("[data-resource-row]"); + expect(daemonRow).not.toBeNull(); + expect(screen.getByText("bb app")).toBeDefined(); + expect( + screen.getByRole("button", { name: "Open homelab settings" }), + ).toBeDefined(); + // App and daemon rows use the bb identity mark; machine ownership comes + // from the section heading rather than a laptop glyph in the row. + expect( + daemonRow?.querySelector('[data-bb-update-role="daemon"]'), + ).not.toBeNull(); + expect( + document.querySelector('[data-bb-update-role="app"]'), + ).not.toBeNull(); + expect(daemonRow?.querySelector('[data-icon="Laptop"]')).toBeNull(); + // An unreachable machine is not pending update work, so the page still + // leads with the settled answer instead of going silent. + // Every row states its own condition, and a settled one says when that was + // established rather than going blank. + await waitFor(() => { + expect(screen.getByText(/^Up to date/)).toBeDefined(); + }); expect(screen.queryByText(/all in sync/)).toBeNull(); + }); + + it("shows only machines with relevant health status in a mixed fleet", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const stalledHost = makeHost({ + id: "host_3", + name: "homelab", + status: "disconnected", + lastRejectedProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION - 1, + updatedAt: Date.now() - 3 * 60 * 1000, + }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [ + makeMachine({ + host: makeHost({ id: "host_1", name: "workstation" }), + }), + makeMachine({ + host: makeHost({ + id: "host_2", + name: "studio-mac", + status: "disconnected", + }), + }), + makeMachine({ + host: stalledHost, + canRetryDaemonUpdate: true, + }), + ], + }), + ); + + renderSection(); + + expect(document.querySelectorAll("[data-updates-machine]")).toHaveLength(3); + expect(screen.queryByText("Needs attention")).toBeNull(); + expect(screen.getByText("workstation")).toBeDefined(); + expect(screen.getByText("studio-mac")).toBeDefined(); + expect(screen.getByText("Offline")).toBeDefined(); + expect(screen.getByText("homelab")).toBeDefined(); expect( - within(screen.getByRole("group", { name: /homelab, Offline/ })).getByText( - "Offline — connect to check for updates", - ), + screen.getByRole("button", { name: /^Failed · Retry on/ }), ).toBeDefined(); }); - it("explains and retries a stranded daemon update", () => { + it("treats a recent daemon protocol mismatch as an automatic update", () => { useDesktopUpdateInfoMock.mockReturnValue({ desktopApi: null, desktopInfo: null, @@ -339,23 +766,506 @@ describe("UpdatesSettingsSection", () => { renderSection(); - expect(screen.getByText("1 machine can't connect")).toBeDefined(); + expect(screen.getByText("homelab")).toBeDefined(); + expect(screen.queryByText("1 updating")).toBeNull(); + // The machine owns the section; the row identifies the daemon explicitly. + expect(screen.getByText("bb daemon")).toBeDefined(); + expect(screen.getAllByText("In progress").length).toBeGreaterThan(0); expect( - screen.getByText("Can't connect — its bb agent is out of date"), - ).toBeDefined(); + document.querySelector('[data-updates-machine="host_1"]'), + ).not.toBeNull(); + expect(screen.queryByText("1 machine is updating bb")).toBeNull(); + expect(screen.queryByRole("button", { name: "Retry update" })).toBeNull(); + expect(screen.queryByText(/can't connect/i)).toBeNull(); + }); + + it("explains and retries a daemon update that has stalled", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const host = makeHost({ + id: "host_1", + name: "homelab", + status: "disconnected", + lastRejectedProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION - 1, + updatedAt: Date.now() - 3 * 60 * 1000, + }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [ + makeMachine({ + host, + canRetryDaemonUpdate: true, + }), + ], + }), + ); + + renderSection(); + + expect(screen.getByText("homelab")).toBeDefined(); + expect(screen.queryByText("1 needs attention")).toBeNull(); + // Not "stalled": that names an internal step and gives the reader nothing + // to weigh. How long it has been waiting is what makes it judgeable. expect( - screen.getByText( - `Needs update · daemon protocol ${HOST_DAEMON_PROTOCOL_VERSION - 1} · server protocol ${HOST_DAEMON_PROTOCOL_VERSION}`, - ), + screen.getByRole("button", { name: "Failed · Retry on homelab now" }), ).toBeDefined(); + expect(screen.queryByText("1 machine needs attention")).toBeNull(); + expect(screen.queryByText(/daemon protocol/)).toBeNull(); + expect( + screen.getByText("bb daemon").closest("[data-resource-row]")?.className, + ).not.toContain("bg-surface-destructive"); + // A stalled bb update is outstanding update work, so the page must not + // claim everything is settled while that row sits under the claim. + expect(screen.queryByText(/^Up to date/)).toBeNull(); + const stalledMessage = screen.getByText("Update didn't finish"); + expect(stalledMessage.tagName).toBe("SPAN"); + expect(stalledMessage.className).toContain("font-semibold"); + expect(stalledMessage.className).toContain("text-destructive"); + expect(stalledMessage.className).not.toContain("rounded"); + expect(stalledMessage.className).not.toContain("font-mono"); + // The row states the condition once; no banner repeats it above. + expect( + screen.getAllByRole("button", { name: /^Failed · Retry on/ }), + ).toHaveLength(1); + + // One stuck machine already has its own Retry on the row, so a bulk sweep + // beside it would be two controls doing the same thing. + expect( + screen.queryByRole("button", { name: /Update all .* machines now/ }), + ).toBeNull(); - fireEvent.click(screen.getByRole("button", { name: "Retry update" })); + fireEvent.click( + screen.getByRole("button", { + name: "Failed · Retry on homelab now", + }), + ); expect(retryHostUpdateMutateMock).toHaveBeenCalledWith( host.id, expect.objectContaining({ onSuccess: expect.any(Function) }), ); }); + it("names a machine running a newer bb than the server", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [ + makeMachine({ + host: makeHost({ + id: "host_1", + name: "homelab", + status: "disconnected", + // Ahead of the server, so the machine cannot fix itself and no + // retry can help — the server is what has to move. + lastRejectedProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION + 1, + }), + canRetryDaemonUpdate: false, + }), + ], + }), + ); + + renderSection(); + + // "Offline" alone was true here and useless: it sends the reader to check + // a network that is working perfectly. The mark still says offline — that + // is the condition — but the row now names the fix beside the machine. + expect(screen.getByText("Update this app to reconnect")).toBeDefined(); + // Nothing on this machine can resolve it, so the row offers no action. + expect( + screen.queryByRole("button", { name: /Update homelab now/ }), + ).toBeNull(); + }); + + it("sweeps every machine stalled on the same bb update", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + // A server protocol bump rejects every enrolled daemon at once, so a + // broken rollout stalls the whole fleet rather than one machine. + const stalled = ["workstation", "studio-mac", "homelab"].map( + (name, index) => + makeHost({ + id: `host_${index}`, + name, + status: "disconnected", + lastRejectedProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION - 1, + updatedAt: Date.now() - 3 * 60 * 1000, + }), + ); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: stalled.map((host) => + makeMachine({ host, canRetryDaemonUpdate: true }), + ), + }), + ); + + renderSection(); + + expect(screen.queryByText(/^Up to date/)).toBeNull(); + fireEvent.click( + screen.getByRole("button", { + name: "Update all 3 machines now", + }), + ); + expect(retryHostUpdateMutateMock).toHaveBeenCalledTimes(3); + for (const host of stalled) { + expect(retryHostUpdateMutateMock).toHaveBeenCalledWith(host.id); + } + // Each row keeps its own Retry: the sweep is an addition, not a takeover. + expect( + screen.getByRole("button", { + name: "Failed · Retry on studio-mac now", + }), + ).toBeDefined(); + }); + + it("shows installed provider CLIs including up-to-date rows", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const host = makeHost({ id: "host_1", name: "workstation" }); + const codexIssue = makeUpdateIssue({ provider: "codex" }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [ + makeMachine({ host, issues: [codexIssue], isPrimary: true }), + ], + }), + ); + + renderSection(); + + const machineHeading = screen.getByRole("heading", { + name: /workstation/, + }); + const machineSection = machineHeading.closest("section"); + expect(machineSection).not.toBeNull(); + // Settings chrome: the same caption weight every other settings section + // uses, so Updates does not read as a differently-built page. + expect(machineHeading.className).toContain("font-semibold"); + expect(machineHeading.className).toContain("text-foreground"); + const machineName = screen.getByText("workstation"); + expect(machineHeading.querySelector('[data-icon="Laptop"]')).not.toBeNull(); + expect(machineName.nextElementSibling).toBeNull(); + // No summary banner above the rows: with work outstanding the rows are + // the statement, and with none the settled card is the only thing shown. + expect(screen.getByText("bb app")).toBeDefined(); + expect(screen.queryByLabelText(/available update/)).toBeNull(); + expect(screen.getAllByText("workstation")).toHaveLength(1); + expect(screen.getByText("Codex")).toBeDefined(); + const claudeRow = screen + .getByText("Claude Code") + .closest("[data-resource-row]"); + expect(claudeRow).not.toBeNull(); + expect( + claudeRow?.querySelector('[data-update-state="up-to-date"]'), + ).not.toBeNull(); + expect(screen.queryByText("Cursor")).toBeNull(); + expect(screen.queryByText(/^Update available/)).toBeNull(); + expect(screen.queryByText("Choose an update below.")).toBeNull(); + const providerIcon = document.querySelector('[data-provider-icon="codex"]'); + expect(providerIcon).not.toBeNull(); + expect(providerIcon?.querySelector("svg")?.className.baseVal).toContain( + "text-muted-foreground", + ); + // Icon-only. The accessible name is the state and the verb — the row + // already prints the CLI, its versions, and the machine above it. Row and + // bulk actions share the same quiet treatment so neither competes with the + // update inventory itself. + const updateButton = screen.getAllByRole("button", { + name: "Update available · Update Codex on workstation", + })[0]; + expect(updateButton.textContent).toBe(""); + // Drawn by the shared `ResourceActionButton`, so it carries that atom's + // muted treatment rather than a colour this page picked for itself. + expect(updateButton.className).toContain("text-muted-foreground"); + expect(updateButton.className).not.toContain("bg-secondary"); + expect(updateButton.className).not.toContain("bg-foreground"); + // Versions sit inline after the name rather than flushed to the right + // edge, and only the version you'd move to is recoloured and weighted. + const versionMetadata = machineSection + ?.querySelector('[data-provider-icon="codex"]') + ?.closest("[data-resource-row]") + ?.querySelector("[data-version-metadata]"); + expect(versionMetadata?.className).toContain("text-2xs"); + expect(versionMetadata?.className).not.toContain("text-right"); + expect(versionMetadata?.className).not.toContain("ml-auto"); + // Not `font-mono`: that stack resolves to one face, so the target + // version's heavier weight rendered identically to the version you are on. + expect(versionMetadata?.className).not.toContain("font-mono"); + const upgrade = versionMetadata?.querySelector(".text-version-upgrade"); + expect(upgrade?.textContent).toBe("1.0.1"); + expect(upgrade?.className).toContain("font-semibold"); + expect(screen.queryByText("1 up to date")).toBeNull(); + }); + + it("badges the client-local daemon independently from the primary update owner", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const primary = makeHost({ id: "host_primary", name: "workstation" }); + const local = makeHost({ id: "host_local", name: "studio-mac" }); + hostDaemon.localDaemonHostId = local.id; + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [ + makeMachine({ + host: primary, + issues: [makeUpdateIssue({ provider: "codex" })], + isPrimary: true, + }), + makeMachine({ + host: local, + issues: [makeUpdateIssue({ provider: "claudeCode" })], + }), + ], + }), + ); + + renderSection(); + + const primaryHeading = screen.getByRole("heading", { + name: /workstation/u, + }); + const localHeading = screen.getByRole("heading", { name: /studio-mac/u }); + expect(primaryHeading.textContent).not.toContain("Primary"); + expect(primaryHeading.textContent).not.toContain("This machine"); + expect(localHeading.textContent).toContain("This machine"); + expect(localHeading.textContent).not.toContain("Primary"); + }); + + it("lists Cursor updates with the other provider CLIs", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const host = makeHost({ id: "host_1", name: "workstation" }); + const cursorIssue = makeUpdateIssue({ provider: "acp-cursor" }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [makeMachine({ host, issues: [cursorIssue] })], + }), + ); + + renderSection(); + + expect( + screen.getByRole("button", { name: "Open Cursor settings" }), + ).toBeDefined(); + expect( + document.querySelector('[data-provider-icon="acp-cursor"]'), + ).not.toBeNull(); + fireEvent.click( + screen.getByRole("button", { + name: "Update available · Update Cursor on workstation", + }), + ); + expect(startInstallMock).toHaveBeenCalledWith({ + hostId: "host_1", + issue: cursorIssue, + }); + }); + + it("names a machine once above all of its CLI updates", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const host = makeHost({ id: "host_1", name: "workstation" }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [ + makeMachine({ + host, + issues: [ + makeUpdateIssue({ provider: "codex" }), + makeUpdateIssue({ provider: "claude-code" }), + ], + }), + ], + }), + ); + + renderSection(); + + // The machine heads the group; its rows name only the tool. Repeating the + // hostname on every row was the redundancy this grouping removes. + expect(screen.getAllByText("workstation")).toHaveLength(1); + expect(screen.getByText("Codex")).toBeDefined(); + expect(screen.getByText("Claude Code")).toBeDefined(); + // Versions stay per row: the same CLI is routinely a different version on + // each host, which is why the rows cannot collapse to one per provider. + expect( + document + .querySelector('[data-updates-machine="host_1"]') + ?.querySelectorAll("[data-resource-row] [data-version-metadata]") + .length, + ).toBe(2); + // Each row still drives its own host-scoped install. + fireEvent.click( + screen.getAllByRole("button", { + name: /^Update available · Update/, + })[0], + ); + expect(startInstallMock).toHaveBeenCalledTimes(1); + expect(startInstallMock.mock.calls[0]?.[0]).toMatchObject({ + hostId: "host_1", + }); + }); + + it("keeps background provider checks out of the compact view", async () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const host = makeHost({ id: "host_1", name: "workstation" }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [makeMachine({ host, statusPending: true })], + }), + ); + + renderSection(); + + // Every row states its own condition, and a settled one says when that was + // established rather than going blank. + await waitFor(() => { + expect(screen.getByText(/^Up to date/)).toBeDefined(); + }); + expect(screen.queryByText("1 up to date")).toBeNull(); + expect(screen.getByRole("heading", { name: "workstation" })).toBeDefined(); + expect(screen.queryByText("Checking provider CLIs…")).toBeNull(); + }); + + it("offers a way out of a failed CLI check", async () => { + // The status query is session-static (staleTime Infinity, no refetch on + // mount/focus/reconnect), so an errored row used to be permanent for the + // life of the page: it named a problem with no affordance to clear it. + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const host = makeHost({ id: "host_1", name: "workstation" }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [makeMachine({ host, statusError: true })], + }), + ); + + renderSection(); + + await waitFor(() => { + expect(screen.getByText("Couldn't check for updates")).toBeDefined(); + }); + const retry = screen.getByRole("button", { + name: /Check workstation's CLIs again/, + }); + expect(retry.hasAttribute("disabled")).toBe(false); + }); + + it("keeps error red on the reason and off the recovery", () => { + // One rule for the whole page: red states what is wrong, never what fixes + // it. A destructive-tinted Retry reads as a second failure rather than a + // way out, and it drifted before because three branches each decided tone + // for themselves. + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const host = makeHost({ id: "host_1", name: "workstation" }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [makeMachine({ host, statusError: true })], + }), + ); + + renderSection(); + + const failedStatus = screen.getByText("Couldn't check for updates"); + expect(failedStatus.tagName).toBe("SPAN"); + for (const className of [ + "shrink-0", + "text-xs", + "font-semibold", + "text-destructive", + ]) { + expect(failedStatus.className).toContain(className); + } + for (const className of [ + "rounded", + "border", + "px-", + "py-", + "bg-", + "font-mono", + ]) { + expect(failedStatus.className).not.toContain(className); + } + expect( + screen.getByRole("button", { name: /Check workstation's CLIs again/ }) + .className, + ).not.toContain("text-destructive"); + }); + + it("leaves never-installed CLIs off an update page", () => { + // An update page lists things that have an update. A CLI you never + // installed has no version to be behind, so it is a first-install decision + // and belongs on Providers — it used to sit here permanently with a + // Download control and count toward "Update all". + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const host = makeHost({ id: "host_1", name: "workstation" }); + const missingCodex = makeUpdateIssue({ provider: "codex" }); + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [ + makeMachine({ + host, + issues: [ + { + ...missingCodex, + status: { + ...missingCodex.status, + installed: false, + currentVersion: null, + }, + }, + makeUpdateIssue({ provider: "claude-code" }), + ], + }), + ], + }), + ); + + renderSection(); + + expect(screen.getByText("Claude Code")).toBeDefined(); + expect(screen.queryByText("Codex")).toBeNull(); + }); + it("removes running and queued provider jobs from Update all", () => { useDesktopUpdateInfoMock.mockReturnValue({ desktopApi: null, @@ -364,7 +1274,7 @@ describe("UpdatesSettingsSection", () => { }); const host = makeHost({ id: "host_1", name: "workstation" }); const codexIssue = makeUpdateIssue({ provider: "codex" }); - const claudeIssue = makeUpdateIssue({ provider: "claudeCode" }); + const claudeIssue = makeUpdateIssue({ provider: "claude-code" }); useUpdateInventoryMock.mockReturnValue( makeInventory({ machines: [makeMachine({ host, issues: [codexIssue, claudeIssue] })], @@ -372,7 +1282,7 @@ describe("UpdatesSettingsSection", () => { ); useProviderCliInstallRunnerMock.mockReturnValue({ failuresByJobKey: new Map(), - queuedJobKeys: new Set(["host_1:claudeCode"]), + queuedJobKeys: new Set(["host_1:claude-code"]), runningJobKey: "host_1:codex", startInstall: startInstallMock, }); @@ -380,9 +1290,21 @@ describe("UpdatesSettingsSection", () => { renderSection(); expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull(); - expect(screen.getByText("2 updates in progress")).toBeDefined(); - expect(screen.getByText("Running…")).toBeDefined(); - expect(screen.getByText("Queued")).toBeDefined(); + expect(screen.queryByText("2 updates in progress")).toBeNull(); + // Running and queued are the same spinner: one is not a state the reader + // can act on differently from the other. + expect( + document.querySelectorAll( + '[data-updates-machine="host_1"] [data-resource-row] [data-update-state="in-progress"]', + ).length, + ).toBe(2); + for (const providerId of ["codex", "claude-code"]) { + expect( + document + .querySelector(`[data-provider-icon="${providerId}"] svg`) + ?.getAttribute("class"), + ).toContain("text-muted-foreground"); + } }); it("keeps a provider update failure and its command log on the row", () => { @@ -392,7 +1314,7 @@ describe("UpdatesSettingsSection", () => { isDesktop: false, }); const host = makeHost({ id: "host_1", name: "workstation" }); - const issue = makeUpdateIssue({ provider: "claudeCode" }); + const issue = makeUpdateIssue({ provider: "claude-code" }); const logDialogState = { displayName: "Claude Code", log: "$ claude update\npermission denied\n", @@ -407,7 +1329,7 @@ describe("UpdatesSettingsSection", () => { useProviderCliInstallRunnerMock.mockReturnValue({ failuresByJobKey: new Map([ [ - "host_1:claudeCode", + "host_1:claude-code", { issueFingerprint: issue.fingerprint, logDialogState }, ], ]), @@ -422,8 +1344,14 @@ describe("UpdatesSettingsSection", () => { expect(screen.getByRole("alert").textContent).toBe( "Command exited with code 1", ); - expect(screen.getByRole("button", { name: "Retry" })).toBeDefined(); - fireEvent.click(screen.getByRole("button", { name: "View log" })); + expect( + screen.getByRole("button", { + name: "Failed · Retry Claude Code on workstation", + }), + ).toBeDefined(); + fireEvent.click( + screen.getByRole("button", { name: "View Claude Code update log" }), + ); expect(getProviderCliInstallSnapshot().logDialogState).toEqual( logDialogState, ); @@ -456,8 +1384,24 @@ describe("UpdatesSettingsSection", () => { renderSection(); expect(screen.getByText("npx bb-app@latest")).toBeDefined(); expect(screen.getByText("0.0.6")).toBeDefined(); + // Icon-only row action: the accessible name carries what the label used to. + const copyButton = screen.getByRole("button", { + name: "Update available · Copy the upgrade command", + }); + expect(copyButton.textContent).toBe(""); + // Row actions are plain regardless of domain. + expect(copyButton.className).not.toContain("bg-secondary"); + const updateSurface = document.querySelector( + '[data-updates-machine="host_primary"]', + ); + // The house settings card, with its rows on the house divider — the same + // chrome every other section of Settings is drawn in. + expect(updateSurface?.querySelector(".bg-card")).not.toBeNull(); + expect(updateSurface?.querySelector(".divide-y")).not.toBeNull(); + expect(screen.queryByText(/^Update available/)).toBeNull(); - fireEvent.click(screen.getByRole("button", { name: "Check for updates" })); + // Opening the page is the check. Nothing to click, and the forced refresh + // still bypasses the cached version. await waitFor(() => { expect(sdk.system.version).toHaveBeenCalledWith({ force: true }); }); @@ -475,8 +1419,9 @@ describe("UpdatesSettingsSection", () => { version: "0.0.5", }; const checkForUpdates = vi.fn().mockResolvedValue(desktopInfo); + const installUpdate = vi.fn().mockResolvedValue(undefined); useDesktopUpdateInfoMock.mockReturnValue({ - desktopApi: { checkForUpdates } as unknown as BbDesktopApi, + desktopApi: { checkForUpdates, installUpdate } as unknown as BbDesktopApi, desktopInfo, isDesktop: true, }); @@ -490,9 +1435,16 @@ describe("UpdatesSettingsSection", () => { ); renderSection(); - expect(screen.getByRole("button", { name: "Relaunch" })).toBeDefined(); + const relaunch = screen.getByRole("button", { + name: /Relaunch bb to finish updating/, + }); + expect(relaunch.querySelector("img")?.className).toContain("size-3"); + expect(relaunch.className).toContain("border"); + fireEvent.click(relaunch); + expect(installUpdate).toHaveBeenCalledOnce(); - fireEvent.click(screen.getByRole("button", { name: "Check for updates" })); + // On a desktop shell the load-time check goes through the bridge, not the + // server's version endpoint. await waitFor(() => { expect(checkForUpdates).toHaveBeenCalledTimes(1); }); @@ -518,7 +1470,7 @@ describe("UpdatesSettingsSection", () => { renderSection(); - expect(screen.getByText("Available")).toBeDefined(); + expect(screen.getByText("Update available")).toBeDefined(); expect(screen.queryByText("Downloading in the background…")).toBeNull(); }); @@ -542,14 +1494,20 @@ describe("UpdatesSettingsSection", () => { useUpdateInventoryMock.mockReturnValue(makeInventory({ desktopInfo })); renderSection(); - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - + // The page already checked once on load; Retry is a second, explicit run. await waitFor(() => { expect(checkForUpdates).toHaveBeenCalledTimes(1); }); + fireEvent.click( + screen.getByRole("button", { name: "Failed · Retry the download" }), + ); + + await waitFor(() => { + expect(checkForUpdates).toHaveBeenCalledTimes(2); + }); }); - it("runs every actionable provider update across machines from Update all", () => { + it("runs every actionable provider update across machines from Update all", async () => { useDesktopUpdateInfoMock.mockReturnValue({ desktopApi: null, desktopInfo: null, @@ -558,7 +1516,7 @@ describe("UpdatesSettingsSection", () => { const laptop = makeHost({ id: "host_1", name: "laptop" }); const homelab = makeHost({ id: "host_2", name: "homelab" }); const laptopIssue = makeUpdateIssue({ provider: "codex" }); - const homelabIssue = makeUpdateIssue({ provider: "claudeCode" }); + const homelabIssue = makeUpdateIssue({ provider: "claude-code" }); useUpdateInventoryMock.mockReturnValue( makeInventory({ machines: [ @@ -572,8 +1530,17 @@ describe("UpdatesSettingsSection", () => { renderSection(); expect(useProviderCliInstallRunnerMock).toHaveBeenCalled(); + expect(screen.getByRole("heading", { name: "laptop" })).toBeDefined(); + expect(screen.getByRole("heading", { name: "homelab" })).toBeDefined(); + // The count stays in the accessible name while the visible control uses + // the established update glyph and the concise requested label. + const updateAll = screen.getByRole("button", { + name: "Update all 2 CLI tools", + }); + expect(updateAll.textContent).toBe("Update all"); + expect(updateAll.querySelector('[data-icon="Download"]')).not.toBeNull(); - fireEvent.click(screen.getByRole("button", { name: "Update all (2)" })); + fireEvent.click(updateAll); expect(startInstallMock).toHaveBeenCalledTimes(2); expect(startInstallMock).toHaveBeenNthCalledWith(1, { hostId: "host_1", @@ -592,7 +1559,7 @@ describe("UpdatesSettingsSection", () => { isDesktop: false, }); const host = makeHost({ id: "host_1", name: "workstation" }); - const issue = makeManualUpdateIssue({ provider: "claudeCode" }); + const issue = makeManualUpdateIssue({ provider: "claude-code" }); useUpdateInventoryMock.mockReturnValue( makeInventory({ machines: [makeMachine({ host, issues: [issue] })], @@ -603,9 +1570,26 @@ describe("UpdatesSettingsSection", () => { renderSection(); - expect(screen.getByText("Update manually")).toBeDefined(); - expect(screen.getByText("1 update needs manual action")).toBeDefined(); + // Where to do it, not the category: bb has no installer it can drive for + // this install, so the next step is a terminal. + expect(screen.getAllByText("Update in terminal").length).toBeGreaterThan(0); + expect(screen.queryByText("1 update needs manual action")).toBeNull(); expect(screen.queryByRole("button", { name: "Update" })).toBeNull(); expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull(); }); + + it("omits an empty machine container", async () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + useUpdateInventoryMock.mockReturnValue(makeInventory({ machines: [] })); + + renderSection(); + + expect(document.querySelector("[data-updates-machine]")).toBeNull(); + expect(screen.queryByText("No machines yet.")).toBeNull(); + expect(screen.getByText("No machines available.")).toBeDefined(); + }); }); diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.tsx index 9ca3c92b8b..46615b6864 100644 --- a/apps/app/src/components/settings/UpdatesSettingsSection.tsx +++ b/apps/app/src/components/settings/UpdatesSettingsSection.tsx @@ -1,21 +1,44 @@ import { useEffect, - useId, + useRef, useState, useSyncExternalStore, type ReactNode, } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import ReactMarkdown, { type Components } from "react-markdown"; +import { useNavigate } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { BbDesktopInfo } from "@bb/desktop-contract"; import type { SystemVersionResponse } from "@bb/server-contract"; +import { + RETRY_ACTION_ICON, + UPDATE_ACTION_ICON, + UPDATE_STATE_PRESENTATION, + type UpdateState, +} from "@bb/domain/update-state"; import { Button, type ButtonProps } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; +import { usePrefersReducedMotion } from "@bb/shared-ui/hooks/use-media-query"; +import { Icon, type IconName } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@bb/shared-ui/tooltip"; +import { + ResourceActionButton, + ResourceListState, + ResourceRow, +} from "@bb/shared-ui/resource-list"; import { hasProviderCliAction, + isProviderCliUpdateIssue, + providerCliEntries, useProviderCliInstallRunner, type ProviderCliActionableIssue, type ProviderCliIssue, + type ProviderCliStatusEntry, } from "@/components/provider-cli/provider-cli-install"; import { openProviderCliInstallLog, @@ -23,13 +46,25 @@ import { type ProviderCliInstallFailure, } from "@/components/provider-cli/provider-cli-install-store"; import { + checkErrorDescription, getAppUpdateCheckSnapshot, startAppUpdateCheck, subscribeAppUpdateCheck, } from "@/components/settings/app-update-check-store"; -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; -import { SettingsBadge } from "@/components/ui/settings-section"; +import { + CHANGELOG_RELEASE_META, + fetchLatestChangelogEntry, + LATEST_CHANGELOG_ENTRY, + type ChangelogBlock, +} from "@/components/settings/changelog-preview"; import { appToast } from "@/components/ui/app-toast"; +import { BbLogo } from "@/components/ui/bb-logo"; +import { OverflowFade } from "@/components/ui/overflow-fade"; +import { + SettingsBadge, + SettingsRowList, + SettingsSection, +} from "@/components/ui/settings-section"; import { invalidateHostProviderCliStatus } from "@/hooks/cache-owners/provider-cli-status-cache-owner"; import { hydrateSystemVersionCache } from "@/hooks/cache-owners/system-version-cache-owner"; import { useRetryHostUpdate } from "@/hooks/mutations/host-mutations"; @@ -37,206 +72,785 @@ import { useUpdateInventory, type UpdateInventoryMachine, } from "@/hooks/useUpdateInventory"; +import { useHostDaemon } from "@/hooks/useHostDaemon"; import { useDesktopUpdateInfo } from "@/hooks/useDesktopUpdateInfo"; import { copyToClipboardWithToast } from "@/lib/clipboard"; -import { formatHostUpdateStatus } from "@/lib/host-update-status"; -import { formatRelativeTime } from "@/lib/relative-time"; +import { + hostCanRetryUpdate, + hostNeedsUpdate, + hostUpdateIsStalled, +} from "@/lib/host-update-status"; import { openUrlInExternalBrowser } from "@/lib/url-open-routing"; +import { + getSettingsMachineRoutePath, + getSettingsRoutePath, +} from "@/lib/route-paths"; +import { getProviderIconInfo } from "@/lib/provider-icon"; import { sdk } from "@/lib/sdk"; +import { rawStringLocalStorage } from "@/lib/browser-storage"; -const CHANGELOG_URL = "https://github.com/get-bb/bb/blob/main/CHANGELOG.md"; const EMPTY_PROVIDER_CLI_FAILURES: ReadonlyMap< string, ProviderCliInstallFailure > = new Map(); +const CHANGELOG_URL = "https://getbb.app/changelog"; +const CHANGELOG_STALE_TIME_MS = 5 * 60_000; +const CHANGELOG_DISMISSED_VERSION_STORAGE_KEY = + "bb.settings.updates.dismissed-changelog-version"; +const CHANGELOG_DISMISS_CONFIRMATION_MS = 2_000; +const CHANGELOG_DISMISS_EXIT_MS = 180; -/** - * The rows and the machine bands above them share one text edge: names start - * at `pl-7` (28px), and the status dot sits centred on `left-3.5` (14px) — - * the same column the child rows' hairline spine runs down. The dot therefore - * caps the spine instead of pushing the machine name out of alignment. - */ -const GUTTER_TEXT = "pl-7"; -const GUTTER_MARK = "absolute left-3.5 top-0 flex h-8 w-0 items-center"; +interface ChangelogDismissal { + phase: "confirming" | "exiting"; + version: string; +} -function updateCheckErrorDescription(error: unknown): string { - if (error instanceof Error && error.message.length > 0) { - return error.message; +function isNewerChangelogVersion( + candidate: string, + dismissed: string, +): boolean { + const versionPattern = /^\d+(?:\.\d+)*$/; + if (!versionPattern.test(candidate) || !versionPattern.test(dismissed)) { + return candidate !== dismissed; } - return "The update check did not complete."; + const candidateParts = candidate.split(".").map(Number); + const dismissedParts = dismissed.split(".").map(Number); + const partCount = Math.max(candidateParts.length, dismissedParts.length); + for (let index = 0; index < partCount; index += 1) { + const candidatePart = candidateParts[index] ?? 0; + const dismissedPart = dismissedParts[index] ?? 0; + if (candidatePart !== dismissedPart) { + return candidatePart > dismissedPart; + } + } + return false; } -function RowButton({ className, ...props }: ButtonProps) { +/** Stalled machines needed before the page offers a bulk retry. */ +const BULK_RETRY_THRESHOLD = 1; + +/** + * A row action. The icon-only form delegates to the shared + * `ResourceActionButton`, which already owns the tooltip, the loading + * spinner, and a `disabledReason` that explains a blocked action rather than + * only greying it out. Labelled forms stay local — the shared atom is + * icon-only by design. + */ +export function UpdateActionButton({ + label, + tooltipLabel, + icon, + iconPosition = "start", + visibleLabel, + className, + variant, + loading = false, + disabled = false, + disabledReason, + onClick, +}: { + label: string; + /** Short tooltip when the accessible label is a full sentence. */ + tooltipLabel?: string; + icon: IconName; + iconPosition?: "start" | "end"; + visibleLabel?: string; + className?: string; + variant?: ButtonProps["variant"]; + loading?: boolean; + disabled?: boolean; + disabledReason?: ReactNode; + onClick?: () => void; +}) { + if (visibleLabel === undefined) { + return ( + onClick?.()} + /> + ); + } + // Only a quiet button gets the quiet text colour. Applying it regardless + // painted `text-subtle-foreground` over a filled variant's own foreground — + // mid-grey on near-black, which is unreadable rather than merely quiet. + const isQuiet = variant === undefined || variant === "ghost"; return ( ); } -export interface UpdatesSectionProps { - title: string; - /** Right-hand slot: the freshness stamp and any section-wide action. */ - action?: ReactNode; - /** Quiet line below the card, for the one caveat worth stating. */ - footnote?: string; - children: ReactNode; -} +/** + * The grid every line in a card sits on: mark, content, trailing controls. + * + * One constant rather than one string per caller, because the whole point is + * that they agree — `ResourceRow` uses this template internally, so a row, a + * caption and bb's own row all end their content column in the same place and + * truncate long text at the same point. + */ +const ROW_GRID = + "grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-3"; /** - * A settings section whose card is flush — rows run edge to edge so their - * separators and machine bands are full-bleed. Deliberately not - * `SettingsSection`, whose padded card would inset every row. + * A row with no destination — bb itself, which has no page of its own to open. + * It borrows `ResourceRow`'s grid rather than its behaviour so its mark, name + * and action land on the same three columns as the rows that are navigable; + * a plain flex row put bb's name half a mark to the left of every CLI's. */ -export function UpdatesSection({ - title, - action, - footnote, +function UpdatesRow({ + leading, children, -}: UpdatesSectionProps) { - const titleId = useId(); - - return ( -
-
-

- {title} -

- {action !== undefined ? ( -
- {action} -
- ) : null} -
-
- {children} -
- {footnote !== undefined ? ( -

{footnote}

- ) : null} -
- ); -} - -/** Groups rows so the first one drops its top separator. */ -export function UpdatesRowList({ children }: { children: ReactNode }) { + className, +}: { + leading?: ReactNode; + children: ReactNode; + className?: string; +}) { return ( -
+
+ + {leading} + {children}
); } -type RowTone = "default" | "attention" | "destructive"; - -function UpdatesRow({ - tone = "default", - indent = false, - children, +/** + * Versions read as part of the name's own phrase — "Codex 0.145.0 → 0.146.0" — + * rather than as a right-aligned column. Sitting in the same line box as the + * name is what keeps the baselines shared no matter how long the name is; a + * right-flushed column drifted away from the text it described and had to be + * re-anchored every time an action's width changed. + * + * Not `font-mono`. The mono stack resolves to a single face here, so + * `font-medium` on the target version rendered at exactly the same weight as + * the version you are on — measured identical widths at 400 through 700 — and + * the pair lost the contrast that makes it scannable. Mono is still right for + * the upgrade *command*, which is text you retype; a version number is prose. + * + * No current version means nothing is installed here; showing `latest` alone + * would read as the version you have, so the row's status label says it. + */ +function RowVersions({ + current, + latest, }: { - tone?: RowTone; - indent?: boolean; - children: ReactNode; + current: string | null; + latest: string | null; }) { + if (current === null) { + return null; + } return ( -
- {indent ? ( - + {current} + {latest !== null && latest !== current ? ( + <> + + {/* The only recoloured half of the pair: what you'd move to reads + louder than what you're on, so the row is scannable without + parsing two version numbers. Semibold, not medium — at 10px a + single step buys almost no contrast, and small text needs more + weight than body text to hold the same emphasis. */} + {latest} + ) : null} - {children} -
+ ); } /** - * Name and version read as one phrase ("Codex 0.146.0") instead of being split - * across a wide gutter; the right edge belongs to state and actions only. + * The bb app's row. `detail` carries the same weight as a machine row's + * provider name: secondary to the thing's identity, ahead of its versions. */ function RowName({ name, + detail, current, latest, }: { name: string; + detail?: ReactNode; current: string | null; latest: string | null; }) { return ( - - {name} - {/* No current version means nothing is installed here; showing `latest` - alone would read as the version you have. The row's label says it. */} - {current === null ? null : ( - - {current} - {latest !== null && latest !== current ? ( - <> - - {latest} - - ) : null} - - )} + + + {name} + + {detail} + ); } /** - * The right-hand slot. A healthy row passes nothing: "Up to date" repeated - * down a column carried no information, so the settled state is now the - * absence of a label and only exceptions speak. + * A row's condition as one mark, named on hover. + * + * The bb card reports condition; the Providers card reports decisions. A + * condition is the same handful of words on every row — "Up to date", "Offline" + * — so spelling it out down a column reads as a wall of repetition that says + * nothing about which row differs. A mark says which row differs at a glance. + * + * The tooltip is the state's name and nothing more. It does not repeat what the + * row already prints (the CLI, the versions, the machine), and it never carries + * something the reader has to act on — that is a visible caption's job. The + * label is always in the accessibility tree, so nothing is hover-only for a + * screen reader. */ -function RowStatus({ - tone = "subtle", - live = false, +/** + * Red belongs to the statement of what is wrong, and to nothing else. + * + * A row says its condition exactly once — as words (`RowStateCaption`) or, when + * it has no words, as the inert glyph. That one element carries the error tone. + * Controls never do: a button is the way out of the problem, not part of it, and + * a destructive-red "Retry" reads as a second failure rather than a recovery. + * + * So there are two red surfaces on this page and no others. Anything that takes + * a click stays untinted, whatever state it belongs to. + */ +function stateTextClass(state: UpdateState): string { + return UPDATE_STATE_PRESENTATION[state].tone === "error" + ? "font-semibold text-destructive" + : "font-semibold text-subtle-foreground"; +} + +/** + * A state's words, placed beside the version rather than beside its control. + * + * The trailing column is the control spine: one thing per row sits on it. When + * a row has both something to say and something to press, the words belong to + * the row's identity — left, flush after the version — and the control keeps + * the spine to itself. + */ +function RowStateCaption({ + state, children, }: { - tone?: "subtle" | "attention" | "destructive"; - live?: boolean; + state: UpdateState; children: ReactNode; }) { return ( + + {children} + + ); +} + +function RowStateControl({ + state, + actionIcon, + actionLabel, + actionTooltip, + buttonLeading, + buttonLabel, + loading = false, + live = false, + onClick, +}: { + state: UpdateState; + /** + * Overrides the state's glyph when the control does something other than the + * state implies — the web bb row copies an upgrade command rather than + * fetching anything, and a Download arrow there promises an install that + * never happens. + */ + actionIcon?: IconName; + /** + * What clicking does. This is the accessible name, so it stays specific — + * two "Retry" buttons in a fleet are indistinguishable to a screen reader + * without the machine in them. + */ + actionLabel?: string; + /** + * The visible tooltip, when it should be shorter than the accessible name. + * A tooltip sits next to the row that already prints the CLI, its versions + * and its machine, so repeating them there is noise; a screen reader has no + * such context and needs the long form. + */ + actionTooltip?: string; + /** Optional decorative mark before a labelled action. */ + buttonLeading?: ReactNode; + /** Renders the control as a labelled button carrying the state's glyph. */ + buttonLabel?: string; + loading?: boolean; + live?: boolean; + onClick?: () => void; +}) { + const presentation = UPDATE_STATE_PRESENTATION[state]; + const icon = actionIcon ?? (presentation.icon as IconName | null); + // A retryable failure defaults to the shared retry glyph. A caller can + // provide a product mark when the action is specifically about that product. + const buttonIcon = + state === "failed" ? (RETRY_ACTION_ICON as IconName) : null; + const spin = loading || presentation.inFlight === true; + const srLabel = presentation.label; + // A spinner is self-evident on sight, so it gets no tooltip — but it still + // needs its words in the accessibility tree, where nothing is self-evident. + const explainOnHover = presentation.inFlight !== true; + + // A state with a resolution is ONE labelled control carrying its mark — not + // a mark beside a button repeating it. + if (onClick !== undefined && buttonLabel !== undefined) { + return ( + + {/* Untinted by rule — see `stateTextClass`. A failure's control wears + the reload glyph; everything else is label-only. */} + + + ); + } + + if (onClick !== undefined && icon !== null) { + return ( + + + + ); + } + + // A glyph-less state still holds the spine, so a column of rows keeps one + // right edge whether each row ends in an icon or a button. + if (icon === null) { + return ; + } + + const mark = ( - {children} + + {srLabel} + + ); + + if (!explainOnHover) { + return {mark}; + } + + return ( + + + + {mark} + {/* The state and nothing else. Anything a reader has to act on is a + visible caption; anything the row already shows is not repeated. */} + {presentation.label} + + ); } +/** + * The trailing column, flush to the card's inner edge. Section bulk actions + * land on the same edge, so every control on the page — per-row and + * per-section — shares one right spine against the content's left one. + */ + function RowActions({ children }: { children: ReactNode }) { return ( - {children} + // `gap-1` is `ResourceRow`'s own gap between a row's meta and its action, + // so a bb row's status lands on the same spine as every machine row's. + + {children} + + ); +} + +const CHANGELOG_INLINE_COMPONENTS: Components = { + p: ({ children }) => <>{children}, + a: ({ children, href }) => ( + { + event.preventDefault(); + if (href !== undefined) { + openUrlInExternalBrowser(href); + } + }} + > + {children} + + ), + code: ({ children }) => ( + + {children} + + ), + strong: ({ children }) => ( + {children} + ), +}; + +function ChangelogInline({ text }: { text: string }) { + return ( + + {text} + + ); +} + +function ChangelogBlocks({ + blocks, + lede = false, +}: { + blocks: ChangelogBlock[]; + lede?: boolean; +}) { + return blocks.map((block, index) => + block.kind === "list" ? ( +
    + {block.items.map((item) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ +

+ ), + ); +} + +/** + * A compact card rendering of the same release structure as getbb.app. Version + * and date stay in a short metadata line so the release content owns the full + * card width. The bundled release stays available offline; the live source + * keeps it current. + */ +export function ChangelogPreviewCard() { + const changelogQuery = useQuery({ + queryKey: ["updates", "changelog", "latest"], + queryFn: ({ signal }) => fetchLatestChangelogEntry(fetch, signal), + placeholderData: LATEST_CHANGELOG_ENTRY ?? undefined, + retry: false, + staleTime: CHANGELOG_STALE_TIME_MS, + }); + const entry = changelogQuery.data ?? LATEST_CHANGELOG_ENTRY; + const [dismissedVersion, setDismissedVersion] = useState(() => + rawStringLocalStorage.getItem(CHANGELOG_DISMISSED_VERSION_STORAGE_KEY, ""), + ); + const [dismissal, setDismissal] = useState(null); + const prefersReducedMotion = usePrefersReducedMotion(); + const releaseBodyRef = useRef(null); + const [moreBelow, setMoreBelow] = useState(false); + const syncFade = (node: HTMLDivElement | null) => { + if (node === null) { + return; + } + setMoreBelow(node.scrollTop + node.clientHeight < node.scrollHeight - 1); + }; + useEffect(() => { + syncFade(releaseBodyRef.current); + }, [entry]); + useEffect(() => { + if (dismissal?.phase !== "confirming") { + return; + } + const timeoutId = window.setTimeout(() => { + setDismissal((current) => + current?.version === dismissal.version + ? { ...current, phase: "exiting" } + : current, + ); + }, CHANGELOG_DISMISS_CONFIRMATION_MS); + return () => window.clearTimeout(timeoutId); + }, [dismissal]); + useEffect(() => { + if (dismissal?.phase !== "exiting") { + return; + } + const dismissedEntryVersion = dismissal.version; + const timeoutId = window.setTimeout( + () => { + setDismissedVersion(dismissedEntryVersion); + setDismissal((current) => + current?.version === dismissedEntryVersion ? null : current, + ); + }, + prefersReducedMotion ? 0 : CHANGELOG_DISMISS_EXIT_MS, + ); + return () => window.clearTimeout(timeoutId); + }, [dismissal, prefersReducedMotion]); + if (entry === null) { + return null; + } + if ( + dismissedVersion.length > 0 && + (changelogQuery.dataUpdatedAt === 0 || + !isNewerChangelogVersion(entry.version, dismissedVersion)) + ) { + return null; + } + const releaseMeta = CHANGELOG_RELEASE_META[entry.version]; + const dismissalPhase = + dismissal?.version === entry.version ? dismissal.phase : "visible"; + const releaseVisible = dismissalPhase === "visible"; + return ( +
section]:min-h-0 [&>section]:overflow-hidden", + dismissalPhase === "exiting" + ? "-mb-6 grid-rows-[0fr] -translate-y-1 opacity-0" + : "grid-rows-[1fr] translate-y-0 opacity-100", + )} + > + + What's new + + } + action={ + releaseVisible ? ( + + + + + Dismiss + + ) : ( + + ) + } + bodyClassName="p-0" + > +
+
+
+
+ + {entry.version} + + {releaseMeta === undefined ? null : ( + + {releaseMeta.date} + + )} +
+ +
+
syncFade(event.currentTarget)} + className="max-h-56 overflow-y-auto pr-3" + > +

+ {releaseMeta?.headline ?? entry.version} +

+ {entry.lede.length === 0 ? null : ( +
+ +
+ )} + {entry.sections.map((section) => ( +
+

+ {section.title} +

+ +
+ ))} +
+ {moreBelow ? : null} +
+
+
+ +
+
+
+
+
+
+
+
+ + + {entry.version} + +
+

+ You're all caught up +

+

+ We'll show the next bb release here. +

+
+
+
+
+
+
); } -export interface BbAppUpdateRowsProps { +interface BbAppUpdateRowsProps { systemVersion: SystemVersionResponse | undefined; desktopInfo: BbDesktopInfo | null; isDesktop: boolean; onRelaunchDesktop: (() => void) | null; onRetryDesktop: (() => void) | null; + /** A check is in flight; the row says so instead of asserting a result. */ + isChecking?: boolean; } /** @@ -250,15 +864,44 @@ export function BbAppUpdateRows({ isDesktop, onRelaunchDesktop, onRetryDesktop, + isChecking = false, }: BbAppUpdateRowsProps) { + // No "checked 2m ago": opening this page runs the check, so the age of the + // claim is always "since you got here" and printing it just gives the reader + // a number to evaluate instead of an answer. + const settledStatus = isChecking ? ( + + ) : ( + + ); + // Every branch below ends in the same shape — mark, name, status, action + // slot — so no branch can quietly drop a column and knock the row out of the + // page's spines. + // One indicator per row. The state's mark *is* the control where the state + // has a resolution, so a row never shows a condition beside a separate + // button that means the same thing. + const row = (name: ReactNode, indicator: ReactNode, caption?: ReactNode) => ( + + +
+ } + > + + {name} + {caption} + + {indicator} + + ); if (isDesktop && desktopInfo === null) { - return ( - - - - Checking… - - + return row( + // One bb, however it happens to be packaged. The desktop shell and a + // web/npm install are two ways to reach the same thing to update, not + // two things, so the row does not rename itself per surface. + , + , ); } @@ -267,76 +910,66 @@ export function BbAppUpdateRows({ desktopInfo.pendingVersion ?? desktopInfo.latestVersion; const latest = desktopInfo.updateAvailable ? pendingVersion : null; const name = ( - + ); if (desktopInfo.updateDownloaded) { - return ( - - {name} - - Downloaded - onRelaunchDesktop?.()}> - Relaunch - - - + return row( + name, + // One control: a small bb mark inside its own outlined labelled button. + } + buttonLabel="Relaunch" + actionLabel="Relaunch bb to finish updating" + onClick={() => onRelaunchDesktop?.()} + />, ); } if (desktopInfo.downloadState === "downloading") { - return ( - - {name} - - - Downloading in the background… - - - - ); + return row(name, ); } if (desktopInfo.downloadState === "failed") { - return ( - - {name} - - Download failed - onRetryDesktop?.()}>Retry - - + return row( + name, + onRetryDesktop?.()} + />, + Download failed, ); } if (desktopInfo.updateAvailable) { - return ( - - {name} - - Available - - - ); + // The shell downloads on its own; the version pair in the name already + // says what is coming, so the mark only says it is in hand. + return row(name, ); } - return {name}; + return row(name, settledStatus); } if (systemVersion === undefined) { - return ( - - - - Checking… - - + return row( + , + , ); } const name = ( + {systemVersion.upgradeCommand} +
+ ) : undefined + } current={systemVersion.currentVersion} latest={ systemVersion.updateAvailable ? systemVersion.latestVersion : null @@ -344,327 +977,390 @@ export function BbAppUpdateRows({ /> ); - if (systemVersion.isDevelopment) { - return ( - - {name} - - Development mode - - - ); - } - if (systemVersion.updateAvailable) { - return ( - - {name} - - Available - {/* The command sits with the button that copies it, rather than - crowding the app name on the left. */} - - {systemVersion.upgradeCommand} - - { - void copyToClipboardWithToast(systemVersion.upgradeCommand, { - successMessage: "Upgrade command copied", - errorMessage: "Couldn't copy upgrade command", - }); - }} - > - Copy - - - + return row( + name, + { + void copyToClipboardWithToast(systemVersion.upgradeCommand, { + successMessage: "Upgrade command copied", + errorMessage: "Couldn't copy upgrade command", + }); + }} + />, ); } - return {name}; + return row(name, settledStatus); } -export interface MachineUpdatesRowsProps { +interface MachineUpdatesRowsProps { machine: UpdateInventoryMachine; runningJobKey: string | null; queuedJobKeys: ReadonlySet; failuresByJobKey?: ReadonlyMap; - retryUpdatePending: boolean; onStartInstall: (hostId: string, issue: ProviderCliActionableIssue) => void; - onRetryDaemonUpdate: (hostId: string) => void; + /** Opens the Providers settings bucket — the row's real destination. */ + onOpenProvider: (providerId: string) => void; } -interface ProviderRowState { - label: string; - rowTone: RowTone; - statusTone: "subtle" | "attention" | "destructive"; +function machineHasRelevantHealthStatus( + machine: UpdateInventoryMachine, +): boolean { + return ( + machine.statusError || + machine.canRetryDaemonUpdate || + machine.host.status !== "connected" + ); } -function providerRowState({ - issue, - installed, -}: { - issue: ProviderCliIssue | null; - installed: boolean; -}): ProviderRowState | null { - if (!installed) { - return { label: "Not installed", rowTone: "default", statusTone: "subtle" }; +function visibleProviderUpdateIssues( + machine: UpdateInventoryMachine, +): ProviderCliIssue[] { + if ( + machine.canRetryDaemonUpdate || + machine.host.status !== "connected" || + machine.statusError || + machine.statusPending || + machine.providerStatus === null + ) { + return []; } - // Up to date: the version alone says it. No label, no tint. - if (issue === null) { - return null; - } - if (issue.action === null) { - return { - label: "Update manually", - rowTone: issue.status.versionUnsupported ? "destructive" : "attention", - statusTone: issue.status.versionUnsupported ? "destructive" : "attention", - }; - } - if (issue.status.versionUnsupported) { - return { - label: "Update needed", - rowTone: "destructive", - statusTone: "destructive", - }; + return machine.issues.filter(isProviderCliUpdateIssue); +} + +/** Installed provider rows shown after a successful check, update or not. */ +function visibleInstalledProviderEntries( + machine: UpdateInventoryMachine, +): ProviderCliStatusEntry[] { + if ( + machine.canRetryDaemonUpdate || + machine.host.status !== "connected" || + machine.statusError || + machine.statusPending || + machine.providerStatus === null + ) { + return []; } - return { label: "Available", rowTone: "attention", statusTone: "attention" }; + return providerCliEntries(machine.providerStatus).filter( + (entry) => entry.status.installed, + ); } -/** The band that heads a machine's rows. */ -function MachineBand({ - connected, - name, - headingId, - statusLabel, - badge, - detail, - tone = "default", - children, +/** + * A machine's bb daemon condition. The machine name now owns the section, so + * the row names the software that needs attention and uses the same bb mark as + * the app row. App-versus-daemon is text, never an unexplained icon swap. + */ +export function BbDaemonUpdateRow({ + machine, + now, + retryUpdatePending, + onRetryDaemonUpdate, + onOpenMachine, }: { - connected: boolean; - name: string; - headingId: string; - statusLabel: string; - badge?: ReactNode; - detail?: ReactNode; - tone?: "default" | "destructive"; - children?: ReactNode; + machine: UpdateInventoryMachine; + now: number; + retryUpdatePending: boolean; + onRetryDaemonUpdate: (hostId: string) => void; + onOpenMachine: (hostId: string) => void; }) { + const { host } = machine; + const updateStalled = + machine.canRetryDaemonUpdate && hostUpdateIsStalled(host, now); + const updating = machine.canRetryDaemonUpdate && !updateStalled; + // The daemon is ahead of this server, so no amount of retrying on the + // machine can fix it — the server is the thing that has to move. Left + // unnamed, the row said only "Offline", which is true and useless: it sends + // the reader to check a network that is working. The caption says "this app" + // rather than "bb" because the opposite direction — a machine whose daemon + // is behind — is a different row entirely (it self-updates, with a Retry), + // and "Update bb" reads as an instruction to go touch the remote machine. + const machineIsAhead = hostNeedsUpdate(host) && !hostCanRetryUpdate(host); + const offline = host.status !== "connected"; + + // Words beside the name; the trailing column stays the control spine. + const daemonCaption = updateStalled ? ( + Update didn't finish + ) : machineIsAhead ? ( + + Update this app to reconnect + + ) : null; + return ( -
- - - - - -

- {name} - , {statusLabel} -

- {badge} + onOpenMachine(host.id)} + leading={ + + - {children} -
- {detail !== undefined ? {detail} : null} -
+ } + title="bb daemon" + state={daemonCaption} + trailingMeta={null} + actions={ + // One indicator. `waiting-to-retry` is the only machine state with a + // resolution here, so it is the only one drawn as a control; the rest + // are conditions and say so on hover. + updating ? ( + + ) : updateStalled ? ( + onRetryDaemonUpdate(host.id)} + /> + ) : machineIsAhead ? ( + // No "needs attention": that names a feeling, not a fix. The row + // says what is true (unreachable) and what resolves it (update the + // app it is talking to). + + ) : offline ? ( + + ) : null + } + /> ); } -/** One machine's rows: a header band, then a row per provider CLI. */ +/** A recoverable provider status failure, kept distinct from bb's daemon. */ +export function ProviderCliCheckRow({ + machine, + onRecheckClis, + onOpenMachine, +}: { + machine: UpdateInventoryMachine; + onRecheckClis: (hostId: string) => void; + onOpenMachine: (hostId: string) => void; +}) { + const { host } = machine; + return ( + onOpenMachine(host.id)} + leading={ + + } + title="Provider CLIs" + state={ + + Couldn't check for updates + + } + trailingMeta={null} + actions={ + onRecheckClis(host.id)} + /> + } + /> + ); +} + +/** + * The one update state a CLI row is in. + * + * Keyed off the same vocabulary the bb rows and `bb updates` use, so a CLI + * that reads "update available" in Settings reads "update available" in the + * terminal too. A CLI with nothing wrong produces no issue and so no row. + * + * `not-installed` is absent on purpose: this page filters to update issues, so + * a CLI without an installed version never reaches a row here. The state still + * exists in the shared vocabulary because `bb updates` prints a full status + * table and does report it. + */ +function providerRowState({ + issue, +}: { + issue: ProviderCliIssue | null; +}): UpdateState | null { + if (issue === null) { + return "up-to-date"; + } + if (issue.action === null) { + return "update-manually"; + } + return "update-available"; +} + +/** Provider update rows owned by the surrounding machine section. */ export function MachineUpdatesRows({ machine, runningJobKey, queuedJobKeys, failuresByJobKey = EMPTY_PROVIDER_CLI_FAILURES, - retryUpdatePending, onStartInstall, - onRetryDaemonUpdate, + onOpenProvider, }: MachineUpdatesRowsProps) { const { host } = machine; - const headingId = useId(); + const providerEntries = visibleInstalledProviderEntries(machine); const issuesByProvider = new Map( - machine.issues.map((issue) => [issue.provider, issue]), + visibleProviderUpdateIssues(machine).map((issue) => [ + issue.provider, + issue, + ]), ); - /* - * A protocol-rejected daemon is disconnected, so this machine has no - * provider rows at all — the band has to carry the whole story on its own, - * which is why it is the one header allowed to run multi-line. The raw - * protocol numbers stay, demoted below the plain-language cause. - */ - if (machine.canRetryDaemonUpdate) { - const daemonStatus = formatHostUpdateStatus(host); - return ( -
- this machine - ) : null - } - detail={ - onRetryDaemonUpdate(host.id)} - > - {retryUpdatePending ? "Retrying…" : "Retry update"} - - } - > - - Can't connect — its bb agent is out of date - - - Usually it updates itself. - - {daemonStatus === null ? null : ( - - {daemonStatus} - - )} - -
- ); + if (providerEntries.length === 0) { + return null; } - return ( -
- this machine : null - } - detail={ - host.status === "connected" ? undefined : ( - Offline — connect to check for updates + const rows = providerEntries.map(({ provider, status }) => { + const issue = issuesByProvider.get(provider) ?? null; + const state = providerRowState({ issue }); + const jobKey = providerCliJobKey(host.id, provider); + const running = runningJobKey === jobKey; + const queued = queuedJobKeys.has(jobKey); + const storedFailure = failuresByJobKey.get(jobKey) ?? null; + const failure = + issue !== null && storedFailure?.issueFingerprint === issue.fingerprint + ? storedFailure + : null; + const actionable = + issue !== null && hasProviderCliAction(issue) && !running && !queued; + const providerId = provider; + const ProviderIcon = getProviderIconInfo(providerId)?.icon; + return ( + onOpenProvider(providerId)} + leading={ + ProviderIcon === undefined ? null : ( + + + ) } - /> - {host.status !== "connected" ? null : machine.statusError ? ( - - - Couldn't check provider CLIs on this machine. - - - ) : machine.statusPending || machine.providerStatus === null ? ( - - Checking provider CLIs… - - ) : ( - (["codex", "claudeCode"] as const).map((provider) => { - const status = machine.providerStatus?.[provider]; - if (status === undefined) { - return null; - } - const issue = issuesByProvider.get(provider) ?? null; - const state = providerRowState({ - issue, - installed: status.installed, - }); - const jobKey = providerCliJobKey(host.id, provider); - const running = runningJobKey === jobKey; - const queued = queuedJobKeys.has(jobKey); - const storedFailure = failuresByJobKey.get(jobKey) ?? null; - const failure = - issue !== null && - storedFailure?.issueFingerprint === issue.fingerprint - ? storedFailure - : null; - const actionable = - issue !== null && - hasProviderCliAction(issue) && - !running && - !queued; - return ( - - - - {failure !== null ? ( - Failed - ) : running ? ( - - - - Running… - - - ) : queued ? ( - Queued - ) : state === null ? null : ( - {state.label} - )} - {failure !== null ? ( - - openProviderCliInstallLog(failure.logDialogState) - } - > - View log - - ) : null} - {actionable ? ( - onStartInstall(host.id, issue)}> - {failure === null ? issue.action.label : "Retry"} - - ) : null} - - {failure !== null ? ( -

+ + {failure === null ? null : ( + <> + Failed + {failure.logDialogState.message} -

+ + + )} + + } + trailingMeta={null} + actions={ + running ? ( + + ) : queued ? ( + + ) : failure !== null ? ( + + + openProviderCliInstallLog(failure.logDialogState) + } + /> + {actionable ? ( + onStartInstall(host.id, issue)} + /> ) : null} -
- ); - }) - )} + + ) : state === null ? null : ( + onStartInstall(host.id, issue) : undefined + } + /> + ) + } + /> + ); + }); + + return <>{rows}; +} + +/** One machine owns one settings section; the badge makes local scope explicit. */ +export function MachineUpdatesSection({ + machine, + isThisMachine, + action, + children, +}: { + machine: UpdateInventoryMachine; + isThisMachine: boolean; + action?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ + + {machine.host.name} + {isThisMachine ? ( + This machine + ) : null} + + } + action={ + action === undefined ? undefined : ( +
{action}
+ ) + } + > + {children} +
+
); } @@ -683,11 +1379,22 @@ function useNow(intervalMs: number): number { * Settings → Updates: one consolidated, per-machine view of bb and provider * CLI updates. Replaces the stacked update/provider-health toasts (BB-48). */ -export function UpdatesSettingsSection() { +interface UpdatesSettingsSectionProps { + /** Default-off experiment gate owned by Settings → Experiments. */ + showChangelogPreview?: boolean; +} + +export function UpdatesSettingsSection({ + showChangelogPreview = false, +}: UpdatesSettingsSectionProps = {}) { const queryClient = useQueryClient(); + const navigate = useNavigate(); const inventory = useUpdateInventory(); + const { localDaemonHostId } = useHostDaemon(); const { desktopApi, desktopInfo, isDesktop } = useDesktopUpdateInfo(); const retryHostUpdate = useRetryHostUpdate(); + // The check store outlives this view, so an in-flight check stays visible + // across navigation and a failure still toasts even if we unmount. const isChecking = useSyncExternalStore( subscribeAppUpdateCheck, getAppUpdateCheckSnapshot, @@ -696,43 +1403,28 @@ export function UpdatesSettingsSection() { const { failuresByJobKey, queuedJobKeys, runningJobKey, startInstall } = useProviderCliInstallRunner(); - const allActionableIssues: { + const visibleProviderIssues: { hostId: string; - issue: ProviderCliActionableIssue; + issue: ProviderCliIssue; }[] = inventory.machines.flatMap((machine) => - machine.issues - .filter(hasProviderCliAction) - .map((issue) => ({ hostId: machine.host.id, issue })), - ); - const actionableIssues = allActionableIssues.filter(({ hostId, issue }) => { - const jobKey = providerCliJobKey(hostId, issue.provider); - return runningJobKey !== jobKey && !queuedJobKeys.has(jobKey); - }); - const manualIssueCount = inventory.machines.reduce( - (count, machine) => - count + - machine.issues.filter( - (issue) => issue.status.installed && !hasProviderCliAction(issue), - ).length, - 0, + visibleProviderUpdateIssues(machine).map((issue) => ({ + hostId: machine.host.id, + issue, + })), ); - - const strandedMachines = inventory.machines.filter( - (machine) => machine.canRetryDaemonUpdate, - ).length; - const checkingMachines = inventory.machines.filter( - (machine) => - machine.host.status === "connected" && - !machine.statusError && - (machine.statusPending || machine.providerStatus === null), - ).length; - const uncheckedMachines = inventory.machines.filter( - (machine) => - !machine.canRetryDaemonUpdate && - (machine.host.status !== "connected" || machine.statusError), - ).length; - const activeInstallCount = - (runningJobKey === null ? 0 : 1) + queuedJobKeys.size; + const actionableIssues = visibleProviderIssues + .filter( + ( + entry, + ): entry is { + hostId: string; + issue: ProviderCliActionableIssue; + } => hasProviderCliAction(entry.issue), + ) + .filter(({ hostId, issue }) => { + const jobKey = providerCliJobKey(hostId, issue.provider); + return runningJobKey !== jobKey && !queuedJobKeys.has(jobKey); + }); // Snapshot the hosts at click time: the check runs in a module-level store // so it survives navigating away, and must not read React state afterwards. @@ -756,171 +1448,214 @@ export function UpdatesSettingsSection() { }); } - /* - * "Up to date" is only credible next to a time, so the stamp — not the - * button — is what the section header leads with; checking is a quiet icon - * beside it. - */ - const checkedLabel = - inventory.lastCheckedAt === null - ? null - : `Checked ${formatRelativeTime({ timestamp: inventory.lastCheckedAt, now })}`; - - const machineSummary = - inventory.machines.length === 0 - ? null - : strandedMachines > 0 - ? `${strandedMachines} ${strandedMachines === 1 ? "machine" : "machines"} can't connect` - : uncheckedMachines > 0 - ? `${uncheckedMachines} ${uncheckedMachines === 1 ? "machine was" : "machines were"} not checked` - : checkingMachines > 0 - ? `Checking ${checkingMachines} ${checkingMachines === 1 ? "machine" : "machines"}…` - : activeInstallCount > 0 - ? `${activeInstallCount} ${activeInstallCount === 1 ? "update" : "updates"} in progress` - : manualIssueCount > 0 - ? `${manualIssueCount} ${manualIssueCount === 1 ? "update needs" : "updates need"} manual action` - : actionableIssues.length === 0 - ? `${inventory.machines.length} ${inventory.machines.length === 1 ? "machine" : "machines"}, all in sync` - : null; + // Opening the page is the request to check, so there is no button to press. + // This waits for the host list rather than firing on the first render: the + // check invalidates each connected machine's CLI status, and on mount that + // list is still empty, so an immediate run would refresh the app version and + // silently skip every machine. + const hostsSettled = !inventory.isLoading; + const checkedOnLoad = useRef(false); + useEffect(() => { + if (checkedOnLoad.current || !hostsSettled) { + return; + } + checkedOnLoad.current = true; + handleCheckForUpdates(); + // Deliberately runs once per mount; `handleCheckForUpdates` closes over the + // host snapshot taken at that moment, which is what the check should use. + // oxlint-disable-next-line react/exhaustive-deps + }, [hostsSettled]); + + const appUpdateVisible = + desktopInfo?.updateAvailable === true || + inventory.systemVersion?.updateAvailable === true || + inventory.appUpdateAvailable; + const relevantFleetMachines = inventory.machines.filter( + machineHasRelevantHealthStatus, + ); + // A machine whose own bb update has stalled is outstanding update work, not + // just fleet trivia: it is the third update domain, and the page would claim + // everything is settled while a stalled row sat under it. + const stalledMachines = relevantFleetMachines.filter( + (machine) => + machine.canRetryDaemonUpdate && hostUpdateIsStalled(machine.host, now), + ); + const appMachine = + inventory.machines.find((machine) => machine.isPrimary) ?? + inventory.machines[0] ?? + null; + const visibleMachines = inventory.machines.filter( + (machine) => + machine.host.id === appMachine?.host.id || + machineHasRelevantHealthStatus(machine) || + visibleInstalledProviderEntries(machine).length > 0, + ); + const hasUpdateWork = + appUpdateVisible || + visibleProviderIssues.length > 0 || + stalledMachines.length > 0; + const fleetIsHealthy = relevantFleetMachines.length === 0; + const showFallbackBbStatus = + !hasUpdateWork && !fleetIsHealthy && isDesktop && desktopInfo === null; + + function retryDaemonUpdate(hostId: string): void { + retryHostUpdate.mutate(hostId, { + onSuccess: () => { + const machine = inventory.machines.find( + (candidate) => candidate.host.id === hostId, + ); + appToast.success( + `Retrying the update on ${machine?.host.name ?? "the requested machine"}`, + ); + }, + }); + } + + // One toast for the whole sweep: a per-machine confirmation would stack as + // many toasts as there are stalled machines, which is exactly the pile the + // consolidated Updates page replaced. + function retryAllStalledDaemonUpdates(): void { + for (const machine of stalledMachines) { + retryHostUpdate.mutate(machine.host.id); + } + appToast.success( + `Retrying the update on ${stalledMachines.length} machines`, + ); + } + + const updateAllButton = + actionableIssues.length > 1 ? ( + { + for (const { hostId, issue } of actionableIssues) { + startInstall({ hostId, issue }); + } + }} + /> + ) : null; + const retryAllButton = + stalledMachines.length > BULK_RETRY_THRESHOLD ? ( + + ) : null; + const bulkActions = + retryAllButton !== null || updateAllButton !== null ? ( +
+ {retryAllButton} + {updateAllButton} +
+ ) : null; return ( - <> - - {isChecking || checkedLabel !== null ? ( - - {isChecking ? "Checking…" : checkedLabel} - - ) : null} - - - - openUrlInExternalBrowser(CHANGELOG_URL)} +
+ {showChangelogPreview ? : null} + + {visibleMachines.length === 0 ? ( + + ) : ( + visibleMachines.map((machine, index) => { + const ownsApp = machine.host.id === appMachine?.host.id; + const showDaemon = + machine.canRetryDaemonUpdate || machine.host.status !== "connected"; + return ( + 1 && + machine.host.id === localDaemonHostId + } + action={index === 0 ? bulkActions : null} > - What's new - - - } - > - - { - void desktopApi.installUpdate().catch((error: unknown) => { - appToast.error("Relaunch failed", { - description: updateCheckErrorDescription(error), - }); - }); + {ownsApp ? ( + { + void desktopApi.installUpdate().catch((error) => { + appToast.error("Relaunch failed", { + description: checkErrorDescription(error), + }); + }); + } } - } - onRetryDesktop={ - desktopApi === null - ? null - : () => { - void desktopApi - .checkForUpdates() - .catch((error: unknown) => { - appToast.error("Update retry failed", { - description: updateCheckErrorDescription(error), - }); - }); + onRetryDesktop={ + desktopApi === null || showFallbackBbStatus + ? null + : () => { + void desktopApi.checkForUpdates().catch((error) => { + appToast.error("Update retry failed", { + description: checkErrorDescription(error), + }); + }); + } } - } - /> - - - - - {machineSummary === null ? null : ( - - {machineSummary} - - )} - {actionableIssues.length > 0 ? ( - { - for (const { hostId, issue } of actionableIssues) { - startInstall({ hostId, issue }); - } + /> + ) : null} + {showDaemon ? ( + + navigate(getSettingsMachineRoutePath(hostId)) + } + /> + ) : null} + {machine.statusError ? ( + { + void invalidateHostProviderCliStatus({ + queryClient, + hostId, + }); }} - > - Update all ({actionableIssues.length}) - + onOpenMachine={(hostId) => + navigate(getSettingsMachineRoutePath(hostId)) + } + /> ) : null} - - ) - } - > - {inventory.machines.length === 0 ? ( -

- {inventory.isLoading ? "Loading…" : "No machines yet."} -

- ) : ( - - {inventory.machines.map((machine) => ( startInstall({ hostId, issue }) } - onRetryDaemonUpdate={(hostId) => - retryHostUpdate.mutate(hostId, { - onSuccess: () => { - appToast.success( - `Update retry requested for ${machine.host.name}`, - ); - }, - }) - } + onOpenProvider={() => navigate(getSettingsRoutePath("providers"))} /> - ))} - - )} -
- +
+ ); + }) + )} +
); } diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx index 9882bb398b..b67b71b5f2 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from "react"; -import type { Host } from "@bb/domain"; +import type { Host, ProviderInfo } from "@bb/domain"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { UsageLimitsSettingsSectionContent, @@ -7,7 +7,7 @@ import { } from "./UsageLimitsSettingsSection"; export default { - title: "settings/Settings Page", + title: "settings/Usage Limits", }; type Usage = UsageLimitsSettingsSectionContentProps["usage"]; @@ -31,7 +31,7 @@ const HEALTHY_USAGE: Usage = { }, ], }, - claudeCode: { + "claude-code": { status: "ok", accountEmail: "sawyer@example.com", planLabel: "Max (20x)", @@ -53,7 +53,7 @@ const HEALTHY_USAGE: Usage = { }, ], }, - cursor: { + "acp-cursor": { status: "ok", accountEmail: "sawyer@example.com", planLabel: "Pro", @@ -75,8 +75,8 @@ const HEALTHY_USAGE: Usage = { const AUTH_USAGE: Usage = { codex: { status: "unauthenticated" }, - claudeCode: { status: "expired" }, - cursor: { status: "not_installed" }, + "claude-code": { status: "expired" }, + "acp-cursor": { status: "not_installed" }, }; const EMPTY_AND_ERROR_USAGE: Usage = { @@ -86,7 +86,7 @@ const EMPTY_AND_ERROR_USAGE: Usage = { planLabel: "Team", windows: [], }, - claudeCode: { + "claude-code": { status: "error", message: "Claude usage is temporarily unavailable.", // Read from local credentials before the usage call, so an outage does not @@ -94,7 +94,7 @@ const EMPTY_AND_ERROR_USAGE: Usage = { planLabel: "Max (5x)", accountEmail: null, }, - cursor: { status: "not_installed" }, + "acp-cursor": { status: "not_installed" }, }; const HOSTS: Host[] = [ @@ -133,6 +133,34 @@ const HOSTS: Host[] = [ }, ]; +function provider(id: string, displayName: string): ProviderInfo { + return { + id, + displayName, + logoUrl: null, + available: true, + experimental_providerHealth: true, + experimental_providerUsage: true, + experimental_providerInstallation: false, + capabilities: { + supportsThreadArchive: false, + supportsThreadRename: false, + supportsServiceTier: false, + supportsNativeUserQuestion: false, + supportsFork: false, + supportsSessionRewind: false, + permissionModes: ["full"], + }, + composerActions: [], + }; +} + +const PROVIDERS = [ + provider("codex", "Codex"), + provider("claude-code", "Claude Code"), + provider("acp-cursor", "Cursor"), +]; + function Stage({ children }: { children: ReactNode }) { return
{children}
; } @@ -167,6 +195,7 @@ function UsagePreview({ isError={isError} isFetching={isFetching} onRefresh={noop} + providers={PROVIDERS} hosts={hosts} selectedHostId={selectedHostId} onSelectHost={onSelectHost} diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx index c98d88abc3..4d46137375 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx @@ -2,7 +2,7 @@ import type { ComponentProps } from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { Host } from "@bb/domain"; +import type { Host, ProviderInfo } from "@bb/domain"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; import { UsageLimitsSettingsSectionContent } from "./UsageLimitsSettingsSection"; @@ -25,14 +25,67 @@ const remoteHost: Host = { name: "Build machine", }; +function provider( + id: string, + displayName: string, + supportsUsage = true, + strings?: ProviderInfo["strings"], +): ProviderInfo { + return { + id, + displayName, + logoUrl: null, + available: true, + experimental_providerHealth: true, + experimental_providerUsage: supportsUsage, + experimental_providerInstallation: false, + capabilities: { + supportsThreadArchive: false, + supportsThreadRename: false, + supportsServiceTier: false, + supportsNativeUserQuestion: false, + supportsFork: false, + supportsSessionRewind: false, + permissionModes: ["full"], + }, + composerActions: [], + ...(strings === undefined ? {} : { strings }), + }; +} + +/** The first-party roster with the copy its plugins declare. */ +const FIRST_PARTY_PROVIDERS: ProviderInfo[] = [ + provider("codex", "Codex", true, { + signInHint: "Run `codex` to sign in and see your usage.", + expiredHint: "Your Codex session expired. Run `codex`, then reload usage.", + installUrl: "https://developers.openai.com/codex/cli", + }), + provider("claude-code", "Claude Code", true, { + signInHint: "Run `claude` to sign in and see your usage.", + expiredHint: "Your Claude session expired. Run `claude`, then reload usage.", + installUrl: "https://claude.com/claude-code", + }), + provider("acp-cursor", "Cursor", true, { + signInHint: "Run `cursor-agent login` to sign in and see your usage.", + expiredHint: + "Your Cursor session expired. Run `cursor-agent login`, then reload usage.", + installUrl: "https://cursor.com/docs/cli/installation", + }), +]; + afterEach(cleanup); function renderContent( props: ComponentProps, ) { + // The roster (names and declared copy) comes from the provider list; a + // test that passes none gets the first-party roster, as the live query would. return render( - + , ); } @@ -41,7 +94,7 @@ describe("UsageLimitsSettingsSectionContent", () => { it("renders Cursor plan and on-demand limits", () => { renderContent({ usage: { - cursor: { + "acp-cursor": { status: "ok", accountEmail: "cursor@example.com", planLabel: "Pro", @@ -71,11 +124,11 @@ describe("UsageLimitsSettingsSectionContent", () => { expect(screen.getByText("$5.00 / $50")).toBeDefined(); }); - it("hides Cursor when its CLI is not installed", () => { + it("keeps an uninstalled provider visible with its status", () => { renderContent({ usage: { codex: { status: "unauthenticated" }, - cursor: { status: "not_installed" }, + "acp-cursor": { status: "not_installed" }, }, isLoading: false, isError: false, @@ -83,7 +136,8 @@ describe("UsageLimitsSettingsSectionContent", () => { onRefresh: vi.fn(), }); - expect(screen.queryByRole("heading", { name: "Cursor" })).toBeNull(); + expect(screen.getByRole("heading", { name: "Cursor" })).toBeDefined(); + expect(screen.getByText("Not installed on this machine.")).toBeDefined(); expect(screen.getByRole("heading", { name: "Codex" })).toBeDefined(); }); @@ -101,6 +155,124 @@ describe("UsageLimitsSettingsSectionContent", () => { expect(heading.parentElement?.contains(status)).toBe(true); }); + it("renders usage reported by a plugin provider", () => { + renderContent({ + usage: { + "echo-agent": { + status: "ok", + accountEmail: null, + planLabel: "Team", + windows: [ + { label: "Monthly messages", usedPercent: 25, resetsAt: null }, + ], + }, + }, + providers: [provider("echo-agent", "Echo Agent")], + isLoading: false, + isError: false, + isFetching: false, + onRefresh: vi.fn(), + }); + + expect(screen.getByRole("heading", { name: "Echo Agent" })).toBeDefined(); + expect(screen.getByText("Monthly messages")).toBeDefined(); + expect(screen.getByText("25% used")).toBeDefined(); + }); + + it("renders supported registry providers in registry order", () => { + renderContent({ + usage: { codex: { status: "unauthenticated" } }, + providers: [ + provider("echo-agent", "Echo Agent"), + provider("no-usage", "No Usage", false), + provider("codex", "Codex from registry"), + ], + isLoading: false, + isError: false, + isFetching: false, + onRefresh: vi.fn(), + }); + + expect( + screen + .getAllByRole("heading", { level: 3 }) + .map((heading) => heading.textContent), + ).toEqual(["Echo Agent", "Codex from registry"]); + expect(screen.queryByRole("heading", { name: "No Usage" })).toBeNull(); + expect(screen.getByText("Usage not provided.")).toBeDefined(); + }); + + it("loads supported providers and hides unsupported providers", () => { + renderContent({ + usage: {}, + providers: [ + provider("codex", "Codex"), + provider("echo-agent", "Echo Agent", false), + ], + isLoading: true, + isError: false, + isFetching: true, + onRefresh: vi.fn(), + }); + + expect(screen.getByRole("heading", { name: "Codex" })).toBeDefined(); + expect(screen.queryByRole("heading", { name: "Echo Agent" })).toBeNull(); + expect(screen.getByText("Loading usage…")).toBeDefined(); + expect(screen.queryByText("Usage not provided.")).toBeNull(); + }); + + it("renders completed providers while their peers are still loading", () => { + renderContent({ + usage: { codex: { status: "unauthenticated" } }, + providers: FIRST_PARTY_PROVIDERS.filter( + (entry) => entry.id === "codex" || entry.id === "claude-code", + ), + providerStates: { + codex: { isError: false, isLoading: false }, + "claude-code": { isError: false, isLoading: true }, + }, + isLoading: true, + isError: false, + isFetching: true, + onRefresh: vi.fn(), + }); + + expect(screen.getByText(/Run `codex` to sign in/u)).toBeDefined(); + const claudeHeading = screen.getByRole("heading", { + name: "Claude Code", + }); + const loading = screen.getByText("Loading usage…"); + expect(claudeHeading.parentElement?.contains(loading)).toBe(true); + }); + + it("shows an initial loading message before the provider list arrives", () => { + renderContent({ + usage: {}, + providers: [], + isLoading: true, + isError: false, + isProviderListLoading: true, + isFetching: true, + onRefresh: vi.fn(), + }); + + expect(screen.getByText("Loading providers and usage…")).toBeDefined(); + }); + + it("keeps provider rows visible when the usage request fails", () => { + renderContent({ + usage: {}, + providers: [provider("echo-agent", "Echo Agent")], + isLoading: false, + isError: true, + isFetching: false, + onRefresh: vi.fn(), + }); + + expect(screen.getByRole("heading", { name: "Echo Agent" })).toBeDefined(); + expect(screen.getByText(/Couldn't load usage right now/u)).toBeDefined(); + }); + it("selects which connected machine supplies usage", () => { const onSelectHost = vi.fn(); renderContent({ diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index 10742f633a..941c0401a3 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -1,7 +1,8 @@ import { useId, useState } from "react"; -import type { Host } from "@bb/domain"; +import type { Host, ProviderInfo } from "@bb/domain"; import type { ProviderUsage, + ProviderUsageResponse, ProviderUsageWindow, } from "@bb/host-daemon-contract"; import { Button } from "@bb/shared-ui/button"; @@ -21,7 +22,9 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { useSystemConfig, - useSystemUsageLimits, + useSystemProviderUsageLimits, + useSystemProviders, + type ProviderUsageQueryState, } from "@/hooks/queries/system-queries"; import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; import { @@ -31,38 +34,31 @@ import { import { cn } from "@bb/shared-ui/lib/utils"; interface ProviderConfig { - key: "codex" | "claudeCode" | "cursor"; name: string; - providerId: "codex" | "claude-code" | "acp-cursor"; + providerId: string; signInHint: string; expiredHint: string; } -const PROVIDERS: ProviderConfig[] = [ - { - key: "codex", - name: "Codex", - providerId: "codex", - signInHint: "Run `codex` to sign in and see your usage.", - expiredHint: "Your Codex session expired. Run `codex`, then reload usage.", - }, - { - key: "claudeCode", - name: "Claude Code", - providerId: "claude-code", - signInHint: "Run `claude` to sign in and see your usage.", +/** + * Usage copy comes from the provider's declared `strings`; a provider that + * declares none (a dynamic ACP agent) gets generic copy built from its name. + */ +function providerConfig( + providerId: string, + info: Pick | undefined, +): ProviderConfig { + const name = info?.displayName ?? providerId; + return { + providerId, + name, + signInHint: + info?.strings?.signInHint ?? `Sign in to ${name}, then reload usage.`, expiredHint: - "Your Claude session expired. Run `claude`, then reload usage.", - }, - { - key: "cursor", - name: "Cursor", - providerId: "acp-cursor", - signInHint: "Run `cursor-agent login` to sign in and see your usage.", - expiredHint: - "Your Cursor session expired. Run `cursor-agent login`, then reload usage.", - }, -]; + info?.strings?.expiredHint ?? + `Your ${name} session expired. Sign in again, then reload usage.`, + }; +} function barColorClass(usedPercent: number): string { if (usedPercent >= 95) { @@ -160,15 +156,15 @@ interface ProviderUsageBlockProps { } export interface UsageLimitsSettingsSectionContentProps { - usage: { - codex?: ProviderUsage; - claudeCode?: ProviderUsage; - cursor?: ProviderUsage; - }; + usage: ProviderUsageResponse; isLoading: boolean; isError: boolean; + isProviderListLoading?: boolean; + isProviderListError?: boolean; isFetching: boolean; onRefresh: () => void; + providerStates?: Readonly>; + providers?: readonly ProviderInfo[]; hosts?: readonly Host[]; selectedHostId?: string | null; onSelectHost?: (hostId: string) => void; @@ -314,7 +310,7 @@ function ProviderUsageBody({ if (!usage) { return (

- {isLoading ? "Loading usage…" : "Usage unavailable."} + {isLoading ? "Loading usage…" : "Usage not provided."}

); } @@ -335,7 +331,11 @@ function ProviderUsageBody({
); case "not_installed": - return null; + return ( +

+ Not installed on this machine. +

+ ); case "unauthenticated": return (

{config.signInHint}

@@ -355,16 +355,38 @@ export function UsageLimitsSettingsSectionContent({ usage, isLoading, isError, + isProviderListLoading = false, + isProviderListError = false, isFetching, onRefresh, + providerStates = {}, + providers = [], hosts = [], selectedHostId = null, onSelectHost, }: UsageLimitsSettingsSectionContentProps) { const showMachinePicker = hosts.length > 1 && onSelectHost !== undefined; - const visibleProviders = PROVIDERS.filter( - (config) => usage[config.key]?.status !== "not_installed", + const providerById = new Map( + providers.map((provider) => [provider.id, provider] as const), ); + const reportedProviderIds = Object.keys(usage); + const orderedProviderIds = [ + ...providers + .filter((provider) => provider.experimental_providerUsage) + .map((provider) => provider.id), + ...reportedProviderIds.filter( + (providerId) => !providerById.has(providerId), + ), + ]; + const providerConfigs = orderedProviderIds.map((providerId) => + providerConfig(providerId, providerById.get(providerId)), + ); + const emptyMessage = + isLoading || isProviderListLoading + ? "Loading providers and usage…" + : isError || isProviderListError + ? "Couldn't load providers or usage right now." + : "No providers available."; return ( - {visibleProviders.map((config) => ( - - ))} + {providerConfigs.length === 0 ? ( +

{emptyMessage}

+ ) : ( + providerConfigs.map((config) => ( + + )) + )}
); @@ -429,20 +457,38 @@ export function UsageLimitsSettingsSection() { hosts.find((host) => host.id === selectedHostId) ?? primaryHost; const usageHostId = selectedHost?.id ?? systemConfigQuery.data?.primaryHostId ?? undefined; - const usageQuery = useSystemUsageLimits({ - hostId: usageHostId, - enabled: systemConfigQuery.data !== undefined, + const providersQuery = useSystemProviders( + usageHostId === undefined + ? { + capability: "usage", + enabled: systemConfigQuery.data !== undefined, + } + : { + capability: "usage", + enabled: systemConfigQuery.data !== undefined, + hostId: usageHostId, + }, + ); + const providers = providersQuery.data ?? []; + const usageQuery = useSystemProviderUsageLimits({ + ...(usageHostId === undefined ? {} : { hostId: usageHostId }), + enabled: systemConfigQuery.data !== undefined && providersQuery.isSuccess, + providerIds: providers.map((provider) => provider.id), }); return ( { void usageQuery.refetch(); }} + providerStates={usageQuery.providerStates} + providers={providers} hosts={hosts} selectedHostId={selectedHost?.id ?? null} onSelectHost={setSelectedHostId} diff --git a/apps/app/src/components/settings/app-update-check-store.ts b/apps/app/src/components/settings/app-update-check-store.ts index 8fe974fe4b..d6f0903a83 100644 --- a/apps/app/src/components/settings/app-update-check-store.ts +++ b/apps/app/src/components/settings/app-update-check-store.ts @@ -22,7 +22,7 @@ export function getAppUpdateCheckSnapshot(): boolean { return isChecking; } -function checkErrorDescription(error: unknown): string { +export function checkErrorDescription(error: unknown): string { if (error instanceof Error && error.message.length > 0) { return error.message; } diff --git a/apps/app/src/components/settings/changelog-preview.test.ts b/apps/app/src/components/settings/changelog-preview.test.ts new file mode 100644 index 0000000000..05b21a32e9 --- /dev/null +++ b/apps/app/src/components/settings/changelog-preview.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + CHANGELOG_ENTRIES, + LATEST_CHANGELOG_ENTRY, + parseChangelogEntries, +} from "./changelog-preview"; + +const SAMPLE = `# Changelog + +## 0.37.0 + +A much faster app on your phone, and a long list of fixes. + +### Mobile is much faster + +Every tap used to make bb measure the whole page. + +- Taps answer at once. +- The sidebar keeps its scroll position. + +### Edit a message you already sent + +Turn on **Edit messages** in Settings → Experiments. + +## 0.36.0 + +- Fixed a [crash](https://example.test) on launch. +- Tidied \`bb status\` output. +`; + +describe("parseChangelogEntries", () => { + it("keeps a release's sections out of its version list", () => { + const entries = parseChangelogEntries(SAMPLE); + + // `###` starts with `##`, so a lazy version pattern turns every section + // heading into its own empty release and the preview shows nothing. + expect(entries.map((entry) => entry.version)).toEqual(["0.37.0", "0.36.0"]); + expect(entries[0].sections.map((section) => section.title)).toEqual([ + "Mobile is much faster", + "Edit a message you already sent", + ]); + }); + + it("keeps the website's paragraphs and lists in their release sections", () => { + const [latest] = parseChangelogEntries(SAMPLE); + + expect(latest.lede).toEqual([ + { + kind: "paragraph", + text: "A much faster app on your phone, and a long list of fixes.", + }, + ]); + expect(latest.sections[0]).toEqual({ + title: "Mobile is much faster", + blocks: [ + { + kind: "paragraph", + text: "Every tap used to make bb measure the whole page.", + }, + { + kind: "list", + items: [ + "Taps answer at once.", + "The sidebar keeps its scroll position.", + ], + }, + ], + }); + }); + + it("keeps release-level bullets when there are no sections", () => { + const [, previous] = parseChangelogEntries(SAMPLE); + + expect(previous.sections).toEqual([]); + expect(previous.lede).toEqual([ + { + kind: "list", + items: [ + "Fixed a [crash](https://example.test) on launch.", + "Tidied `bb status` output.", + ], + }, + ]); + }); +}); + +describe("LATEST_CHANGELOG_ENTRY", () => { + it("is the newest release, not the running build's", () => { + // The card says "what's new". Keyed off the running version, a build one + // release behind previewed its own old notes as news. + expect(LATEST_CHANGELOG_ENTRY).toBe(CHANGELOG_ENTRIES[0]); + }); + + it("reads the repo's own changelog", () => { + expect(CHANGELOG_ENTRIES.length).toBeGreaterThan(0); + expect(LATEST_CHANGELOG_ENTRY?.version).toMatch(/^\d+\.\d+\.\d+/); + expect(LATEST_CHANGELOG_ENTRY?.sections.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/app/src/components/settings/changelog-preview.ts b/apps/app/src/components/settings/changelog-preview.ts new file mode 100644 index 0000000000..34fb3fec95 --- /dev/null +++ b/apps/app/src/components/settings/changelog-preview.ts @@ -0,0 +1,166 @@ +import changelogSource from "../../../../../CHANGELOG.md?raw"; + +const LATEST_CHANGELOG_SOURCE_URL = + "https://raw.githubusercontent.com/get-bb/bb/main/CHANGELOG.md"; + +export type ChangelogBlock = + | { kind: "paragraph"; text: string } + | { kind: "list"; items: string[] }; + +interface ChangelogSection { + title: string; + blocks: ChangelogBlock[]; +} + +/** + * The same release shape used by getbb.app/changelog: introductory blocks, + * then titled sections containing paragraphs and lists. + */ +interface ChangelogEntry { + version: string; + lede: ChangelogBlock[]; + sections: ChangelogSection[]; +} + +interface ChangelogReleaseMeta { + date: string; + headline: string; +} + +/** Presentation metadata from the canonical changelog page. */ +export const CHANGELOG_RELEASE_META: Record = { + "0.39.0": { + date: "August 19, 2026", + headline: "Faster large threads and a long list of fixes", + }, + "0.38.0": { + date: "August 15, 2026", + headline: "Extensions Page and Plugin Marketplaces", + }, + "0.37.0": { + date: "August 11, 2026", + headline: "A much faster mobile app", + }, + "0.36.0": { + date: "August 8, 2026", + headline: "Fixes and improvements", + }, + "0.35.0": { date: "August 4, 2026", headline: "Plugins" }, + "0.34.0": { + date: "July 28, 2026", + headline: "Fresher models, cross-provider questions", + }, + "0.33.0": { + date: "July 21, 2026", + headline: "Quieter updates and safer approvals", + }, + "0.0.31": { date: "July 17, 2026", headline: "Splits for everyone" }, + "0.0.30": { + date: "July 14, 2026", + headline: "Multi-machine workflows and bb Connect", + }, + "0.0.29": { + date: "July 9, 2026", + headline: "More agents, more models, redesigned Settings", + }, +}; + +/** Parse the repo changelog with the same block boundaries as the website. */ +export function parseChangelogEntries(source: string): ChangelogEntry[] { + const entries: ChangelogEntry[] = []; + let entry: ChangelogEntry | null = null; + let section: ChangelogSection | null = null; + let paragraph: string[] = []; + + const blocksInScope = (): ChangelogBlock[] | null => { + if (entry === null) { + return null; + } + return section === null ? entry.lede : section.blocks; + }; + + const flushParagraph = () => { + if (paragraph.length === 0) { + return; + } + const text = paragraph.join(" ").trim(); + paragraph = []; + const blocks = blocksInScope(); + if (text !== "" && blocks !== null) { + blocks.push({ kind: "paragraph", text }); + } + }; + + for (const rawLine of source.split("\n")) { + const line = rawLine.trimEnd(); + + if (line.startsWith("## ") && !line.startsWith("### ")) { + flushParagraph(); + section = null; + entry = { version: line.slice(3).trim(), lede: [], sections: [] }; + entries.push(entry); + continue; + } + if (entry === null) { + continue; + } + if (line.startsWith("### ")) { + flushParagraph(); + section = { title: line.slice(4).trim(), blocks: [] }; + entry.sections.push(section); + continue; + } + if (line.startsWith("- ")) { + flushParagraph(); + const blocks = blocksInScope(); + if (blocks === null) { + continue; + } + const last = blocks.at(-1); + const list = + last?.kind === "list" ? last : { kind: "list" as const, items: [] }; + if (last !== list) { + blocks.push(list); + } + list.items.push(line.slice(2).trim()); + continue; + } + if (line.startsWith(" ") && line.trim() !== "") { + const last = blocksInScope()?.at(-1); + if (last?.kind === "list" && last.items.length > 0) { + last.items[last.items.length - 1] += ` ${line.trim()}`; + continue; + } + } + if (line.trim() === "") { + flushParagraph(); + continue; + } + paragraph.push(line.trim()); + } + flushParagraph(); + + return entries; +} + +export const CHANGELOG_ENTRIES = parseChangelogEntries(changelogSource); + +/** The newest bundled release, used when the live changelog is unavailable. */ +export const LATEST_CHANGELOG_ENTRY: ChangelogEntry | null = + CHANGELOG_ENTRIES[0] ?? null; + +/** Read the same current changelog source that getbb.app builds from. */ +export async function fetchLatestChangelogEntry( + fetchFn: typeof fetch, + signal?: AbortSignal, +): Promise { + const response = await fetchFn(LATEST_CHANGELOG_SOURCE_URL, { signal }); + if (!response.ok) { + throw new Error(`Changelog request failed (${response.status})`); + } + const [entry] = parseChangelogEntries(await response.text()); + if (entry === undefined) { + throw new Error("The changelog has no releases"); + } + return entry; +} diff --git a/apps/app/src/components/settings/settings-nav.test.tsx b/apps/app/src/components/settings/settings-nav.test.tsx index 8efae3036f..bc9708a208 100644 --- a/apps/app/src/components/settings/settings-nav.test.tsx +++ b/apps/app/src/components/settings/settings-nav.test.tsx @@ -35,16 +35,13 @@ afterEach(() => { }); describe("useSettingsNavState", () => { - it("resolves Codex and Claude Code as separate provider pages", () => { + it("resolves the Providers bucket from its section route", () => { const { result } = renderHook(() => useSettingsNavState(), { - wrapper: wrapperFor("/settings/providers/claude-code"), + wrapper: wrapperFor("/settings/providers"), }); - expect(result.current.activeProviderId).toBe("claude-code"); - expect(result.current.activeSection).toBeNull(); - expect( - result.current.providerEntries.map((provider) => provider.id), - ).toEqual(["codex", "claude-code"]); + expect(result.current.activeSection).toBe("providers"); + expect(result.current.hasUnknownSection).toBe(false); }); it("shows the Machines section", () => { diff --git a/apps/app/src/components/settings/settings-nav.tsx b/apps/app/src/components/settings/settings-nav.tsx index 70962b461c..baa4079cb8 100644 --- a/apps/app/src/components/settings/settings-nav.tsx +++ b/apps/app/src/components/settings/settings-nav.tsx @@ -6,7 +6,6 @@ import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; import { SETTINGS_MACHINE_ROUTE_PATH, SETTINGS_PLUGIN_ROUTE_PATH, - SETTINGS_PROVIDER_ROUTE_PATH, SETTINGS_SECTION_ROUTE_PATH, } from "@/lib/route-paths"; @@ -17,6 +16,7 @@ import { */ export const SETTINGS_NAV_SECTIONS = [ { icon: "Settings", id: "general", label: "General" }, + { icon: "Zap", id: "providers", label: "Providers" }, { icon: "Palette", id: "appearance", label: "Appearance" }, { icon: "SlidersHorizontal", id: "keyboard", label: "Keyboard" }, { icon: "ChartColumn", id: "usage", label: "Usage limits" }, @@ -33,31 +33,16 @@ export const SETTINGS_NAV_SECTIONS = [ label: string; }[]; -export type SettingsNavSection = (typeof SETTINGS_NAV_SECTIONS)[number]; +type SettingsNavSection = (typeof SETTINGS_NAV_SECTIONS)[number]; export type SettingsSectionId = SettingsNavSection["id"]; -export const SETTINGS_PROVIDER_ENTRIES = [ - { id: "codex", label: "Codex" }, - { id: "claude-code", label: "Claude Code" }, -] as const; -export type SettingsProviderId = - (typeof SETTINGS_PROVIDER_ENTRIES)[number]["id"]; - -function isSettingsProviderId(value: string): value is SettingsProviderId { - return SETTINGS_PROVIDER_ENTRIES.some((provider) => provider.id === value); -} - -export function isSettingsSectionId(value: string): value is SettingsSectionId { +function isSettingsSectionId(value: string): value is SettingsSectionId { return SETTINGS_NAV_SECTIONS.some((section) => section.id === value); } export interface SettingsNavState { - /** Host id from /settings/machines/:hostId, else null. */ - activeMachineId: string | null; - /** Provider id from /settings/providers/:providerId, else null. */ - activeProviderId: SettingsProviderId | null; - /** Selected bucket; null while a provider page is active. */ + /** Selected bucket; null while a plugin page is active. */ activeSection: SettingsSectionId | null; /** True when the :section URL segment is unknown (the view redirects). */ hasUnknownSection: boolean; @@ -65,7 +50,6 @@ export interface SettingsNavState { activePluginId: string | null; /** Enabled plugins with configuration, for the sidebar's Plugins group. */ pluginEntries: readonly { id: string; label: string; icon: string | null }[]; - providerEntries: typeof SETTINGS_PROVIDER_ENTRIES; /** Buckets visible on this host. */ sections: readonly SettingsNavSection[]; } @@ -82,10 +66,6 @@ export function useSettingsNavState(): SettingsNavState { const { fileOpeners, settingsSections } = usePluginSlots(); const pluginListQuery = usePluginList({ enabled: true }); - const providerMatch = matchPath( - SETTINGS_PROVIDER_ROUTE_PATH, - location.pathname, - ); const sectionMatch = matchPath( SETTINGS_SECTION_ROUTE_PATH, location.pathname, @@ -98,20 +78,13 @@ export function useSettingsNavState(): SettingsNavState { location.pathname, ); const activeMachineId = machineMatch?.params.hostId ?? null; - const providerParam = providerMatch?.params.providerId; - const activeProviderId = - providerParam !== undefined && isSettingsProviderId(providerParam) - ? providerParam - : null; - const sectionParam = - providerMatch === null ? sectionMatch?.params.section : undefined; + const sectionParam = sectionMatch?.params.section; const hasUnknownSection = - (sectionParam !== undefined && !isSettingsSectionId(sectionParam)) || - (providerParam !== undefined && !isSettingsProviderId(providerParam)); + sectionParam !== undefined && !isSettingsSectionId(sectionParam); const activeSection: SettingsSectionId | null = activeMachineId !== null ? "machines" - : providerMatch !== null || activePluginId !== null + : activePluginId !== null ? null : sectionParam !== undefined && isSettingsSectionId(sectionParam) ? sectionParam @@ -142,13 +115,10 @@ export function useSettingsNavState(): SettingsNavState { .sort((left, right) => left.label.localeCompare(right.label)); return { - activeMachineId, activePluginId, - activeProviderId, activeSection, hasUnknownSection, pluginEntries, - providerEntries: SETTINGS_PROVIDER_ENTRIES, sections, }; } diff --git a/apps/app/src/components/showcase-hero/ShowcaseArchetypeCards.tsx b/apps/app/src/components/showcase-hero/ShowcaseArchetypeCards.tsx index b26bf4466a..74f0948b96 100644 --- a/apps/app/src/components/showcase-hero/ShowcaseArchetypeCards.tsx +++ b/apps/app/src/components/showcase-hero/ShowcaseArchetypeCards.tsx @@ -1,76 +1,9 @@ import { Icon } from "@bb/shared-ui/icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { cn } from "@bb/shared-ui/lib/utils"; -import { - showcaseArchetypePrompt, - type ShowcaseArchetype, -} from "./showcase-archetype"; +import type { ShowcaseArchetype } from "./showcase-archetype"; import { accentInk, accentTint, neutral } from "./showcase-tokens"; -/** - * The archetype grid under a showcase hero: the same ideas the carousel cycles - * through, held still so a visitor can read all of them at once and pick one. - * - * Each card is a single button that opens the composer already carrying that - * archetype's brief, through the same navigation the surface's own "New …" - * menu uses — the card is a shortcut into creation, not a detail page. - */ -export function ShowcaseArchetypeCards({ - archetypes, - promptPrefix, - onCreate, - heading, - className, -}: { - archetypes: readonly ShowcaseArchetype[]; - /** The sentence prefix each brief completes. */ - promptPrefix: string; - /** Receives the full composer prompt for the chosen archetype. */ - onCreate: (prompt: string) => void; - heading?: string; - className?: string; -}) { - return ( -
- {heading !== undefined ? ( -

- {heading} -

- ) : null} -
- {archetypes.map((archetype) => ( - - ))} -
-
- ); -} - -function ArchetypeCard({ - archetype, - promptPrefix, - onCreate, -}: { - archetype: ShowcaseArchetype; - promptPrefix: string; - onCreate: (prompt: string) => void; -}) { - return ( - onCreate(showcaseArchetypePrompt(promptPrefix, archetype))} - /> - ); -} - /** * The one example-card shape every tier uses, so grids of different sources * still read as one system. Without an accent token the icon chip goes diff --git a/apps/app/src/components/showcase-hero/ShowcaseHeroCarousel.tsx b/apps/app/src/components/showcase-hero/ShowcaseHeroCarousel.tsx index 041fe848f9..b4c7e47c8e 100644 --- a/apps/app/src/components/showcase-hero/ShowcaseHeroCarousel.tsx +++ b/apps/app/src/components/showcase-hero/ShowcaseHeroCarousel.tsx @@ -7,6 +7,7 @@ import { type CSSProperties, } from "react"; import { useNavigate } from "react-router-dom"; +import { usePrefersReducedMotion } from "@bb/shared-ui/hooks/use-media-query"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import { PluginNewThreadComposer } from "@/components/plugin/PluginNewThreadComposer"; @@ -16,7 +17,6 @@ import { getThreadRoutePath } from "@/lib/route-paths"; import { ShowcaseFrame } from "./ShowcaseFrame"; import type { ShowcaseArchetype, ShowcaseScenes } from "./showcase-archetype"; import { accentInk, accentTint, neutral } from "./showcase-tokens"; -import { useReducedMotion } from "./use-reduced-motion"; const SLIDE_MS = 5000; @@ -64,7 +64,7 @@ export interface ShowcaseHeroComposerConfig { draftKey: string; } -export interface ShowcaseHeroCarouselProps { +interface ShowcaseHeroCarouselProps { archetypes: readonly ShowcaseArchetype[]; scenes: ShowcaseScenes; copy: ShowcaseHeroCopy; @@ -115,7 +115,7 @@ export function ShowcaseHeroCarousel({ openRequest = null, onComposingChange, }: ShowcaseHeroCarouselProps) { - const reducedMotion = useReducedMotion(); + const reducedMotion = usePrefersReducedMotion(); const navigate = useNavigate(); const createThread = useCreateThread(); // The composer restores its saved draft on mount and only falls back to @@ -198,7 +198,7 @@ export function ShowcaseHeroCarousel({ // setState — during render. A request carrying a seed is an explicit // choice (a card, a menu example), so it replaces the stored draft; a // seedless request behaves like the blank CTA and restores it. - // eslint-disable-next-line react-hooks/set-state-in-effect + // oxlint-disable-next-line react/set-state-in-effect setSeedAndNotify( openRequest.close === true ? null diff --git a/apps/app/src/components/showcase-hero/showcase-archetype.ts b/apps/app/src/components/showcase-hero/showcase-archetype.ts index 80eccceacb..26ccc08317 100644 --- a/apps/app/src/components/showcase-hero/showcase-archetype.ts +++ b/apps/app/src/components/showcase-hero/showcase-archetype.ts @@ -30,20 +30,7 @@ export interface ShowcaseArchetype { } /** A mini-window interior. Scenes are components, never image assets. */ -export type ShowcaseScene = (props: { accentToken: string }) => ReactElement; +type ShowcaseScene = (props: { accentToken: string }) => ReactElement; /** Scene renderers keyed by archetype id. */ export type ShowcaseScenes = Record; - -/** Stable, readable ids derived from the title. */ -export function showcaseArchetypeId(title: string): string { - return title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); -} - -/** The full composer prompt for an archetype on a given surface. */ -export function showcaseArchetypePrompt( - promptPrefix: string, - archetype: ShowcaseArchetype, -): string { - return `${promptPrefix}${archetype.brief}.`; -} diff --git a/apps/app/src/components/showcase-hero/use-reduced-motion.ts b/apps/app/src/components/showcase-hero/use-reduced-motion.ts deleted file mode 100644 index 81d3492bf0..0000000000 --- a/apps/app/src/components/showcase-hero/use-reduced-motion.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useState } from "react"; - -const QUERY = "(prefers-reduced-motion: reduce)"; - -/** - * Tracks the reduced-motion preference so the hero can disable autoplay and - * transitions in JS, not just in CSS: a carousel that keeps advancing on a - * timer is still motion even when each transition is instant. - */ -export function useReducedMotion(): boolean { - const [reduced, setReduced] = useState(() => { - if (typeof window === "undefined" || !window.matchMedia) return false; - return window.matchMedia(QUERY).matches; - }); - - useEffect(() => { - if (typeof window === "undefined" || !window.matchMedia) return; - const media = window.matchMedia(QUERY); - const update = () => setReduced(media.matches); - update(); - media.addEventListener("change", update); - return () => media.removeEventListener("change", update); - }, []); - - return reduced; -} diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx index 824464dab8..48daad1f2d 100644 --- a/apps/app/src/components/sidebar/AppSidebar.tsx +++ b/apps/app/src/components/sidebar/AppSidebar.tsx @@ -33,7 +33,6 @@ import { shouldUseMacosDesktopChrome, } from "@/lib/bb-desktop"; import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; -import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled"; import { usePaneContentSplitDrag } from "./usePaneContentSplitDrag"; import { openUrlInExternalBrowser } from "@/lib/url-open-routing"; import type { SidebarThreadSearchNavigationItem } from "./sidebarThreadSearch"; @@ -93,10 +92,9 @@ export function AppSidebar({ const threadListReplacement = useThreadListReplacement(); const { threadId: activeThreadId } = useRouteState(); const navigate = useNavigate(); - const threadSplitsEnabled = useThreadSplitsEnabled(); const newThreadSplit = usePaneContentSplitDrag({ content: NEW_THREAD_PANE_CONTENT, - enabled: threadSplitsEnabled, + enabled: true, label: "New thread", }); const closeOnMobile = useCloseMobileSidebar(); @@ -336,7 +334,7 @@ export function AppSidebar({ className="shrink-0 px-2 py-2 group-data-[collapsible=icon]:hidden" > diff --git a/apps/app/src/components/sidebar/BuiltInSidebarSection.test.tsx b/apps/app/src/components/sidebar/BuiltInSidebarSection.test.tsx index 30f45fa3d6..eadeec7216 100644 --- a/apps/app/src/components/sidebar/BuiltInSidebarSection.test.tsx +++ b/apps/app/src/components/sidebar/BuiltInSidebarSection.test.tsx @@ -6,7 +6,7 @@ import { renderBuiltInSidebarSection, type BuiltInSidebarSectionOptionsById, } from "./BuiltInSidebarSection"; -import { NO_COLLAPSED_CHILD_ACTIVITY } from "@/lib/thread-activity"; +import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core"; const SECTIONS: BuiltInSidebarSectionOptionsById = { pinned: { diff --git a/apps/app/src/components/sidebar/BuiltInSidebarSection.tsx b/apps/app/src/components/sidebar/BuiltInSidebarSection.tsx index 44e28c7243..efe071d37e 100644 --- a/apps/app/src/components/sidebar/BuiltInSidebarSection.tsx +++ b/apps/app/src/components/sidebar/BuiltInSidebarSection.tsx @@ -9,7 +9,7 @@ import { type TopLevelSidebarSectionProps, } from "./TopLevelSidebarSection"; import { useSidebarSortable } from "./sortableMotion"; -import type { CollapsedChildActivity } from "@/lib/thread-activity"; +import type { CollapsedChildActivity } from "@bb/client-core"; import type { ThreadSplitIndicatorTarget } from "./paneContentSplitIndicator"; interface SortableSidebarSectionProps extends TopLevelSidebarSectionProps { diff --git a/apps/app/src/components/sidebar/PinnedThreadTree.tsx b/apps/app/src/components/sidebar/PinnedThreadTree.tsx index 243d9d7214..7f6812d74e 100644 --- a/apps/app/src/components/sidebar/PinnedThreadTree.tsx +++ b/apps/app/src/components/sidebar/PinnedThreadTree.tsx @@ -4,14 +4,14 @@ import { SortableContext, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import type { NeighborReorderRequest } from "@/lib/neighbor-reorder"; +import type { NeighborReorderRequest } from "@bb/client-core"; import { DropPreviewRow, ThreadTreeNodeRow } from "./ProjectRow"; import { useSidebarSortable, type SidebarSortableDragBindings, } from "./sortableMotion"; import { useSidebarReorderDnd } from "./useSidebarReorderDnd"; -import type { ProjectThreadNode } from "./projectThreadGroups"; +import type { ProjectThreadNode } from "@bb/client-core"; import { useNeighborReorderSortable, type UseNeighborReorderSortableArgs, diff --git a/apps/app/src/components/sidebar/PluginThreadList.tsx b/apps/app/src/components/sidebar/PluginThreadList.tsx index b32d274acc..4c8a09baf9 100644 --- a/apps/app/src/components/sidebar/PluginThreadList.tsx +++ b/apps/app/src/components/sidebar/PluginThreadList.tsx @@ -7,7 +7,7 @@ import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; import type { PluginThreadListSlot } from "@/lib/plugin-slots"; /** Shared by the mount and the host's crash check. */ -export const THREAD_LIST_SLOT_KIND = "threadList"; +const THREAD_LIST_SLOT_KIND = "threadList"; interface PluginThreadListProps { replacement: ResolvedReplacement; diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index ee10b78e5e..e034107b7d 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -18,7 +18,7 @@ import { import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadListEntry } from "@bb/domain"; import { ActiveSidebarModeSections, MachineModeSections } from "./ProjectList"; -import { buildMachineThreadGroups } from "./machineThreadGroups"; +import { buildMachineThreadGroups } from "@bb/client-core"; import { collapsedSidebarSectionIdsAtom, sidebarCollapsedMachinesAtom, @@ -39,8 +39,8 @@ vi.mock("@/hooks/queries/host-queries", () => ({ usePrimaryHost: vi.fn(() => undefined), })); -vi.mock("./machineThreadGroups", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("@bb/client-core", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, buildMachineThreadGroups: vi.fn(actual.buildMachineThreadGroups), diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index deee8d8ddf..6049a3d64f 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -40,7 +40,7 @@ import { import { useHosts, usePrimaryHost } from "@/hooks/queries/host-queries"; import { useDialogState } from "@/hooks/useDialogState"; import { usePromptDraftInputThreadIds } from "@/hooks/usePromptDraftStorage"; -import { getCollapsedChildActivity } from "@/lib/thread-activity"; +import { getCollapsedChildActivity } from "@bb/client-core"; import { getRootComposeRoutePath } from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; @@ -58,7 +58,7 @@ import { ConfirmDeleteDialog, ConfirmDeleteDialogContent, } from "@/components/dialogs/ConfirmDeleteDialog"; -import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; import { Skeleton } from "@bb/shared-ui/skeleton"; @@ -80,15 +80,21 @@ import { import { SidebarThreadSearchPanel } from "./SidebarThreadSearchPanel"; import type { ProjectThreadListState } from "./ProjectRow"; import { + buildMachineThreadGroups, + buildPinnedSidebarState, + CHRONOLOGICAL_CONTAINER_ID, compareByCreatedAtDescending, compareStandardThreads, createSidebarProjectIdResolver, isSidebarProjectThread, + NO_MACHINE_GROUP_KEY, resolveSidebarProjectId, + sectionKeyForThreadSection, + buildSidebarEntitySectionId, type ProjectThreadItem, type SidebarSectionDefinition, type ThreadComparator, -} from "./projectThreadGroups"; +} from "@bb/client-core"; import { SortableProjectRow, type ProjectListRowModel, @@ -98,7 +104,6 @@ import { type PinnedThreadTreeProps, } from "./PinnedThreadTree"; import { useThreadTitleMentionResources } from "@/components/thread/ThreadTitleMentions"; -import { buildPinnedSidebarState } from "./pinnedSidebarThreads"; import { collapsedEnvironmentIdsAtom, collapsedThreadIdsAtom, @@ -113,12 +118,6 @@ import { type SidebarOrganizationMode, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; -import { sectionKeyForThreadSection } from "./sectionKeys"; -import { - buildMachineThreadGroups, - NO_MACHINE_GROUP_KEY, -} from "./machineThreadGroups"; -import { CHRONOLOGICAL_CONTAINER_ID } from "./projectThreadGroups"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -144,7 +143,6 @@ import { import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider"; import { usePaneContentSplitIndicator } from "./paneContentSplitIndicator"; import { SplitPaneMiniMap } from "./SplitPaneMiniMap"; -import { buildSidebarEntitySectionId } from "./sidebarSectionOrder"; import { renderBuiltInSidebarSection, SortableSidebarSection, @@ -153,6 +151,7 @@ import { } from "./BuiltInSidebarSection"; import { ReorderableSidebarSectionOrderList } from "./ReorderableSidebarSectionOrderList"; import { useSidebarModeSectionOrder } from "./useSidebarModeSectionOrder"; +import { haveSameOrder } from "./usePersistedSidebarSectionOrder"; import { resolveThreadTitleDisplayText, type ThreadTitleMentionResources, @@ -285,16 +284,6 @@ type OpenSidebarMenu = | `displayOptions:${string}` | null; -function hasSameStringList( - left: readonly string[], - right: readonly string[], -): boolean { - if (left.length !== right.length) { - return false; - } - return left.every((sectionId, index) => sectionId === right[index]); -} - function removeCollapsedIds( current: T[], idsToRemove: ReadonlySet, @@ -1754,7 +1743,7 @@ function ProjectListComponent({ ); useEffect(() => { if ( - hasSameStringList( + haveSameOrder( collapsedSidebarSectionIdList, normalizedCollapsedSidebarSectionIds, ) diff --git a/apps/app/src/components/sidebar/ProjectListProjects.tsx b/apps/app/src/components/sidebar/ProjectListProjects.tsx index 70b5da8f58..bc493c8028 100644 --- a/apps/app/src/components/sidebar/ProjectListProjects.tsx +++ b/apps/app/src/components/sidebar/ProjectListProjects.tsx @@ -14,7 +14,7 @@ import { } from "@/components/ui/sidebar.js"; import { ProjectRow } from "./ProjectRow"; import type { ProjectRowProps, ProjectThreadListState } from "./ProjectRow"; -import type { ThreadComparator } from "./projectThreadGroups"; +import type { ThreadComparator } from "@bb/client-core"; import { useSidebarSortable } from "./sortableMotion"; import type { SidebarReorderDndContextProps } from "./useSidebarReorderDnd"; import type { ConsumeDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; @@ -36,7 +36,7 @@ export interface ProjectListRowModel { * such as stories. The live sidebar places the same sortable rows in its one * heterogeneous top-level context instead. */ -export interface ProjectListReorderBindings { +interface ProjectListReorderBindings { dndContextProps: SidebarReorderDndContextProps; itemIds: string[]; disabled: boolean; @@ -59,7 +59,7 @@ interface ProjectListProjectsProps { reorder?: ProjectListReorderBindings; } -export interface SortableProjectRowProps extends ProjectRowProps { +interface SortableProjectRowProps extends ProjectRowProps { reorderDisabled: boolean; sortableId?: string; } diff --git a/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx b/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx index 687c22efc4..47f72f38a3 100644 --- a/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx +++ b/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx @@ -4,7 +4,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { createStore, Provider } from "jotai"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { NO_COLLAPSED_CHILD_ACTIVITY } from "@/lib/thread-activity"; +import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { SPLIT_LAYOUT_STORAGE_KEY } from "@/lib/split-layout/persistence"; import { diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index 34defc2fb2..09e37a21c0 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -19,7 +19,7 @@ import { ProjectRow, type ProjectThreadListState, } from "./ProjectRow"; -import { buildSidebarEntitySectionId } from "./sidebarSectionOrder"; +import { buildSidebarEntitySectionId } from "@bb/client-core"; const mockUpdateEnvironment = vi.hoisted(() => ({ mutate: vi.fn(), @@ -33,10 +33,6 @@ vi.mock("@/hooks/useLocalPathPicker", () => ({ usePathPickerHost: () => ({ hostId: null, hostName: null }), })); -vi.mock("@/hooks/useThreadSplitsEnabled", () => ({ - useThreadSplitsEnabled: () => false, -})); - vi.mock("@/hooks/mutations/environment-mutations", () => ({ useArchiveEnvironmentThreads: () => ({ isPending: false, diff --git a/apps/app/src/components/sidebar/ProjectRow.stories.tsx b/apps/app/src/components/sidebar/ProjectRow.stories.tsx index 7d7a444b2a..2b6599d8fb 100644 --- a/apps/app/src/components/sidebar/ProjectRow.stories.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.stories.tsx @@ -17,7 +17,7 @@ import { ProjectListProjects, type ProjectListRowModel, } from "./ProjectListProjects"; -import { compareStandardThreads } from "./projectThreadGroups"; +import { compareStandardThreads } from "@bb/client-core"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; export default { diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 21f5fcc5c0..c59648f214 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -66,7 +66,7 @@ import { getCollapsedChildActivity, NO_COLLAPSED_CHILD_ACTIVITY, type CollapsedChildActivity, -} from "@/lib/thread-activity"; +} from "@bb/client-core"; import { cn } from "@bb/shared-ui/lib/utils"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; import { getProjectSettingsRoutePath } from "@/lib/route-paths"; @@ -79,6 +79,7 @@ import { type ThreadRowOptions, } from "./ThreadRow"; import { + buildSidebarEntitySectionId, buildSectionThreadList, buildProjectThreadGroups, CHRONOLOGICAL_CONTAINER_ID, @@ -95,7 +96,7 @@ import { type SidebarSectionDefinition, type SidebarSectionGroup, type ThreadComparator, -} from "./projectThreadGroups"; +} from "@bb/client-core"; import { SidebarWindowedItems } from "./SidebarWindowedItems"; import { SidebarSectionRow } from "./SidebarSectionRow"; import { TopLevelSidebarSection } from "./TopLevelSidebarSection"; @@ -117,9 +118,8 @@ import { type SidebarSortableDragBindings, } from "./sortableMotion"; import type { ConsumeDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; -import type { NeighborReorderRequest } from "@/lib/neighbor-reorder"; +import type { NeighborReorderRequest } from "@bb/client-core"; import { SidebarChildToggleChevron } from "./SidebarChildToggleChevron"; -import { buildSidebarEntitySectionId } from "./sidebarSectionOrder"; import { SidebarSectionOrderList } from "./SidebarSectionOrderList"; import { collectSectionThreadDndLookup, @@ -399,18 +399,18 @@ interface EnvironmentThreadGroupHeaderProps { parentLineDepth?: number; childActivity: CollapsedChildActivity; isCollapsed: boolean; - archiveThreadsPending?: boolean; - onArchiveThreads?: () => void; - onCreateNewThread?: () => void; - onRenameEnvironment?: () => void; + archiveThreadsPending: boolean; + onArchiveThreads: () => void; + onCreateNewThread: () => void; + onRenameEnvironment: () => void; onToggleCollapsed: (environmentId: string) => void; } interface EnvironmentThreadGroupHeaderActionsProps { archiveThreadsPending: boolean; - onArchiveThreads?: () => void; - onCreateNewThread?: () => void; - onRenameEnvironment?: () => void; + onArchiveThreads: () => void; + onCreateNewThread: () => void; + onRenameEnvironment: () => void; onOpenChange: (open: boolean) => void; } @@ -858,10 +858,6 @@ function EnvironmentThreadGroupHeaderActions({ onRenameEnvironment, onOpenChange, }: EnvironmentThreadGroupHeaderActionsProps) { - if (!onCreateNewThread && !onArchiveThreads && !onRenameEnvironment) { - return null; - } - return ( @@ -884,37 +880,31 @@ function EnvironmentThreadGroupHeaderActions({ - {onCreateNewThread ? ( - - - ) : null} - {onRenameEnvironment ? ( - { - onRenameEnvironment(); - }} - > - - ) : null} - {onArchiveThreads ? ( - { - if (archiveThreadsPending) { - event.preventDefault(); - return; - } - onArchiveThreads(); - }} - > - - ) : null} + + + { + onRenameEnvironment(); + }} + > + + { + if (archiveThreadsPending) { + event.preventDefault(); + return; + } + onArchiveThreads(); + }} + > + @@ -929,7 +919,7 @@ function EnvironmentThreadGroupHeader({ parentLineDepth, childActivity, isCollapsed, - archiveThreadsPending = false, + archiveThreadsPending, onArchiveThreads, onCreateNewThread, onRenameEnvironment, diff --git a/apps/app/src/components/sidebar/ReorderableSidebarSectionOrderList.tsx b/apps/app/src/components/sidebar/ReorderableSidebarSectionOrderList.tsx index 9fde3279d8..572bc688b1 100644 --- a/apps/app/src/components/sidebar/ReorderableSidebarSectionOrderList.tsx +++ b/apps/app/src/components/sidebar/ReorderableSidebarSectionOrderList.tsx @@ -3,7 +3,7 @@ import type { DragEndEvent } from "@dnd-kit/core"; import type { ConsumeDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; import type { SidebarSectionId } from "./sidebarCollapsedAtoms"; import { SidebarSectionOrderList } from "./SidebarSectionOrderList"; -import { reorderSidebarSectionOrder } from "./sidebarSectionOrder"; +import { reorderSidebarSectionOrder } from "@bb/client-core"; import { useSidebarReorderDnd } from "./useSidebarReorderDnd"; interface ReorderableSidebarSectionOrderListProps { diff --git a/apps/app/src/components/sidebar/SectionGrouping.stories.tsx b/apps/app/src/components/sidebar/SectionGrouping.stories.tsx index 6b1f3d04e0..ca52661394 100644 --- a/apps/app/src/components/sidebar/SectionGrouping.stories.tsx +++ b/apps/app/src/components/sidebar/SectionGrouping.stories.tsx @@ -13,10 +13,10 @@ import { type ProjectThreadListState, } from "./ProjectRow"; import { + buildSidebarEntitySectionId, compareStandardThreads, type SidebarSectionDefinition, -} from "./projectThreadGroups"; -import { buildSidebarEntitySectionId } from "./sidebarSectionOrder"; +} from "@bb/client-core"; export default { title: "sidebar/Section grouping", diff --git a/apps/app/src/components/sidebar/SectionSidebar.tsx b/apps/app/src/components/sidebar/SectionSidebar.tsx index 06906ac2ac..86f5b96b75 100644 --- a/apps/app/src/components/sidebar/SectionSidebar.tsx +++ b/apps/app/src/components/sidebar/SectionSidebar.tsx @@ -16,7 +16,7 @@ import { import { SidebarHistoryNavigationControls } from "@/components/sidebar/SidebarHistoryNavigationControls"; import { PROJECT_LIST_ACTION_BUTTON_CLASS } from "@/components/sidebar/ProjectList"; import { SIDEBAR_STANDARD_ROW_PADDING_CLASS } from "@/components/sidebar/sidebarRowClasses"; -import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { CHROME_ROW_CLASS, getBbDesktopInfo, diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.stories.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.stories.tsx index 7c1855977d..0b831ae599 100644 --- a/apps/app/src/components/sidebar/SidebarSectionRow.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarSectionRow.stories.tsx @@ -4,7 +4,7 @@ import { SidebarStickyStack } from "@/components/ui/sidebar.js"; import { NO_COLLAPSED_CHILD_ACTIVITY, type CollapsedChildActivity, -} from "@/lib/thread-activity"; +} from "@bb/client-core"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { DropPreviewRow } from "./ProjectRow"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx index dc120f826a..b7431e7040 100644 --- a/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx +++ b/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx @@ -3,7 +3,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { NO_COLLAPSED_CHILD_ACTIVITY } from "@/lib/thread-activity"; +import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { SPLIT_LAYOUT_STORAGE_KEY } from "@/lib/split-layout/persistence"; import { SidebarSectionRow } from "./SidebarSectionRow"; diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.tsx index 1f39481c2b..844fd52121 100644 --- a/apps/app/src/components/sidebar/SidebarSectionRow.tsx +++ b/apps/app/src/components/sidebar/SidebarSectionRow.tsx @@ -3,6 +3,7 @@ import { useCallback, useState, type CSSProperties, + type MouseEvent, type MouseEventHandler, } from "react"; import { Button } from "@bb/shared-ui/button"; @@ -29,7 +30,7 @@ import { SIDEBAR_HOVER_ACTIONS_ROW_CLASS, } from "@/components/ui/sidebar-hover-actions.js"; import { cn } from "@bb/shared-ui/lib/utils"; -import type { CollapsedChildActivity } from "@/lib/thread-activity"; +import type { CollapsedChildActivity } from "@bb/client-core"; import { SIDEBAR_MORE_ACTION_TRIGGER_CLASS, SIDEBAR_ROW_BASE_CLASS, @@ -40,7 +41,6 @@ import { SidebarChildToggleChevron } from "./SidebarChildToggleChevron"; import { CollapsedThreadStatusGlyph } from "./ThreadRow"; import type { SidebarSortableDragBindings } from "./sortableMotion"; import type { ConsumeDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; -import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled"; import { useThreadGroupSplitIndicator, type ThreadSplitIndicatorTarget, @@ -49,6 +49,10 @@ import { SplitPaneMiniMap } from "./SplitPaneMiniMap"; const EMPTY_SPLIT_INDICATOR_THREADS: readonly ThreadSplitIndicatorTarget[] = []; +function stopActionsClick(event: MouseEvent) { + event.stopPropagation(); +} + interface SidebarSectionRowProps { // Leaf segment shown on the header ("Q3"). name: string; @@ -89,10 +93,9 @@ function SidebarSectionRowComponent({ stickyLevel, }: SidebarSectionRowProps) { const [isActionsOpen, setIsActionsOpen] = useState(false); - const threadSplitsEnabled = useThreadSplitsEnabled(); const collapsedSplitIndicator = useThreadGroupSplitIndicator( collapsedThreads, - threadSplitsEnabled && isCollapsed, + isCollapsed, ); const hasMenuActions = Boolean(onRename || onRemove); const hasActions = Boolean(onCreateThread || hasMenuActions); @@ -142,12 +145,6 @@ function SidebarSectionRowComponent({ }, [consumeClickSuppression], ); - const stopActionsClick = useCallback>( - (event) => { - event.stopPropagation(); - }, - [], - ); const content = ( <> {/* Full-bleed toggle target for pointer users; the chevron owns keyboard diff --git a/apps/app/src/components/sidebar/SidebarStatusNotifications.stories.tsx b/apps/app/src/components/sidebar/SidebarStatusNotifications.stories.tsx index 588ac48819..56ae89f5ee 100644 --- a/apps/app/src/components/sidebar/SidebarStatusNotifications.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarStatusNotifications.stories.tsx @@ -18,7 +18,7 @@ import { type ProjectListRowModel, } from "./ProjectListProjects"; import type { ProjectThreadListState } from "./ProjectRow"; -import { compareStandardThreads } from "./projectThreadGroups"; +import { compareStandardThreads } from "@bb/client-core"; import { ThreadRow, type ThreadRowOptions } from "./ThreadRow"; export default { diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx index 3714c20d19..c4f2a36c9c 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from "react"; import type { ThreadListEntry } from "@bb/domain"; import type { ThreadSearchMatch } from "@bb/server-contract"; -import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { COARSE_POINTER_TEXT_SM_CLASS, COARSE_POINTER_ICON_SIZE_CLASS, diff --git a/apps/app/src/components/sidebar/SidebarThreadTitleMentions.tsx b/apps/app/src/components/sidebar/SidebarThreadTitleMentions.tsx deleted file mode 100644 index 6bcdfd8979..0000000000 --- a/apps/app/src/components/sidebar/SidebarThreadTitleMentions.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export { - ThreadTitleMentions as SidebarThreadTitle, - ThreadTitleMentionResourcesProvider as SidebarThreadTitleMentionResourcesProvider, - type ThreadTitleMentionResourcesProviderProps as SidebarThreadTitleMentionResourcesProviderProps, -} from "@/components/thread/ThreadTitleMentions"; diff --git a/apps/app/src/components/sidebar/SidebarUpdatesBadge.test.tsx b/apps/app/src/components/sidebar/SidebarUpdatesBadge.test.tsx index 776b06ec36..3df9af2b80 100644 --- a/apps/app/src/components/sidebar/SidebarUpdatesBadge.test.tsx +++ b/apps/app/src/components/sidebar/SidebarUpdatesBadge.test.tsx @@ -102,6 +102,7 @@ function machine( isPrimary: true, providerStatus: null, statusPending: false, + statusFetching: false, statusError: false, issues: [], canRetryDaemonUpdate: false, @@ -153,7 +154,7 @@ describe("SidebarUpdatesBadge", () => { it("shows only the provider chip when bb itself is current", () => { renderBadge({ machines: [ - machine({ issues: [providerIssue("claudeCode", "Claude Code")] }), + machine({ issues: [providerIssue("claude-code", "Claude Code")] }), ], }); @@ -169,7 +170,7 @@ describe("SidebarUpdatesBadge", () => { renderBadge({ machines: [ machine({ - issues: [missingInstallIssue("claudeCode", "Claude Code")], + issues: [missingInstallIssue("claude-code", "Claude Code")], }), ], }); @@ -198,12 +199,12 @@ describe("SidebarUpdatesBadge", () => { machines: [ machine({ host: host("host-1"), - issues: [providerIssue("claudeCode", "Claude Code")], + issues: [providerIssue("claude-code", "Claude Code")], }), machine({ host: host("host-2"), issues: [ - providerIssue("claudeCode", "Claude Code"), + providerIssue("claude-code", "Claude Code"), providerIssue("codex", "Codex"), ], }), @@ -211,9 +212,10 @@ describe("SidebarUpdatesBadge", () => { }); const providerChip = screen.getByTestId("sidebar-updates-badge-providers"); - // Codex leads regardless of which machine surfaced the issue first. + // The first host-reported provider order is retained while duplicates + // from later machines collapse into one mark. expect(providerChip.getAttribute("aria-label")).toBe( - "Codex and Claude Code updates available", + "Claude Code and Codex updates available", ); expect(providerChip.querySelectorAll("svg[viewBox]").length).toBe(3); expect(screen.getByTestId("sidebar-updates-badge-bb")).toBeTruthy(); diff --git a/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx b/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx index 56f97a42bd..ad162646b2 100644 --- a/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx +++ b/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx @@ -11,27 +11,10 @@ import { } from "@/lib/provider-icon"; import { getSettingsRoutePath } from "@/lib/route-paths"; -export interface SidebarUpdatesBadgeProps { +interface SidebarUpdatesBadgeProps { onNavigate?: () => void; } -/** - * Provider CLI keys are their own namespace (`claudeCode`), distinct from the - * agent provider ids the icon registry is keyed by (`claude-code`). - */ -const PROVIDER_CLI_AGENT_PROVIDER_ID = { - codex: "codex", - claudeCode: "claude-code", - cursor: "acp-cursor", -} as const satisfies Record; - -/** Stable left-to-right order so the marks never reshuffle between polls. */ -const PROVIDER_CLI_DISPLAY_ORDER = [ - "codex", - "claudeCode", - "cursor", -] as const satisfies readonly ProviderCliKey[]; - const CHIP_CLASS = cn( "flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-sidebar-border px-2", "text-xs font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent", @@ -90,10 +73,7 @@ export function SidebarUpdatesBadge({ onNavigate }: SidebarUpdatesBadgeProps) { } } } - const staleProviders = PROVIDER_CLI_DISPLAY_ORDER.flatMap((provider) => { - const stale = staleProvidersByKey.get(provider); - return stale === undefined ? [] : [stale]; - }); + const staleProviders = [...staleProvidersByKey.values()]; if (bbUpdateCount === 0 && staleProviders.length === 0) { return null; @@ -141,8 +121,7 @@ export function SidebarUpdatesBadge({ onNavigate }: SidebarUpdatesBadgeProps) { {staleProviders.map((stale) => { - const providerId = - PROVIDER_CLI_AGENT_PROVIDER_ID[stale.provider]; + const providerId = stale.provider; const iconInfo = getProviderIconInfo(providerId); if (iconInfo === undefined) { return null; diff --git a/apps/app/src/components/sidebar/SidebarWindowedItems.tsx b/apps/app/src/components/sidebar/SidebarWindowedItems.tsx index 0c9da85697..767d36db55 100644 --- a/apps/app/src/components/sidebar/SidebarWindowedItems.tsx +++ b/apps/app/src/components/sidebar/SidebarWindowedItems.tsx @@ -210,7 +210,7 @@ export function SidebarWindowedItems({ } // The pass re-runs only when the key list (or windowing mode) changes; // scroll-driven changes are the observer's job. - // eslint-disable-next-line react-hooks/exhaustive-deps + // oxlint-disable-next-line react/exhaustive-deps }, [windowingEnabled, keySignature]); useEffect(() => { diff --git a/apps/app/src/components/sidebar/ThreadRow.stories.tsx b/apps/app/src/components/sidebar/ThreadRow.stories.tsx index 5a336fb1a5..f5538f88c0 100644 --- a/apps/app/src/components/sidebar/ThreadRow.stories.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.stories.tsx @@ -10,11 +10,11 @@ import { makeThreadListEntry } from "../../../.ladle/story-fixtures"; import { SidebarMenu, SidebarMenuItem } from "@/components/ui/sidebar.js"; import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider"; import { ThreadRow, type ThreadRowOptions } from "./ThreadRow"; -import { SidebarThreadTitleMentionResourcesProvider } from "./SidebarThreadTitleMentions"; +import { ThreadTitleMentionResourcesProvider } from "@/components/thread/ThreadTitleMentions"; import { NO_COLLAPSED_CHILD_ACTIVITY, type CollapsedChildActivity, -} from "@/lib/thread-activity"; +} from "@bb/client-core"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; @@ -666,7 +666,7 @@ export function Overview() { label="parent with a child from another project" hint="a child that lives in a different project than its parent shows the folder-export marker after its title; hover it for the project name" > - - + ({ }), })); import { TooltipProvider } from "@bb/shared-ui/tooltip"; -import { SidebarThreadTitleMentionResourcesProvider } from "./SidebarThreadTitleMentions"; +import { ThreadTitleMentionResourcesProvider } from "@/components/thread/ThreadTitleMentions"; import { SIDEBAR_SUCCESS_STATUS_COLOR_CLASS, SIDEBAR_WORKING_STATUS_COLOR_CLASS, @@ -44,11 +44,7 @@ import { } from "@/lib/plugin-thread-row-status"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { SPLIT_LAYOUT_STORAGE_KEY } from "@/lib/split-layout/persistence"; -import { NO_COLLAPSED_CHILD_ACTIVITY } from "@/lib/thread-activity"; - -vi.mock("@/hooks/useThreadSplitsEnabled", () => ({ - useThreadSplitsEnabled: () => true, -})); +import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core"; vi.mock("@/components/thread/ThreadActionsMenu", () => ({ ThreadActionsContextMenu: ({ children }: { children: ReactNode }) => ( @@ -110,18 +106,14 @@ const DEFAULT_OPTIONS: ThreadRowOptions = { }; function ThreadRowTestHarness({ - accessibleTitle, crossProjectId = null, - displayTitle, hasComposerDraft = false, isActive = false, options = DEFAULT_OPTIONS, shortcutKey, thread, }: { - accessibleTitle?: string; crossProjectId?: string | null; - displayTitle?: string; hasComposerDraft?: boolean; isActive?: boolean; options?: ThreadRowOptions; @@ -148,8 +140,6 @@ function ThreadRowTestHarness({ isActive={isActive} hasComposerDraft={hasComposerDraft} options={options} - displayTitle={displayTitle} - accessibleTitle={accessibleTitle} /> @@ -628,7 +618,7 @@ describe("ThreadRow", () => { }); render( - { "Compare @thread:thr_mentioned in @project:proj_mentioned, @section:sec_mentioned, legacy @folder:sec_legacy, and @apps/app/src/ThreadRow.tsx", })} /> - , + , ); expect(screen.getByText("Mention target").closest("a")).toBeNull(); @@ -665,7 +655,7 @@ describe("ThreadRow", () => { it("marks a child from another project with the project name", () => { const { container } = render( - { projectId: "proj_other", })} /> - , + , ); const marker = container.querySelector( @@ -722,63 +712,6 @@ describe("ThreadRow", () => { ).toBeNull(); }); - it("keeps an explicit accessible title while resolving its mentions", () => { - const mentionedThread = createThread({ - id: "thr_visible", - title: "Visible target", - titleFallback: "Visible target", - }); - const onToggleCollapsed = vi.fn(); - - render( - - - , - ); - - expect(screen.getByText("Visible target")).not.toBeNull(); - expect( - screen.getByRole("link", { - name: "Open Full path in Accessible section", - }), - ).not.toBeNull(); - expect(screen.getByTitle("Full path in Accessible section")).not.toBeNull(); - expect( - screen.getByRole("button", { - name: "Collapse Full path in Accessible section threads", - }), - ).not.toBeNull(); - }); - it("renders a complete Unicode path mention instead of an ASCII prefix", () => { const { container } = renderThreadRow({ thread: createThread({ diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 40c753c1b0..11f207869c 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -50,7 +50,7 @@ import { resolveThreadListIndicator, type CollapsedChildActivity, type ThreadListIndicatorState, -} from "@/lib/thread-activity"; +} from "@bb/client-core"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { getThreadRoutePath } from "@/lib/route-paths"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -73,13 +73,12 @@ import type { ConsumeDragClickSuppression } from "@/components/ui/use-drag-click import type { SidebarSortableDragBindings } from "./sortableMotion"; import { SidebarChildToggleChevron } from "./SidebarChildToggleChevron"; import { useSidebarThreadShortcut } from "./sidebarThreadShortcuts"; -import { SidebarThreadTitle } from "./SidebarThreadTitleMentions"; import { SplitPaneMiniMap } from "./SplitPaneMiniMap"; import { usePaneContentSplitIndicator } from "./paneContentSplitIndicator"; -import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled"; import { useThreadRowSplitDrag } from "./useThreadRowSplitDrag"; import { AppCommandShortcutPill } from "@/components/commands/AppCommandShortcutHint"; import { + ThreadTitleMentions, useSidebarProjectName, useThreadTitleDisplayText, } from "@/components/thread/ThreadTitleMentions"; @@ -137,10 +136,6 @@ interface ThreadRowProps { hasComposerDraft: boolean; onProjectSelect?: () => void; options: ThreadRowOptions; - // Visible row text override. Defaults to the thread title. - displayTitle?: string; - // Accessible name + hover tooltip override. Defaults to the thread title. - accessibleTitle?: string; } type ThreadRowClickCaptureHandler = MouseEventHandler; @@ -509,8 +504,6 @@ function ThreadRowComponent({ hasComposerDraft, onProjectSelect, options, - displayTitle, - accessibleTitle, }: ThreadRowProps) { const [isDropdownActionsOpen, setIsDropdownActionsOpen] = useState(false); const [isContextActionsOpen, setIsContextActionsOpen] = useState(false); @@ -533,9 +526,7 @@ function ThreadRowComponent({ const threadUnreadError = threadUnreadDone && thread.status === "error"; const threadUnreadSuccess = threadUnreadDone && !threadUnreadError; const threadTitle = getThreadDisplayTitle(thread); - // Inside a section the row shows the leaf but keeps the full path for a11y. - const visibleTitle = displayTitle ?? threadTitle; - const labelTitle = useThreadTitleDisplayText(accessibleTitle ?? threadTitle); + const labelTitle = useThreadTitleDisplayText(threadTitle); const crossProjectName = useSidebarProjectName(crossProjectId); const crossProjectLabel = crossProjectId === null @@ -562,10 +553,9 @@ function ThreadRowComponent({ }, [startEditing], ); - const threadSplitsEnabled = useThreadSplitsEnabled(); const splitIndicator = usePaneContentSplitIndicator( { kind: "thread", projectId, threadId: thread.id }, - threadSplitsEnabled, + true, ); const { onPointerDown: onSplitDragPointerDown, openInSplit } = useThreadRowSplitDrag({ @@ -738,7 +728,7 @@ function ThreadRowComponent({ title={labelTitle} onDoubleClick={startTitleEditing} > - + )} {crossProjectLabel !== null ? ( diff --git a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx index dd5889099d..9e43a0a5d5 100644 --- a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx +++ b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx @@ -21,7 +21,7 @@ import { isUnreadDoneThread, resolveThreadListIndicator, type ThreadListIndicatorState, -} from "@/lib/thread-activity"; +} from "@bb/client-core"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { cn } from "@bb/shared-ui/lib/utils"; import { ThreadStatusGlyph } from "./ThreadRow"; diff --git a/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx b/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx index b01ec1452b..55d6f94ed4 100644 --- a/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx +++ b/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx @@ -2,6 +2,7 @@ import { useCallback, type CSSProperties, type KeyboardEventHandler, + type MouseEvent, type MouseEventHandler, type PointerEventHandler, type ReactNode, @@ -9,7 +10,7 @@ import { import { cn } from "@bb/shared-ui/lib/utils"; import { Icon } from "@bb/shared-ui/icon"; import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; -import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { SidebarStickyGroup, SidebarStickyTier, @@ -24,9 +25,8 @@ import { import type { ConsumeDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; import { SIDEBAR_STANDARD_ROW_PADDING_CLASS } from "./sidebarRowClasses"; import type { SidebarSortableDragBindings } from "./sortableMotion"; -import type { CollapsedChildActivity } from "@/lib/thread-activity"; +import type { CollapsedChildActivity } from "@bb/client-core"; import { CollapsedThreadStatusGlyph } from "./ThreadRow"; -import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled"; import { useThreadGroupSplitIndicator, type ThreadSplitIndicatorTarget, @@ -36,7 +36,11 @@ import { COARSE_POINTER_ROW_ACTION_SIZE_CLASS } from "@bb/shared-ui/coarse-point const EMPTY_SPLIT_INDICATOR_THREADS: readonly ThreadSplitIndicatorTarget[] = []; -export interface TopLevelSidebarSectionCollapseControl { +function stopActionsClick(event: MouseEvent) { + event.stopPropagation(); +} + +interface TopLevelSidebarSectionCollapseControl { isCollapsed: boolean; onToggleCollapsed: () => void; } @@ -81,10 +85,9 @@ export function TopLevelSidebarSection({ consumeClickSuppression, isDropTargetActive = false, }: TopLevelSidebarSectionProps) { - const threadSplitsEnabled = useThreadSplitsEnabled(); const collapsedSplitIndicator = useThreadGroupSplitIndicator( collapsedThreads, - threadSplitsEnabled && collapseControl?.isCollapsed === true, + collapseControl?.isCollapsed === true, ); const handleClickCapture = useCallback>( (event) => { @@ -106,12 +109,6 @@ export function TopLevelSidebarSection({ }, [collapseControl], ); - const stopActionsClick = useCallback>( - (event) => { - event.stopPropagation(); - }, - [], - ); const stopCollapseControlPointerDown = useCallback< PointerEventHandler >((event) => { diff --git a/apps/app/src/components/sidebar/paneContentSplitIndicator.ts b/apps/app/src/components/sidebar/paneContentSplitIndicator.ts index c1c082aa1f..15841cdada 100644 --- a/apps/app/src/components/sidebar/paneContentSplitIndicator.ts +++ b/apps/app/src/components/sidebar/paneContentSplitIndicator.ts @@ -21,7 +21,7 @@ export interface MiniMapSlot { isFocused: boolean; } -export interface PaneContentSplitIndicator { +interface PaneContentSplitIndicator { /** This content is open in a pane while the layout is split (>1 pane). */ isOpenInSplit: boolean; /** Mini-map slots for the sidebar glyph, or null when there is nothing to show. */ diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 1bf7cc38e9..2899784eaf 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -1,4 +1,5 @@ import { atomWithStorage } from "jotai/utils"; +import type { CollapsibleSidebarSectionId } from "@bb/client-core"; import { createJsonLocalStorage, type SyncStorage, @@ -23,13 +24,10 @@ const COLLAPSED_THREAD_SECTIONS_STORAGE_KEY = const LEGACY_COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; const COLLAPSED_MACHINES_STORAGE_KEY = "bb.sidebar.collapsedMachines"; -export type SidebarSectionId = - | "pinned" - | "threads" - | `project:${string}` - | `section:${string}` - | `machine:${string}`; -export type CollapsibleSidebarSectionId = "pinned" | "threads"; +export type { + CollapsibleSidebarSectionId, + SidebarSectionId, +} from "@bb/client-core"; // "project" keeps the per-project grouping; "chronological" is the persisted // value for the cross-project Sections view that replaced the old None view; @@ -40,7 +38,7 @@ export type SidebarOrganizationMode = "project" | "chronological" | "machine"; // that the runtime normalizes back to "updated". export type SidebarChronologicalSort = "updated" | "created" | "alpha" | "none"; -export const DEFAULT_SIDEBAR_SECTION_ORDER: readonly string[] = [ +const DEFAULT_SIDEBAR_SECTION_ORDER: readonly string[] = [ "pinned", "projects", "threads", diff --git a/apps/app/src/components/sidebar/sidebarRowClasses.ts b/apps/app/src/components/sidebar/sidebarRowClasses.ts index 64ddb25450..026c4cbe22 100644 --- a/apps/app/src/components/sidebar/sidebarRowClasses.ts +++ b/apps/app/src/components/sidebar/sidebarRowClasses.ts @@ -1,8 +1,6 @@ import { COARSE_POINTER_DOT_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { CONTEXT_SELECTION_SURFACE_CLASS } from "@/components/ui/context-selection"; -export type SidebarUnreadDotTone = "default" | "error"; - export const SIDEBAR_ROW_BASE_CLASS = "flex w-full items-center gap-2 rounded-md pr-0 text-sm transition-colors"; @@ -20,13 +18,7 @@ export const SIDEBAR_ROW_GLYPH_SLOT_CLASS = * Inner styling only — call sites own wrapper, positioning, fade, and the * aria-label. */ -const SIDEBAR_UNREAD_DOT_CLASS_BY_TONE: Record = { - default: `rounded-full bg-foreground ${COARSE_POINTER_DOT_SIZE_CLASS}`, - error: `rounded-full bg-destructive ${COARSE_POINTER_DOT_SIZE_CLASS}`, -}; - -export const SIDEBAR_UNREAD_DOT_CLASS = - SIDEBAR_UNREAD_DOT_CLASS_BY_TONE.default; +export const SIDEBAR_UNREAD_DOT_CLASS = `rounded-full bg-foreground ${COARSE_POINTER_DOT_SIZE_CLASS}`; export const SIDEBAR_WORKING_STATUS_COLOR_CLASS = "text-muted-foreground/50"; diff --git a/apps/app/src/components/sidebar/sidebarThreadShortcuts.ts b/apps/app/src/components/sidebar/sidebarThreadShortcuts.ts index ee43097085..2eea7091c6 100644 --- a/apps/app/src/components/sidebar/sidebarThreadShortcuts.ts +++ b/apps/app/src/components/sidebar/sidebarThreadShortcuts.ts @@ -22,7 +22,7 @@ export function encodeSidebarWindowedNavigationEntries( .join(" "); } -export const MAX_SIDEBAR_THREAD_SHORTCUTS = 9; +const MAX_SIDEBAR_THREAD_SHORTCUTS = 9; export interface SidebarThreadShortcutTarget { /** Null for a thread inside a windowed-out placeholder: there is no row diff --git a/apps/app/src/components/sidebar/sortComparator.test.ts b/apps/app/src/components/sidebar/sortComparator.test.ts index 4ca8192760..01ef7d774f 100644 --- a/apps/app/src/components/sidebar/sortComparator.test.ts +++ b/apps/app/src/components/sidebar/sortComparator.test.ts @@ -9,8 +9,8 @@ import { type ProjectThreadNode, type ProjectThreadItem, type ThreadComparator, -} from "./projectThreadGroups"; -import { NO_COLLAPSED_CHILD_ACTIVITY } from "@/lib/thread-activity"; +} from "@bb/client-core"; +import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core"; import type { ThreadTitleMentionResources } from "@/components/thread/ThreadTitleMentions"; function thread(overrides: Partial): ThreadListEntry { diff --git a/apps/app/src/components/sidebar/threadListProvider.test.ts b/apps/app/src/components/sidebar/threadListProvider.test.ts deleted file mode 100644 index 6c27b909e7..0000000000 --- a/apps/app/src/components/sidebar/threadListProvider.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { PluginThreadListSlot } from "@/lib/plugin-slots"; -import { - AUTOMATIC_THREAD_LIST_PROVIDER, - BUILT_IN_THREAD_LIST_PROVIDER, - resolveThreadListProvider, - threadListProviderKey, -} from "./threadListProvider"; - -function slot(pluginId: string, id: string): PluginThreadListSlot { - return { - pluginId, - id, - generation: 1, - title: `${pluginId} list`, - component: () => null, - }; -} - -describe("resolveThreadListProvider", () => { - it("uses the built-in list when no replacement is registered", () => { - expect(resolveThreadListProvider([])).toBeNull(); - }); - - it("activates the first registered replacement", () => { - const first = slot("alpha", "inbox"); - expect( - resolveThreadListProvider( - [first, slot("beta", "inbox")], - AUTOMATIC_THREAD_LIST_PROVIDER, - ), - ).toBe(first); - }); - - it("lets the user keep BB's list", () => { - expect( - resolveThreadListProvider( - [slot("alpha", "inbox")], - BUILT_IN_THREAD_LIST_PROVIDER, - ), - ).toBeNull(); - }); - - it("lets the user pin a specific provider", () => { - const first = slot("alpha", "inbox"); - const second = slot("beta", "inbox"); - expect( - resolveThreadListProvider([first, second], threadListProviderKey(second)), - ).toBe(second); - }); - - it("uses BB while an explicitly selected provider is unavailable", () => { - expect(resolveThreadListProvider([], "alpha/inbox")).toBeNull(); - }); - - it("reveals the next replacement when the first is removed", () => { - const first = slot("alpha", "inbox"); - const second = slot("beta", "inbox"); - expect( - resolveThreadListProvider( - [first, second], - AUTOMATIC_THREAD_LIST_PROVIDER, - ), - ).toBe(first); - expect( - resolveThreadListProvider([second], AUTOMATIC_THREAD_LIST_PROVIDER), - ).toBe(second); - }); -}); diff --git a/apps/app/src/components/sidebar/threadListProvider.ts b/apps/app/src/components/sidebar/threadListProvider.ts index 013c971eb6..046a251046 100644 --- a/apps/app/src/components/sidebar/threadListProvider.ts +++ b/apps/app/src/components/sidebar/threadListProvider.ts @@ -1,64 +1,24 @@ -import { atomWithStorage } from "jotai/utils"; import { useAtomValue } from "jotai"; -import { createJsonLocalStorage } from "@/lib/browser-storage"; -import { resolveThreadListReplacement } from "@/lib/plugin-slot-resolvers"; +import { + createReplacementPreferenceAtom, + resolvePreferredReplacement, +} from "@/lib/plugin-replacement-preference"; import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; import { usePluginSlots, type PluginThreadListSlot } from "@/lib/plugin-slots"; const THREAD_LIST_PROVIDER_STORAGE_KEY = "bb.sidebar.threadListProvider"; -/** Follow deterministic slot order and activate the first provider. */ -export const AUTOMATIC_THREAD_LIST_PROVIDER = "__automatic__"; - -/** Always use BB's own thread list. */ -export const BUILT_IN_THREAD_LIST_PROVIDER = "__builtin__"; - /** * Automatic by default, with an explicit per-client override available in * Appearance. Existing stored built-in and plugin selections remain valid. */ -export const threadListProviderAtom = atomWithStorage( +export const threadListProviderAtom = createReplacementPreferenceAtom( THREAD_LIST_PROVIDER_STORAGE_KEY, - AUTOMATIC_THREAD_LIST_PROVIDER, - createJsonLocalStorage(), - { getOnInit: true }, ); -export function threadListProviderKey( - slot: Pick, -): string { - return `${slot.pluginId}/${slot.id}`; -} - -/** - * Resolve automatic, BB-owned, and explicit-provider modes. An unavailable - * explicit provider falls back to BB without erasing the stored selection, so - * a temporarily disabled plugin gets its list back when it returns. - */ -export function resolveThreadListProvider( - slots: readonly PluginThreadListSlot[], - preference: string = AUTOMATIC_THREAD_LIST_PROVIDER, -): PluginThreadListSlot | null { - const resolved = resolveThreadListProviderReplacement(slots, preference); - return resolved.kind === "plugin" ? resolved.registration : null; -} - -function resolveThreadListProviderReplacement( - slots: readonly PluginThreadListSlot[], - preference: string, -): ResolvedReplacement { - if (preference === BUILT_IN_THREAD_LIST_PROVIDER) return { kind: "owner" }; - return resolveThreadListReplacement( - slots, - preference === AUTOMATIC_THREAD_LIST_PROVIDER - ? undefined - : (candidate) => threadListProviderKey(candidate) === preference, - ); -} - /** The active replacement, or the owner when none is registered. */ export function useThreadListReplacement(): ResolvedReplacement { const { threadLists } = usePluginSlots(); const preference = useAtomValue(threadListProviderAtom); - return resolveThreadListProviderReplacement(threadLists, preference); + return resolvePreferredReplacement(threadLists, preference); } diff --git a/apps/app/src/components/sidebar/useNeighborReorderSortable.ts b/apps/app/src/components/sidebar/useNeighborReorderSortable.ts index 9ee7a7df66..b6653a3ab0 100644 --- a/apps/app/src/components/sidebar/useNeighborReorderSortable.ts +++ b/apps/app/src/components/sidebar/useNeighborReorderSortable.ts @@ -5,7 +5,7 @@ import { applyNeighborReorder, buildNeighborReorderRequest, type NeighborReorderRequest, -} from "@/lib/neighbor-reorder"; +} from "@bb/client-core"; interface NeighborReorderSortableCallbacks { onSettled: () => void; diff --git a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts index 9d78e939a0..8ab975247d 100644 --- a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts +++ b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts @@ -82,7 +82,7 @@ export function usePaneContentSplitDrag({ const startY = event.clientY; const startLayout = store.get(splitLayoutAtom); const fallback = singlePaneFallback(startLayout); - beginSplitDrag(startX, startY, { + beginSplitDrag({ ghostLabel: label, sourceEl: rowEl, cancelSidebarReorderOnEngage: true, diff --git a/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts b/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts index ab5bb0e9fd..734faae9bd 100644 --- a/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts +++ b/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts @@ -3,7 +3,7 @@ import type { SidebarSectionId } from "./sidebarCollapsedAtoms"; import { normalizeSidebarSectionOrder, type LegacySidebarEntityAnchor, -} from "./sidebarSectionOrder"; +} from "@bb/client-core"; interface UsePersistedSidebarSectionOrderArgs { entitySectionIds: readonly SidebarSectionId[]; @@ -15,7 +15,10 @@ interface UsePersistedSidebarSectionOrderArgs { storedOrder: readonly string[]; } -function haveSameOrder(left: readonly string[], right: readonly string[]) { +export function haveSameOrder( + left: readonly string[], + right: readonly string[], +) { return ( left.length === right.length && left.every((sectionId, index) => sectionId === right[index]) diff --git a/apps/app/src/components/sidebar/useSectionThreadDnd.projection.test.tsx b/apps/app/src/components/sidebar/useSectionThreadDnd.projection.test.tsx index a9fcb8e520..9d63097fac 100644 --- a/apps/app/src/components/sidebar/useSectionThreadDnd.projection.test.tsx +++ b/apps/app/src/components/sidebar/useSectionThreadDnd.projection.test.tsx @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildSectionThreadList, CHRONOLOGICAL_CONTAINER_ID, -} from "./projectThreadGroups"; +} from "@bb/client-core"; import { collectSectionThreadDndLookup, SectionThreadProjectionGate, diff --git a/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts b/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts index 1561f2bfe8..2c61826056 100644 --- a/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts +++ b/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildSectionThreadList, CHRONOLOGICAL_CONTAINER_ID, -} from "./projectThreadGroups"; +} from "@bb/client-core"; import { collectSectionThreadDndLookup, PINNED_THREAD_PARENT_KEY, diff --git a/apps/app/src/components/sidebar/useSectionThreadDnd.ts b/apps/app/src/components/sidebar/useSectionThreadDnd.ts index ba1b2b2733..c8484719b7 100644 --- a/apps/app/src/components/sidebar/useSectionThreadDnd.ts +++ b/apps/app/src/components/sidebar/useSectionThreadDnd.ts @@ -20,19 +20,17 @@ import { useUnpinThread, useUpdateThread, } from "@/hooks/mutations/thread-state-mutations"; -import type { NeighborReorderRequest } from "@/lib/neighbor-reorder"; +import type { NeighborReorderRequest } from "@bb/client-core"; import { + buildSidebarEntitySectionId, getSidebarDndItemId, + reorderSidebarSectionOrder, type ProjectThreadItem, -} from "./projectThreadGroups"; +} from "@bb/client-core"; import { sidebarCollapsedThreadSectionsAtom, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; -import { - buildSidebarEntitySectionId, - reorderSidebarSectionOrder, -} from "./sidebarSectionOrder"; import { sidebarReorderCollisionDetection, useSidebarReorderDnd, @@ -70,7 +68,7 @@ interface UseSectionThreadDndArgs { ) => void; } -export interface SectionThreadDndLookup { +interface SectionThreadDndLookup { sectionParentKeyBySectionId: Map; sectionSectionIdByParentKey: Map; sectionIdByParentKey: Map; @@ -80,13 +78,13 @@ export interface SectionThreadDndLookup { threadByItemId: Map; } -export interface SectionThreadDropTarget { +interface SectionThreadDropTarget { activeId: string; fromParentKey: string; toParentKey: string; } -export type SectionThreadDropDecision = +type SectionThreadDropDecision = | { kind: "move"; activeId: string; sectionId: string | null } | { kind: "pin"; activeId: string } | { diff --git a/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts b/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts index d542d99c14..c9db6a639d 100644 --- a/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts +++ b/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts @@ -7,7 +7,7 @@ import { type SidebarOrganizationMode, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; -import type { LegacySidebarEntityAnchor } from "./sidebarSectionOrder"; +import type { LegacySidebarEntityAnchor } from "@bb/client-core"; import { usePersistedSidebarSectionOrder } from "./usePersistedSidebarSectionOrder"; const MODE_SECTION_ORDER_CONFIG: Record< diff --git a/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx b/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx index 6467484df0..936f690f68 100644 --- a/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx +++ b/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx @@ -10,10 +10,9 @@ import type { LayoutNode, PaneContent, SplitLayout } from "@/lib/split-layout"; import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; import { useThreadRowSplitDrag } from "./useThreadRowSplitDrag"; -const { navigateSpy, compactState, experimentState } = vi.hoisted(() => ({ +const { navigateSpy, compactState } = vi.hoisted(() => ({ navigateSpy: vi.fn(), compactState: { value: false }, - experimentState: { enabled: true }, })); vi.mock("react-router-dom", async (importOriginal) => ({ @@ -25,10 +24,6 @@ vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ useIsCompactViewport: () => compactState.value, })); -vi.mock("@/hooks/useThreadSplitsEnabled", () => ({ - useThreadSplitsEnabled: () => experimentState.enabled, -})); - function content(threadId: string): PaneContent { return { kind: "thread", projectId: "p1", threadId }; } @@ -90,7 +85,6 @@ describe("useThreadRowSplitDrag — openInSplit (cmd-click / context-menu entry) beforeEach(() => { navigateSpy.mockClear(); compactState.value = false; - experimentState.enabled = true; }); it("splits the focused pane to the right by default", () => { @@ -145,18 +139,4 @@ describe("useThreadRowSplitDrag — openInSplit (cmd-click / context-menu entry) expect(store.get(splitLayoutAtom)).toBeNull(); expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t9"); }); - - it("disables drag and plain-navigates without touching the layout when the experiment is off", () => { - experimentState.enabled = false; - const seeded = twoPanes(); - const { store, getOnPointerDown, openInSplit } = renderOpenInSplit( - "t9", - seeded, - ); - - expect(getOnPointerDown()).toBeUndefined(); - openInSplit(); - expect(store.get(splitLayoutAtom)).toBe(seeded); - expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t9"); - }); }); diff --git a/apps/app/src/components/sidebar/useThreadRowSplitDrag.ts b/apps/app/src/components/sidebar/useThreadRowSplitDrag.ts index 39914b979f..a987785427 100644 --- a/apps/app/src/components/sidebar/useThreadRowSplitDrag.ts +++ b/apps/app/src/components/sidebar/useThreadRowSplitDrag.ts @@ -1,7 +1,6 @@ import { useCallback, type PointerEvent as ReactPointerEvent } from "react"; import { useStore } from "jotai"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; -import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled"; import { useRouteNavigate } from "@/components/ui/app-route-anchor"; import { getThreadRoutePath } from "@/lib/route-paths"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; @@ -62,11 +61,10 @@ export function useThreadRowSplitDrag({ const store = useStore(); const navigate = useRouteNavigate(); const isCompact = useIsCompactViewport(); - const threadSplitsEnabled = useThreadSplitsEnabled(); const onPointerDown = useCallback( (event: ReactPointerEvent) => { - if (!threadSplitsEnabled || event.button !== 0) { + if (event.button !== 0) { return; } const rowEl = event.currentTarget; @@ -79,7 +77,7 @@ export function useThreadRowSplitDrag({ const startLayout = store.get(splitLayoutAtom); const fallback = singlePaneFallback(startLayout); - beginSplitDrag(startX, startY, { + beginSplitDrag({ ghostLabel: title, sourceEl: rowEl, cancelSidebarReorderOnEngage: true, @@ -129,7 +127,7 @@ export function useThreadRowSplitDrag({ }, }); }, - [navigate, projectId, store, threadId, threadSplitsEnabled, title], + [navigate, projectId, store, threadId, title], ); const openInSplit = useCallback(() => { @@ -139,13 +137,11 @@ export function useThreadRowSplitDrag({ projectId, threadId, isCompact, - threadSplitsEnabled, }); - }, [isCompact, navigate, projectId, store, threadId, threadSplitsEnabled]); + }, [isCompact, navigate, projectId, store, threadId]); return { - onPointerDown: - threadSplitsEnabled && !isCompact ? onPointerDown : undefined, + onPointerDown: !isCompact ? onPointerDown : undefined, openInSplit, }; } diff --git a/apps/app/src/components/thread/InlineThreadTitle.tsx b/apps/app/src/components/thread/InlineThreadTitle.tsx index 4af9e88eaf..922beb48ed 100644 --- a/apps/app/src/components/thread/InlineThreadTitle.tsx +++ b/apps/app/src/components/thread/InlineThreadTitle.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { cn } from "@bb/shared-ui/lib/utils"; -export interface InlineThreadTitleCommitResult { +interface InlineThreadTitleCommitResult { kind: "cancel" | "commit"; title?: string; } @@ -26,16 +26,14 @@ export function resolveInlineThreadTitleCommit(args: { interface InlineThreadTitleEditorProps { ariaLabel: string; - className?: string; value: string; onCancel: () => void; onChange: (value: string) => void; onSubmit: () => void; } -export function InlineThreadTitleEditor({ +function InlineThreadTitleEditor({ ariaLabel, - className, value, onCancel, onChange, @@ -90,7 +88,6 @@ export function InlineThreadTitleEditor({ // border, so the chrome does not change the line box and can follow // the rounded corners. "relative z-10 box-border min-w-0 w-[calc(100%+0.5rem)] -mx-1 appearance-none rounded-sm border-0 bg-transparent px-1 py-0 text-sm font-normal leading-[inherit] outline-none ring-1 ring-ring", - className, )} spellCheck={false} value={value} @@ -117,7 +114,6 @@ export function InlineThreadTitleEditor({ } interface UseInlineThreadTitleArgs { - inputClassName?: string; onCommit: (title: string) => void; /** Cancel an open edit when this identity changes (usually the thread id). */ resetKey: string; @@ -131,7 +127,6 @@ interface UseInlineThreadTitleResult { } export function useInlineThreadTitle({ - inputClassName, onCommit, resetKey, title, @@ -183,7 +178,6 @@ export function useInlineThreadTitle({ editor: isEditing ? ( void; triggerClassName?: string; - align?: "start" | "center" | "end"; /** * Contextual toolbar actions that move into this menu when a split header is * too narrow to show them inline. @@ -133,7 +126,6 @@ function ThreadActionMenuSeparator({ function ThreadActionsMenuItems({ thread, - canDelete = true, onOpenInSplit, responsiveActions = [], surface, @@ -234,20 +226,18 @@ function ThreadActionsMenuItems({ > {isArchived ? "Unarchive" : "Archive"} - {canDelete ? ( - { - window.setTimeout(() => { - requestDelete(thread); - }, 0); - }} - > - Delete - - ) : null} + { + window.setTimeout(() => { + requestDelete(thread); + }, 0); + }} + > + Delete + ); } @@ -299,12 +289,10 @@ export function ThreadArchiveQuickAction({ export function ThreadActionsMenu({ thread, - canDelete = true, onOpenInSplit, responsiveActions, onOpenChange, triggerClassName, - align = "end", }: ThreadActionsMenuProps) { return ( @@ -329,10 +317,9 @@ export function ThreadActionsMenu({ /> - + @@ -385,7 +370,6 @@ function ThreadActionsCompactLongPressMenu({ function ThreadActionsDesktopContextMenu({ children, thread, - canDelete = true, onOpenInSplit, onOpenChange, }: ThreadActionsContextMenuProps) { @@ -395,7 +379,6 @@ function ThreadActionsDesktopContextMenu({ diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index 8382c71a5c..b5dfdf289d 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -43,7 +43,7 @@ import { } from "@/components/dialogs/ThreadDeleteDialog"; import { ArchivedThreadToastTitle } from "@/components/thread/ArchivedThreadToastTitle"; import { destroyPersistedBrowserViewsForThread } from "@/components/secondary-panel/browserViewVisibilityCoordinator"; -import { getThreadReadToggleAction } from "@/components/sidebar/threadReadState"; +import { getThreadReadToggleAction } from "@bb/client-core"; import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; import { getDesktopBrowserApi } from "@/lib/bb-desktop"; import { useRouteNavigate } from "@/components/ui/app-route-anchor"; diff --git a/apps/app/src/components/thread/ThreadTitleMentions.tsx b/apps/app/src/components/thread/ThreadTitleMentions.tsx index ca0752935c..9911764ccb 100644 --- a/apps/app/src/components/thread/ThreadTitleMentions.tsx +++ b/apps/app/src/components/thread/ThreadTitleMentions.tsx @@ -26,7 +26,7 @@ import { sdk } from "@/lib/sdk"; import { getThreadDisplayTitle } from "@/lib/thread-title"; /** The slice of a thread a title mention needs: its label and route. */ -export type ThreadTitleMentionThread = Pick< +type ThreadTitleMentionThread = Pick< ThreadListEntry, "id" | "projectId" | "title" | "titleFallback" >; @@ -187,7 +187,7 @@ export function useSidebarThreadTitleMentionResources( navigation: ThreadTitleMentionNavigationSource | undefined; resources: ThreadTitleMentionResources; } | null>(null); - /* eslint-disable react-hooks/refs -- render-time cache, see above */ + /* oxlint-disable react/refs -- render-time cache, see above */ const cached = cacheRef.current; if (cached !== null && cached.navigation === navigation) { return cached.resources; @@ -197,7 +197,7 @@ export function useSidebarThreadTitleMentionResources( cached?.resources ?? EMPTY_TITLE_MENTION_RESOURCES, ); cacheRef.current = { navigation, resources }; - /* eslint-enable react-hooks/refs */ + /* oxlint-enable react/refs */ return resources; } @@ -318,18 +318,13 @@ function RawThreadMentionResolverProvider({ ); } -interface RawThreadMentionBatchContextValue { - register: (threadId: string) => void; - resourceById: ReadonlyMap; -} - -const EMPTY_RAW_THREAD_MENTION_BATCH: RawThreadMentionBatchContextValue = { +const EMPTY_RAW_THREAD_MENTION_BATCH: RawThreadMentionResolverContextValue = { register: () => {}, resourceById: new Map(), }; const RawThreadMentionBatchContext = - createContext( + createContext( EMPTY_RAW_THREAD_MENTION_BATCH, ); @@ -417,19 +412,19 @@ export function ThreadTitleMentionResourcesProvider({ ); } -function isMentionBoundary(text: string, index: number): boolean { +export function isMentionBoundary(text: string, index: number): boolean { const previous = text[index - 1]; return previous === undefined || !/[\p{L}\p{N}_.+-]/u.test(previous); } -function isRawThreadIdBoundary(text: string, index: number): boolean { +export function isRawThreadIdBoundary(text: string, index: number): boolean { const previous = text[index - 1]; return ( previous !== "/" && previous !== "\\" && isMentionBoundary(text, index) ); } -function isMentionEndBoundary(text: string, index: number): boolean { +export function isMentionEndBoundary(text: string, index: number): boolean { const next = text[index]; if (next === undefined) return true; if (next === ".") { @@ -439,7 +434,7 @@ function isMentionEndBoundary(text: string, index: number): boolean { return !/[\p{L}\p{N}_.+\/-]/u.test(next); } -function isRawThreadIdEndBoundary(text: string, index: number): boolean { +export function isRawThreadIdEndBoundary(text: string, index: number): boolean { return text[index] !== "\\" && isMentionEndBoundary(text, index); } diff --git a/apps/app/src/components/thread/ThreadUnarchiveButton.tsx b/apps/app/src/components/thread/ThreadUnarchiveButton.tsx index e7c48af522..d5963b4c59 100644 --- a/apps/app/src/components/thread/ThreadUnarchiveButton.tsx +++ b/apps/app/src/components/thread/ThreadUnarchiveButton.tsx @@ -1,34 +1,27 @@ import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; -import { cn } from "@bb/shared-ui/lib/utils"; export function ThreadUnarchiveButton({ isPending, onUnarchive, - buttonLabel, - className, variant = "icon", }: { isPending?: boolean; onUnarchive: () => void; - buttonLabel?: string; - className?: string; variant?: "icon" | "secondary"; }) { - const resolvedLabel = buttonLabel ?? "Unarchive thread"; const isSecondary = variant === "secondary"; return (
{inactiveContent.canStartReplacement ? ( + ) : null} +
+ )} + />, + { container: scrollElement }, + ), + measurements, + }; +} + +beforeEach(() => { + itemHeights = new Map(); + scrollElement = document.createElement("div"); + document.body.append(scrollElement); + Object.defineProperty(scrollElement, "clientWidth", { + configurable: true, + value: 320, + }); + Object.defineProperty(scrollElement, "offsetWidth", { + configurable: true, + value: 320, + }); + Object.defineProperty(scrollElement, "scrollHeight", { + configurable: true, + value: 3_200, + }); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this === scrollElement) return rect(0, scrollElement.clientHeight); + if (this.hasAttribute("data-timeline-virtual-spacer")) { + return rect( + -scrollElement.scrollTop, + Number.parseFloat(this.style.height) || 0, + ); + } + const index = Number(this.dataset.index); + if (Number.isInteger(index)) { + return rect( + index * 32 - scrollElement.scrollTop, + itemHeights.get(index) ?? 32, + ); + } + return rect(0, Number.parseFloat(this.style.height) || 0); + }, + ); + vi.stubGlobal("ResizeObserver", ResizeObserverStub); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("TimelineWindowedItems", () => { + it("seeds exact heights while the lazy windowing implementation loads", () => { + const measurements = new Map(); + + render( + 100} + gap={0} + getScrollElement={() => scrollElement} + itemKeys={ITEM_KEYS} + measurements={measurements} + renderItem={(index, state) => ( +
+ )} + />, + { container: scrollElement }, + ); + + expect(measurements.get("row-0")).toBe(32); + expect(measurements.get("row-99")).toBe(32); + }); + + it("keeps the control path fully mounted when the experiment is off", () => { + renderWindowedItems({ enabled: false }); + + expect(screen.getAllByTestId(/^content-/)).toHaveLength(100); + expect( + scrollElement.querySelector("[data-timeline-virtual-spacer]"), + ).toBeNull(); + }); + + it("mounts only the visible TanStack range and removes offscreen wrappers", async () => { + renderWindowedItems(); + + await waitFor(() => expect(screen.getByTestId("content-0")).toBeTruthy()); + expect(screen.getAllByTestId(/^wrapper-/).length).toBeLessThan(30); + expect(screen.queryByTestId("wrapper-60")).toBeNull(); + expect( + scrollElement.querySelector("[data-timeline-virtual-spacer]") + ?.style.height, + ).toBe("3200px"); + }); + + it("changes ranges on scroll without retaining the old rich rows", async () => { + renderWindowedItems(); + await waitFor(() => expect(screen.getByTestId("content-0")).toBeTruthy()); + + scrollElement.scrollTop = 1_600; + fireEvent.scroll(scrollElement); + + await waitFor(() => expect(screen.getByTestId("content-50")).toBeTruthy()); + expect(screen.queryByTestId("wrapper-0")).toBeNull(); + }); + + it("preserves an existing scroll offset when a nested virtualizer mounts", async () => { + scrollElement.scrollTop = 1_600; + + renderWindowedItems(); + + await waitFor(() => expect(screen.getByTestId("content-50")).toBeTruthy()); + expect(scrollElement.scrollTop).toBe(1_600); + }); + + it("keeps search and interacted rows mounted outside the visible range", async () => { + renderWindowedItems({ alwaysMountedKeys: new Set(["row-80"]) }); + await waitFor(() => expect(screen.getByTestId("content-80")).toBeTruthy()); + fireEvent.click(screen.getByTestId("content-0")); + + scrollElement.scrollTop = 1_600; + fireEvent.scroll(scrollElement); + + await waitFor(() => expect(screen.getByTestId("content-50")).toBeTruthy()); + expect(screen.getByTestId("content-0")).toBeTruthy(); + expect(screen.getByTestId("content-80")).toBeTruthy(); + }); + + it("defers rich transient rows during a fast traversal until scroll idle", async () => { + vi.useFakeTimers(); + renderWindowedItems(); + await act(async () => {}); + + scrollElement.scrollTop = 1_600; + fireEvent.scroll(scrollElement); + await act(async () => {}); + + expect( + scrollElement.querySelectorAll( + '[data-timeline-windowed-realized="false"]', + ).length, + ).toBeGreaterThan(0); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + expect(screen.getByTestId("content-50")).toBeTruthy(); + }); + + it("seeds its size model from measurements retained by the thread", async () => { + const measurements = new Map([["row-50", 64]]); + renderWindowedItems({ measurements }); + await waitFor(() => + expect( + scrollElement.querySelector( + "[data-timeline-virtual-spacer]", + )?.style.height, + ).toBe("3232px"), + ); + }); + + it("renders everything when its scrollport has no usable geometry", async () => { + renderWindowedItems({ clientHeight: 0 }); + + await waitFor(() => + expect(screen.getAllByTestId(/^content-/)).toHaveLength(100), + ); + }); +}); diff --git a/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx b/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx new file mode 100644 index 0000000000..e9caaf3d11 --- /dev/null +++ b/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx @@ -0,0 +1,346 @@ +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, + type SyntheticEvent, +} from "react"; +import { useComposedRefs } from "@radix-ui/react-compose-refs"; +import { + defaultRangeExtractor, + useVirtualizer, + type Range, + type Virtualizer, +} from "@tanstack/react-virtual"; +import type { TimelineWindowedItemsProps } from "./TimelineWindowedItemsLoader.js"; + +export type { TimelineWindowedItemRenderState } from "./TimelineWindowedItemsLoader.js"; + +/** Rich rows retained on each side of the visible range. */ +const TIMELINE_WINDOW_OVERSCAN_ITEMS = 8; +/** TanStack's scroll-idle boundary also drives rich-content realization. */ +const TIMELINE_WINDOW_IDLE_DELAY_MS = 300; +/** Bound row-local interaction state retained across a long-lived session. */ +const TIMELINE_WINDOW_MAX_INTERACTION_PINS = 24; +/** Bound exact heights that survive nested-list unmounts. */ +const TIMELINE_WINDOW_MAX_MEASUREMENTS = 2_000; +/** Short lists cost less to keep mounted than to virtualize. */ +const TIMELINE_WINDOWING_MIN_ITEM_COUNT = 20; + +const EMPTY_KEY_SET: ReadonlySet = new Set(); +const GET_NO_SCROLL_ELEMENT = () => null; +const NOOP_ITEM_REF = () => {}; + +function recordTimelineMeasurement( + measurements: Map, + key: string, + height: number, +): void { + measurements.delete(key); + measurements.set(key, height); + while (measurements.size > TIMELINE_WINDOW_MAX_MEASUREMENTS) { + const oldestKey = measurements.keys().next().value; + if (oldestKey === undefined) break; + measurements.delete(oldestKey); + } +} + +interface ScrollSample { + at: number; + fast: boolean; + offset: number; +} + +function measureBorderBox( + element: HTMLElement, + entry: ResizeObserverEntry | undefined, +): number { + const observedHeight = entry?.borderBoxSize[0]?.blockSize; + return observedHeight ?? element.getBoundingClientRect().height; +} + +function findOwnedWindowKey( + target: EventTarget | null, + container: HTMLElement, + indexByKey: ReadonlyMap, +): string | null { + let element = target instanceof Element ? target : null; + while (element !== null && element !== container) { + const key = element.getAttribute("data-timeline-window-key"); + if (key !== null && indexByKey.has(key)) return key; + element = element.parentElement; + } + return null; +} + +/** + * Timeline adapter around TanStack Virtual. + * + * TanStack owns range calculation, dynamic measurement, scroll correction, + * and iOS momentum safety. This adapter only retains product policy: stable + * row keys, nested scroll offsets, search/interaction pins, and cheap + * placeholders during a synthetic or high-velocity traversal. + */ +export function TimelineWindowedItems({ + enabled, + alwaysMountedKeys = EMPTY_KEY_SET, + estimateItemHeight, + gap, + getScrollElement, + itemKeys, + measurements, + minItemCount = TIMELINE_WINDOWING_MIN_ITEM_COUNT, + renderItem, +}: TimelineWindowedItemsProps) { + const configured = + enabled && itemKeys.length >= minItemCount && getScrollElement !== null; + const [scrollRootUsable, setScrollRootUsable] = useState(true); + const [scrollMargin, setScrollMargin] = useState(0); + const [interactionPins, setInteractionPins] = useState([]); + const containerElementRef = useRef(null); + const scrollSampleRef = useRef({ + at: 0, + fast: false, + offset: 0, + }); + const windowingEnabled = configured && scrollRootUsable; + const resolvedGetScrollElement = getScrollElement ?? GET_NO_SCROLL_ELEMENT; + + const indexByKey = useMemo( + () => new Map(itemKeys.map((key, index) => [key, index])), + [itemKeys], + ); + const forcedIndexes = useMemo(() => { + const indexes = new Set(); + for (const key of alwaysMountedKeys) { + const index = indexByKey.get(key); + if (index !== undefined) indexes.add(index); + } + for (const key of interactionPins) { + const index = indexByKey.get(key); + if (index !== undefined) indexes.add(index); + } + return indexes; + }, [alwaysMountedKeys, indexByKey, interactionPins]); + + const getItemKey = useCallback( + (index: number) => itemKeys[index] ?? index, + [itemKeys], + ); + const estimateSize = useCallback( + (index: number) => { + const key = itemKeys[index]; + return key === undefined + ? Math.max(1, estimateItemHeight(index)) + : (measurements.get(key) ?? Math.max(1, estimateItemHeight(index))); + }, + [estimateItemHeight, itemKeys, measurements], + ); + const measureElement = useCallback( + ( + element: HTMLDivElement, + entry: ResizeObserverEntry | undefined, + ): number => { + const index = Number(element.dataset.index); + const height = measureBorderBox(element, entry); + const key = Number.isInteger(index) ? itemKeys[index] : undefined; + if ( + key !== undefined && + height > 0 && + element.dataset.timelineWindowedRealized === "true" + ) { + recordTimelineMeasurement(measurements, key, height); + } + return height > 0 ? height : estimateSize(index); + }, + [estimateSize, itemKeys, measurements], + ); + const rangeExtractor = useCallback( + (range: Range) => { + const indexes = new Set(defaultRangeExtractor(range)); + for (const index of forcedIndexes) indexes.add(index); + return [...indexes].sort((left, right) => left - right); + }, + [forcedIndexes], + ); + const handleVirtualizerChange = useCallback( + ( + instance: Virtualizer, + scrolling: boolean, + ) => { + const sample = scrollSampleRef.current; + if (!scrolling) { + sample.fast = false; + sample.at = 0; + sample.offset = instance.scrollOffset ?? sample.offset; + return; + } + const now = performance.now(); + const offset = instance.scrollOffset ?? 0; + const elapsed = sample.at === 0 ? 0 : now - sample.at; + const distance = Math.abs(offset - sample.offset); + const viewportSize = instance.scrollRect?.height ?? 0; + sample.fast = + (sample.at === 0 || elapsed <= 100) && + distance >= Math.max(200, viewportSize * 0.5); + sample.at = now; + sample.offset = offset; + }, + [], + ); + const initialOffset = useCallback( + () => resolvedGetScrollElement()?.scrollTop ?? 0, + [resolvedGetScrollElement], + ); + + const virtualizer = useVirtualizer({ + count: itemKeys.length, + directDomUpdates: true, + directDomUpdatesMode: "position", + enabled: windowingEnabled, + estimateSize, + gap, + getItemKey, + getScrollElement: resolvedGetScrollElement, + initialOffset, + isScrollingResetDelay: TIMELINE_WINDOW_IDLE_DELAY_MS, + measureElement, + onChange: handleVirtualizerChange, + overscan: TIMELINE_WINDOW_OVERSCAN_ITEMS, + rangeExtractor, + scrollMargin, + useFlushSync: false, + }); + const containerRef = useComposedRefs( + containerElementRef, + virtualizer.containerRef, + ); + + const updateScrollGeometry = useCallback(() => { + if (!configured) return; + const container = containerElementRef.current; + const scrollElement = resolvedGetScrollElement(); + if (container === null || scrollElement === null) return; + const nextMargin = + container.getBoundingClientRect().top - + scrollElement.getBoundingClientRect().top + + scrollElement.scrollTop - + scrollElement.clientTop; + setScrollMargin((previous) => + Math.abs(previous - nextMargin) < 0.5 ? previous : nextMargin, + ); + }, [configured, resolvedGetScrollElement]); + + useLayoutEffect(() => { + if (!configured) return; + const updateRootUsability = () => { + const scrollElement = resolvedGetScrollElement(); + if (scrollElement !== null) { + setScrollRootUsable(scrollElement.clientHeight > 0); + } + }; + const scrollElement = resolvedGetScrollElement(); + if (scrollElement === null) { + const frame = requestAnimationFrame(() => { + updateRootUsability(); + updateScrollGeometry(); + }); + return () => cancelAnimationFrame(frame); + } + updateRootUsability(); + updateScrollGeometry(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + updateRootUsability(); + updateScrollGeometry(); + }); + observer.observe(scrollElement); + const containerParent = containerElementRef.current?.parentElement; + if (containerParent !== null && containerParent !== undefined) { + observer.observe(containerParent); + } + return () => observer.disconnect(); + }, [configured, resolvedGetScrollElement, updateScrollGeometry]); + + // A nested list's offset can change when its owning row expands or reflows + // without resizing the scroll root itself. Re-read after those React commits. + useLayoutEffect(updateScrollGeometry); + + const retainInteractedItem = useCallback( + (event: SyntheticEvent) => { + const container = containerElementRef.current; + if (container === null) return; + const key = findOwnedWindowKey(event.target, container, indexByKey); + if (key === null) return; + setInteractionPins((previous) => { + const next = previous.filter((candidate) => candidate !== key); + next.push(key); + return next.slice(-TIMELINE_WINDOW_MAX_INTERACTION_PINS); + }); + }, + [indexByKey], + ); + + if (!windowingEnabled) { + return ( + <> + {itemKeys.map((key, index) => + renderItem(index, { + isRealized: true, + itemIndex: undefined, + itemRef: NOOP_ITEM_REF, + itemStyle: undefined, + windowingEnabled: false, + }), + )} + + ); + } + + const fastScrolling = scrollSampleRef.current.fast; + const virtualItemsByIndex = new Map( + virtualizer.getVirtualItems().map((item) => [item.index, item]), + ); + // Range calculation starts after the scroll element is measured. Product- + // pinned rows must exist in the first commit so navigation search and saved + // scroll restoration can find their DOM ids immediately. + for (const index of forcedIndexes) { + const item = virtualizer.measurementsCache[index]; + if (item !== undefined) virtualItemsByIndex.set(index, item); + } + const virtualItems = [...virtualItemsByIndex.values()].sort( + (left, right) => left.index - right.index, + ); + return ( +
+ {virtualItems.map((item) => { + const isRealized = !fastScrolling || forcedIndexes.has(item.index); + return renderItem(item.index, { + isRealized, + itemIndex: item.index, + itemRef: virtualizer.measureElement, + itemStyle: { + position: "absolute", + left: 0, + width: "100%", + ...(isRealized + ? undefined + : { + height: item.size, + minHeight: item.size, + overflow: "hidden", + }), + }, + windowingEnabled: true, + }); + })} +
+ ); +} diff --git a/apps/app/src/components/thread/timeline/TimelineWindowedItemsLoader.tsx b/apps/app/src/components/thread/timeline/TimelineWindowedItemsLoader.tsx new file mode 100644 index 0000000000..d05a0945fa --- /dev/null +++ b/apps/app/src/components/thread/timeline/TimelineWindowedItemsLoader.tsx @@ -0,0 +1,100 @@ +import { + createContext, + lazy, + Suspense, + type CSSProperties, + type ReactNode, +} from "react"; + +const DEFAULT_WINDOWING_MIN_ITEM_COUNT = 20; +const MAX_CONTROL_PATH_MEASUREMENTS = 2_000; +const NOOP_ITEM_REF = () => {}; + +export interface TimelineWindowingScrollRoot { + getScrollElement: () => HTMLElement | null; +} + +/** Nested capped details virtualize against their own scroll element. */ +export const TimelineWindowingScrollRootContext = + createContext(null); + +/** Exact heights survive while a virtualized parent unmounts a nested list. */ +export const TimelineWindowingMeasurementsContext = createContext | null>(null); + +export interface TimelineWindowedItemRenderState { + isRealized: boolean; + itemIndex: number | undefined; + itemRef: (node: HTMLDivElement | null) => void; + itemStyle: CSSProperties | undefined; + windowingEnabled: boolean; +} + +export interface TimelineWindowedItemsProps { + enabled: boolean; + alwaysMountedKeys?: ReadonlySet; + estimateItemHeight: (index: number) => number; + gap: number; + getScrollElement: (() => HTMLElement | null) | null; + itemKeys: readonly string[]; + measurements: Map; + minItemCount?: number; + renderItem: ( + index: number, + state: TimelineWindowedItemRenderState, + ) => ReactNode; +} + +const LazyTimelineWindowedItems = lazy(async () => { + const module = await import("./TimelineWindowedItems.js"); + return { default: module.TimelineWindowedItems }; +}); + +function TimelineWindowedItemsControl({ + itemKeys, + measurements, + renderItem, + captureMeasurements = false, +}: TimelineWindowedItemsProps & { captureMeasurements?: boolean }) { + return itemKeys.map((key, index) => + renderItem(index, { + isRealized: true, + itemIndex: captureMeasurements ? index : undefined, + itemRef: captureMeasurements + ? (element) => { + if (element === null) return; + const height = element.getBoundingClientRect().height; + if (height <= 0) return; + measurements.delete(key); + measurements.set(key, height); + while (measurements.size > MAX_CONTROL_PATH_MEASUREMENTS) { + const oldestKey = measurements.keys().next().value; + if (oldestKey === undefined) break; + measurements.delete(oldestKey); + } + } + : NOOP_ITEM_REF, + itemStyle: undefined, + windowingEnabled: false, + }), + ); +} + +/** Keep TanStack Virtual out of the route bundle until the experiment is on. */ +export function TimelineWindowedItemsLoader(props: TimelineWindowedItemsProps) { + const configured = + props.enabled && + props.getScrollElement !== null && + props.itemKeys.length >= + (props.minItemCount ?? DEFAULT_WINDOWING_MIN_ITEM_COUNT); + if (!configured) return ; + return ( + } + > + + + ); +} diff --git a/apps/app/src/components/thread/timeline/TimelineWorkingIndicator.tsx b/apps/app/src/components/thread/timeline/TimelineWorkingIndicator.tsx index fbb25d0a1f..05dfd2aab2 100644 --- a/apps/app/src/components/thread/timeline/TimelineWorkingIndicator.tsx +++ b/apps/app/src/components/thread/timeline/TimelineWorkingIndicator.tsx @@ -13,7 +13,7 @@ import { TimelineStatusIndicator } from "./TimelineStatusIndicator.js"; // animates the jump (see HeightTransition's ResizeObserver). const INDICATOR_HEADER_HEIGHT_CLASS = "min-h-7 items-center"; -export interface TimelineWorkingIndicatorProps { +interface TimelineWorkingIndicatorProps { label?: string; isThinking?: boolean; details?: string; diff --git a/apps/app/src/components/thread/timeline/ToolCallDetailBlock.tsx b/apps/app/src/components/thread/timeline/ToolCallDetailBlock.tsx index 471b4135fb..f33a16c0ca 100644 --- a/apps/app/src/components/thread/timeline/ToolCallDetailBlock.tsx +++ b/apps/app/src/components/thread/timeline/ToolCallDetailBlock.tsx @@ -7,7 +7,7 @@ import { } from "./conversation-message-overflow.js"; import { TimelineDetailScroll } from "./TimelineDetailScroll.js"; -export interface ToolCallDetailBlockProps { +interface ToolCallDetailBlockProps { toolName: string; args: TimelineToolArgs; output: string; diff --git a/apps/app/src/components/thread/timeline/TurnRequestLabel.tsx b/apps/app/src/components/thread/timeline/TurnRequestLabel.tsx index 53136be68d..3135c26809 100644 --- a/apps/app/src/components/thread/timeline/TurnRequestLabel.tsx +++ b/apps/app/src/components/thread/timeline/TurnRequestLabel.tsx @@ -1,7 +1,7 @@ import type { TimelineConversationTurnRequest } from "@bb/server-contract"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; -import { turnRequestLabel } from "./conversation-turn-request-label.js"; +import { turnRequestLabel } from "@bb/client-core"; interface TurnRequestLabelProps { turnRequest: TimelineConversationTurnRequest; diff --git a/apps/app/src/components/thread/timeline/conversation-message-overflow.test.tsx b/apps/app/src/components/thread/timeline/conversation-message-overflow.test.tsx index 3f1b77dd1a..4b93d4361e 100644 --- a/apps/app/src/components/thread/timeline/conversation-message-overflow.test.tsx +++ b/apps/app/src/components/thread/timeline/conversation-message-overflow.test.tsx @@ -8,6 +8,7 @@ import { useOverflowMeasurement } from "./conversation-message-overflow"; afterEach(() => { cleanup(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); function OverflowProbe({ name }: { name: string }) { @@ -17,9 +18,7 @@ function OverflowProbe({ name }: { name: string }) { enabled: true, measurementKey: name, }); - return ( -
- ); + return
; } describe("useOverflowMeasurement", () => { @@ -41,6 +40,20 @@ describe("useOverflowMeasurement", () => { disconnect = disconnect; }, ); + const scrollHeight = vi + .spyOn(HTMLElement.prototype, "scrollHeight", "get") + .mockImplementation(function (this: HTMLElement) { + return this.dataset.testid === "first" ? 80 : 20; + }); + const clientHeight = vi + .spyOn(HTMLElement.prototype, "clientHeight", "get") + .mockReturnValue(20); + const scrollWidth = vi + .spyOn(HTMLElement.prototype, "scrollWidth", "get") + .mockReturnValue(20); + const clientWidth = vi + .spyOn(HTMLElement.prototype, "clientWidth", "get") + .mockReturnValue(20); render( <> @@ -51,18 +64,12 @@ describe("useOverflowMeasurement", () => { const first = screen.getByTestId("first"); const second = screen.getByTestId("second"); - Object.defineProperties(first, { - scrollHeight: { configurable: true, value: 80 }, - clientHeight: { configurable: true, value: 20 }, - scrollWidth: { configurable: true, value: 20 }, - clientWidth: { configurable: true, value: 20 }, - }); - Object.defineProperties(second, { - scrollHeight: { configurable: true, value: 20 }, - clientHeight: { configurable: true, value: 20 }, - scrollWidth: { configurable: true, value: 20 }, - clientWidth: { configurable: true, value: 20 }, - }); + expect(first.dataset.measurement).toBe("unmeasured"); + expect(second.dataset.measurement).toBe("unmeasured"); + expect(scrollHeight).not.toHaveBeenCalled(); + expect(clientHeight).not.toHaveBeenCalled(); + expect(scrollWidth).not.toHaveBeenCalled(); + expect(clientWidth).not.toHaveBeenCalled(); act(() => { observerCallback?.( @@ -76,6 +83,11 @@ describe("useOverflowMeasurement", () => { expect(constructorSpy).toHaveBeenCalledOnce(); expect(observe).toHaveBeenCalledTimes(2); + expect(scrollHeight).toHaveBeenCalledTimes(2); + expect(clientHeight).toHaveBeenCalledTimes(2); + // The overflowing first row short-circuits before the width reads. + expect(scrollWidth).toHaveBeenCalledOnce(); + expect(clientWidth).toHaveBeenCalledOnce(); expect(first.dataset.measurement).toBe("overflowing"); expect(second.dataset.measurement).toBe("fits"); }); diff --git a/apps/app/src/components/thread/timeline/conversation-message-overflow.tsx b/apps/app/src/components/thread/timeline/conversation-message-overflow.tsx index 85d68c8317..7c841f3586 100644 --- a/apps/app/src/components/thread/timeline/conversation-message-overflow.tsx +++ b/apps/app/src/components/thread/timeline/conversation-message-overflow.tsx @@ -82,14 +82,8 @@ function observeOverflow( }; } -interface ConversationMessageOverflowToggleLabels { - collapsed: string; - expanded: string; -} - interface ConversationMessageOverflowToggleProps { expanded: boolean; - labels: ConversationMessageOverflowToggleLabels; onToggle: () => void; } @@ -132,9 +126,12 @@ export function useOverflowMeasurement({ if (!element.isConnected) return; setMeasurement(nextMeasurement); }; - applyMeasurement(readOverflowMeasurement(element)); - if (typeof ResizeObserver === "undefined") { + // Modern browsers deliver one initial ResizeObserver batch for every + // observed element. Waiting for that batch lets the shared observer + // complete all sibling layout reads before any React state write. A + // synchronous read here would instead force layout once per message. + applyMeasurement(readOverflowMeasurement(element)); return; } @@ -150,7 +147,6 @@ export function useIsOverflowing(args: UseOverflowMeasurementArgs): boolean { export function ConversationMessageOverflowToggle({ expanded, - labels, onToggle, }: ConversationMessageOverflowToggleProps) { return ( @@ -161,7 +157,7 @@ export function ConversationMessageOverflowToggle({ className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground" aria-expanded={expanded} > - {expanded ? labels.expanded : labels.collapsed} + {expanded ? "Show less" : "Show more"}
); diff --git a/apps/app/src/components/thread/timeline/index.ts b/apps/app/src/components/thread/timeline/index.ts index 383c6ae29b..46693249d3 100644 --- a/apps/app/src/components/thread/timeline/index.ts +++ b/apps/app/src/components/thread/timeline/index.ts @@ -1,34 +1,15 @@ -export { isRunningThreadRuntimeDisplayStatus } from "./thread-runtime-status.js"; +export { isRunningThreadRuntimeDisplayStatus } from "@bb/client-core"; export { ThreadTimelineRows } from "./ThreadTimelineRows.js"; export type { ThreadTimelineRowsProps } from "./ThreadTimelineRows.js"; -export { - ThreadTimelinePanelContent, - type ThreadTimelinePanelContentProps, -} from "./ThreadTimelinePanelContent.js"; +export { ThreadTimelinePanelContent } from "./ThreadTimelinePanelContent.js"; export { ThreadTimelineSurface, - type HostConnectionNotice, type ThreadTimelineSurfaceProps, } from "./ThreadTimelineSurface.js"; -export { - useThreadTimelineController, - type ThreadTimelineRowFilter, - type UseThreadTimelineControllerArgs, - type UseThreadTimelineControllerResult, -} from "./useThreadTimelineController.js"; +export { useThreadTimelineController } from "./useThreadTimelineController.js"; export type { TimelineTitleActionResolver } from "./TimelineTitleView.js"; -export { - TimelineStatusIndicator, - type TimelineStatusIndicatorProps, -} from "./TimelineStatusIndicator.js"; -export { - TimelineWorkingIndicator, - type TimelineWorkingIndicatorProps, -} from "./TimelineWorkingIndicator.js"; -export { - ThreadContextWindowIndicator, - type ThreadContextWindowIndicatorProps, -} from "./ThreadContextWindowIndicator.js"; +export { TimelineWorkingIndicator } from "./TimelineWorkingIndicator.js"; +export { ThreadContextWindowIndicator } from "./ThreadContextWindowIndicator.js"; export type { ThreadTimelineEditMessageHandler, ThreadTimelineEditMessageTarget, @@ -36,15 +17,10 @@ export type { ThreadTimelineForkMessageHandler, ThreadTimelineAddToChatHandler, ThreadTimelineSendToMainMessageHandler, - ThreadTimelineSendToMainMessageTarget, ThreadTimelineConsumerMessageAction, ThreadTimelineLinkHandler, - ThreadTimelineImageViewSrcResolver, - ThreadTimelineImageViewSrcTarget, ThreadTimelineLocalFileLink, ThreadTimelineLocalFileLinkHandler, ThreadTimelineOpenPluginPanelHandler, ThreadTimelineUnreadDividerPlacement, - ThreadTimelineTheme, - UserAttachmentImageSrcResolver, } from "./types.js"; diff --git a/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx b/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx index eeba252094..5fa8301ad5 100644 --- a/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx +++ b/apps/app/src/components/thread/timeline/rows/AssistantMessage.stories.tsx @@ -207,11 +207,8 @@ export function Overview() { id="row_story_short" threadId="thr_story" turnId="turn_story_short" - sourceSeqStart={0} - sourceSeqEnd={0} text={shortMessage} attachments={null} - turnRequest={null} showActions={true} mobileActionDisplay="inline" streaming={false} @@ -228,11 +225,8 @@ export function Overview() { id="row_story_long" threadId="thr_story" turnId="turn_story_long" - sourceSeqStart={0} - sourceSeqEnd={0} text={longMessage} attachments={null} - turnRequest={null} showActions={true} mobileActionDisplay="inline" streaming={false} diff --git a/apps/app/src/components/thread/timeline/rows/FileChange.stories.tsx b/apps/app/src/components/thread/timeline/rows/FileChange.stories.tsx index cdbd79a169..c3589617b2 100644 --- a/apps/app/src/components/thread/timeline/rows/FileChange.stories.tsx +++ b/apps/app/src/components/thread/timeline/rows/FileChange.stories.tsx @@ -1,9 +1,5 @@ import type { TimelineRow } from "@bb/server-contract"; -import { - ThreadTimelineRows, - type ThreadTimelineRowsProps, -} from "@/components/thread/timeline"; -import { usePreferredTheme } from "@/hooks/useTheme"; +import { ThreadTimelineRows } from "@/components/thread/timeline"; import { fileChangeRow } from "@/test/fixtures/thread-timeline-rows"; import { StoryCard, StoryRow } from "../../../../../.ladle/story-card"; @@ -20,18 +16,6 @@ const baseProps = { workspaceRootPath: "/Users/michael/.bb-dev/worktrees/env_story/bb", }; -// Story-only wrapper — pulls the active theme from ladle so the diff body's -// syntax highlighting flips with the toolbar toggle. Without this each -// ThreadTimelineRows render would default to themeType="light" regardless -// of the page theme. -type ThemedTimelineRowsProps = Omit & - Partial>; - -function ThemedTimelineRows(props: ThemedTimelineRowsProps) { - const themeType = usePreferredTheme(); - return ; -} - // --------------------------------------------------------------------------- // Real file-change rows pulled from live threads in ~/.bb-dev/bb.db. // @@ -199,7 +183,7 @@ export function Overview() { hint="production-default — header only, click to expand. Real unified diff." > - + - @@ -218,7 +202,7 @@ export function Overview() { hint="kind=delete. Diff is the prior file content; stats count as removed." > - @@ -229,7 +213,7 @@ export function Overview() { hint="status=pending, no completedAt — edit is mid-flight" > - @@ -237,7 +221,7 @@ export function Overview() { - + - @@ -256,7 +240,7 @@ export function Overview() { hint="approvalStatus=waiting_for_approval, parked before applying the edit" > - @@ -267,7 +251,7 @@ export function Overview() { hint="approvalStatus=denied, user rejected the edit" > - @@ -278,7 +262,7 @@ export function Overview() { hint="extract-to-memo refactor of ThreadFollowUpComposer.tsx — full diff body inline" > - undefined; interface StoryMentionArgs { resource: PromptMentionResource; @@ -538,6 +539,29 @@ export function Overview() { return ( + + + + + ; diff --git a/apps/app/src/components/thread/timeline/timeline-row-containment.ts b/apps/app/src/components/thread/timeline/timeline-row-containment.ts index 69c2902646..e03aac9bc9 100644 --- a/apps/app/src/components/thread/timeline/timeline-row-containment.ts +++ b/apps/app/src/components/thread/timeline/timeline-row-containment.ts @@ -49,10 +49,11 @@ export const TOP_LEVEL_TIMELINE_ROW_CLASS_NAME = `${CONTENT_VISIBILITY_CLASS_NAM */ export function useArmTopLevelTimelineRowContainment( wrapperRef: RefObject, + enabled = true, ): void { useEffect(() => { const wrapper = wrapperRef.current; - if (wrapper === null || !supportsScrollAnchoring()) { + if (!enabled || wrapper === null || !supportsScrollAnchoring()) { return; } let cancelled = false; @@ -74,7 +75,7 @@ export function useArmTopLevelTimelineRowContainment( cancelAnimationFrame(secondFrame); } }; - }, [wrapperRef]); + }, [enabled, wrapperRef]); } /** diff --git a/apps/app/src/components/thread/timeline/types.ts b/apps/app/src/components/thread/timeline/types.ts index 13e1371de3..67a3a2db8a 100644 --- a/apps/app/src/components/thread/timeline/types.ts +++ b/apps/app/src/components/thread/timeline/types.ts @@ -5,11 +5,9 @@ import type { MarkdownPreviewLocalFileLinkHandler, } from "../../ui/markdown-local-file-link.js"; import type { MarkdownPreviewLinkHandler } from "../../ui/markdown-link.js"; -import type { PromptDraftAttachment } from "@/lib/prompt-draft"; +import type { PromptDraftAttachment } from "@bb/client-core"; import type { MarkdownMessageDirectiveOpenThreadPanel } from "@/components/ui/markdown-message-directives"; -export type ThreadTimelineTheme = "light" | "dark"; - export type ThreadTimelineLocalFileLink = MarkdownPreviewLocalFileLink; export type ThreadTimelineLocalFileLinkHandler = @@ -20,7 +18,7 @@ export type ThreadTimelineLinkHandler = MarkdownPreviewLinkHandler; export type ThreadTimelineOpenPluginPanelHandler = MarkdownMessageDirectiveOpenThreadPanel; -export interface ThreadTimelineForkMessageTarget { +interface ThreadTimelineForkMessageTarget { /** Last source event sequence included in the provider-history fork. */ sourceSeqEnd: number; } diff --git a/apps/app/src/components/thread/timeline/useAutoLoadOlderRows.ts b/apps/app/src/components/thread/timeline/useAutoLoadOlderRows.ts index 8a1c6e4c27..2485f7757c 100644 --- a/apps/app/src/components/thread/timeline/useAutoLoadOlderRows.ts +++ b/apps/app/src/components/thread/timeline/useAutoLoadOlderRows.ts @@ -8,13 +8,13 @@ import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll- */ const AUTO_LOAD_OLDER_ROWS_PREFETCH_MARGIN_PX = 600; -export interface UseAutoLoadOlderRowsArgs { +interface UseAutoLoadOlderRowsArgs { hasOlderTimelineRows: boolean; isLoadingOlderTimelineRows: boolean; onLoadOlderRows: (() => Promise | void) | undefined; } -export interface AutoLoadOlderRows { +interface AutoLoadOlderRows { /** * Attach to the element marking the top of the loaded window. Null-safe to * attach even when auto-loading is off. diff --git a/apps/app/src/components/thread/timeline/useScrollOverflowState.ts b/apps/app/src/components/thread/timeline/useScrollOverflowState.ts index f99e57d9d5..2b35ac05b9 100644 --- a/apps/app/src/components/thread/timeline/useScrollOverflowState.ts +++ b/apps/app/src/components/thread/timeline/useScrollOverflowState.ts @@ -21,19 +21,20 @@ import { * is async and only delivers callbacks when a sentinel actually crosses * the visible boundary, so it doesn't pile up work during animations. */ -export interface ScrollOverflowSentinelRefs { +interface ScrollOverflowSentinelRefs { scrollRef: RefObject; topSentinelRef: RefObject; bottomSentinelRef: RefObject; } -export interface ScrollOverflowStateBinding - extends ScrollOverflowSentinelRefs { +interface ScrollOverflowStateBinding< + TElement extends HTMLElement, +> extends ScrollOverflowSentinelRefs { aboveOverflow: boolean; belowOverflow: boolean; } -export interface UseScrollOverflowStateOptions { +interface UseScrollOverflowStateOptions { /** * Enables observation after a conditionally-rendered scroll region mounts. * Disable it while the region is absent so reopening rebinds fresh nodes. @@ -53,9 +54,7 @@ interface OverflowFlags { below: boolean; } -export function useScrollOverflowState< - TElement extends HTMLElement, ->( +export function useScrollOverflowState( options: UseScrollOverflowStateOptions = {}, ): ScrollOverflowStateBinding { const scrollRef = useRef(null); diff --git a/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts b/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts index e02892f08f..6c6aa338a8 100644 --- a/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts +++ b/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts @@ -30,6 +30,7 @@ interface SeqRange { const FLASH_CLASS_NAME = "bb-search-flash"; const FLASH_DURATION_MS = 1700; +const POST_WINDOW_SETTLE_REVEAL_MS = 800; function escapeTimelineRowId(rowId: string): string { if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { @@ -129,7 +130,7 @@ function collectSearchedMessageAncestorRowIdsInRows({ continue; } const nestedRows = getNestedRows(row); - if (nestedRows === null) { + if (nestedRows === null || nestedRows.length === 0) { ancestorIds.add(row.id); return true; } @@ -201,6 +202,8 @@ export function useScrollToSearchedMessage( const bottomAnchor = useBottomAnchoredScroll(); const handledKeyRef = useRef(null); const olderLoadAttemptKeyRef = useRef(null); + const locationKeyRef = useRef(location.key); + locationKeyRef.current = location.key; const target = readSearchMessageTarget(location.state); const targetSeq = target?.seq ?? null; const targetThreadId = target?.threadId ?? null; @@ -247,13 +250,20 @@ export function useScrollToSearchedMessage( return; } const selector = `[data-timeline-row-id="${escapeTimelineRowId(targetLeafRow.id)}"]`; - if (document.querySelector(selector) === null) { + const renderedTarget = document.querySelector(selector); + if ( + renderedTarget === null || + renderedTarget.dataset.timelineWindowedRealized === "false" + ) { return; } handledKeyRef.current = location.key; let flashed = false; const revealTarget = () => { + if (locationKeyRef.current !== location.key) { + return; + } const element = document.querySelector(selector); if (element === null) { return; @@ -277,13 +287,12 @@ export function useScrollToSearchedMessage( } }; - // Reveal on the next frame, then once more after layout settles, so a late - // scroll-anchor restore can't leave the target off-screen. + // Reveal after initial layout and again after idle placeholder correction. const frame = requestAnimationFrame(revealTarget); - const settle = window.setTimeout(revealTarget, 320); + window.setTimeout(revealTarget, 320); + window.setTimeout(revealTarget, POST_WINDOW_SETTLE_REVEAL_MS); return () => { cancelAnimationFrame(frame); - window.clearTimeout(settle); }; }, [ bottomAnchor, diff --git a/apps/app/src/components/thread/timeline/useStickyBottomScroll.ts b/apps/app/src/components/thread/timeline/useStickyBottomScroll.ts index b016f9bd8b..bff135dfe2 100644 --- a/apps/app/src/components/thread/timeline/useStickyBottomScroll.ts +++ b/apps/app/src/components/thread/timeline/useStickyBottomScroll.ts @@ -9,7 +9,7 @@ import { type WheelEventHandler, } from "react"; -export interface StickyBottomScrollBinding { +interface StickyBottomScrollBinding { /** * Attach to an element that wraps the scrolled content. The scroll port's * box is fixed, so content-only height changes (an image load, a @@ -25,7 +25,7 @@ export interface StickyBottomScrollBinding { ref: RefObject; } -export interface UseStickyBottomScrollArgs { +interface UseStickyBottomScrollArgs { contentKey: string; // When false, the hook is dormant: the scroll-to-bottom effect doesn't fire // on contentKey changes and the window pointer-tracking listeners aren't diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.merge.test.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.merge.test.ts deleted file mode 100644 index 538bd74d83..0000000000 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.merge.test.ts +++ /dev/null @@ -1,583 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - ThreadTimelineResponse, - TimelineCommandWorkRow, - TimelinePaginationCursor, - TimelineRow, - TimelineTurnRow, - TimelineUserConversationRow, -} from "@bb/server-contract"; -import { - mergeLoadedTimelineWithLatest, - mergeLatestTimelineRows, - prependOlderTimelineRows, - recoverLoadedTimelineAfterStaleCursor, - type LoadedTimelineState, -} from "./useThreadTimelineController"; - -interface TimelineTestRowArgs { - endSequence?: number; - id: string; - sequence: number; -} - -interface TimelineTurnTestRowArgs extends TimelineTestRowArgs { - children?: TimelineRow[]; - endSequence?: number; -} - -function timelineCursor(args: TimelineTestRowArgs): TimelinePaginationCursor { - return { - anchorSeq: args.sequence, - anchorId: args.id, - }; -} - -function userRow(args: TimelineTestRowArgs): TimelineUserConversationRow { - return { - id: args.id, - threadId: "thread-1", - turnId: "turn-1", - sourceSeqStart: args.sequence, - sourceSeqEnd: args.endSequence ?? args.sequence, - startedAt: args.sequence, - createdAt: args.sequence, - kind: "conversation", - role: "user", - initiator: "user", - senderThreadId: null, - systemMessageKind: "unlabeled", - systemMessageSubject: null, - text: args.id, - mentions: [], - attachments: null, - turnRequest: { isGrouped: false, kind: "message", status: "accepted" }, - }; -} - -function commandRow(args: TimelineTestRowArgs): TimelineCommandWorkRow { - return { - id: args.id, - threadId: "thread-1", - turnId: "turn-1", - sourceSeqStart: args.sequence, - sourceSeqEnd: args.endSequence ?? args.sequence, - startedAt: args.sequence, - createdAt: args.sequence, - kind: "work", - workKind: "command", - status: "completed", - callId: args.id, - command: "pnpm test", - cwd: null, - source: null, - output: "", - exitCode: 0, - completedAt: args.sequence, - approvalStatus: null, - activityIntents: [], - }; -} - -function turnSummaryRow(args: TimelineTurnTestRowArgs): TimelineTurnRow { - return { - id: args.id, - threadId: "thread-1", - turnId: "turn-1", - sourceSeqStart: args.sequence, - sourceSeqEnd: args.endSequence ?? args.sequence, - startedAt: args.sequence, - createdAt: args.sequence, - kind: "turn", - status: "completed", - summaryCount: 1, - completedAt: args.sequence, - children: args.children ?? null, - }; -} - -function makeTimelineResponse( - rows: TimelineRow[], - olderCursor: TimelinePaginationCursor | null, - maxSeq = Math.max( - olderCursor?.anchorSeq ?? 0, - ...rows.map((row) => row.sourceSeqEnd), - ), -): ThreadTimelineResponse { - return { - rows, - activePromptMode: null, - activeThinking: null, - activeWorkflows: [], - activeBackgroundCommands: [], - pendingTodos: null, - goal: null, - modelFallback: null, - maxSeq, - timelinePage: { - kind: "latest", - segmentLimit: 20, - returnedSegmentCount: rows.length > 0 ? 1 : 0, - hasOlderRows: olderCursor !== null, - olderCursor, - }, - }; -} - -function makeLoadedTimelineState( - rows: TimelineRow[], - olderCursor: TimelinePaginationCursor | null, - latestWindowEndSequence = Math.max( - olderCursor?.anchorSeq ?? 0, - ...rows.map((row) => row.sourceSeqEnd), - ), -): LoadedTimelineState { - return { - latestWindowEndSequence, - rows, - olderCursor, - surfaceKey: "thread-1:default", - }; -} - -describe("timeline page row merging", () => { - it("prepends older server-ordered rows without sorting by source sequence", () => { - const olderUser = userRow({ id: "older-user", sequence: 10 }); - const olderCommand = commandRow({ id: "older-command", sequence: 1 }); - const latestUser = userRow({ id: "latest-user", sequence: 20 }); - - const rows = prependOlderTimelineRows({ - olderRows: [olderUser, olderCommand], - loadedRows: [latestUser], - }); - - expect(rows.map((row) => row.id)).toEqual([ - "older-user", - "older-command", - "latest-user", - ]); - }); - - it("keeps server-ordered worked-for rows after the first user when their source sequence sorts earlier", () => { - const firstUser = userRow({ id: "first-user", sequence: 10 }); - const workedForSummary = turnSummaryRow({ - id: "worked-for-summary", - sequence: 1, - }); - const latestUser = userRow({ id: "latest-user", sequence: 20 }); - - const rows = prependOlderTimelineRows({ - olderRows: [firstUser, workedForSummary], - loadedRows: [latestUser], - }); - - expect(rows.map((row) => row.id)).toEqual([ - "first-user", - "worked-for-summary", - "latest-user", - ]); - }); - - it("keeps distinct byte-budget slices of one finished turn", () => { - const olderCommands = [ - commandRow({ id: "command-1", sequence: 10 }), - commandRow({ id: "command-2", sequence: 11 }), - ]; - const latestCommands = [ - commandRow({ id: "command-3", sequence: 20 }), - commandRow({ id: "command-4", sequence: 21 }), - ]; - const olderSlice = turnSummaryRow({ - id: "turn-1:sequence-page:10", - sequence: 10, - children: olderCommands, - }); - const latestSlice = turnSummaryRow({ - id: "turn-1:sequence-page:20", - sequence: 20, - children: latestCommands, - }); - - const rows = prependOlderTimelineRows({ - olderRows: [olderSlice], - loadedRows: [latestSlice], - }); - - expect(rows.map((row) => row.id)).toEqual([ - "turn-1:sequence-page:10", - "turn-1:sequence-page:20", - ]); - expect( - rows.flatMap((row) => - row.kind === "turn" && row.children !== null - ? row.children.map((child) => child.id) - : [], - ), - ).toEqual(["command-1", "command-2", "command-3", "command-4"]); - }); - - it("replaces a byte-cut latest page while an unfinished turn grows", () => { - const loadedRows = [15, 16, 17, 18].map((sequence) => - commandRow({ - id: `command-${sequence}`, - sequence, - }), - ); - const latestRows = [15, 16, 17, 18, 19, 20].map((sequence) => - commandRow({ - id: `command-${sequence}`, - sequence, - }), - ); - - const merge = mergeLatestTimelineRows({ - latestWindowStartSequence: 15, - loadedRows, - latestRows, - }); - const callIds = merge.rows.flatMap((row) => - row.kind === "work" && row.workKind === "command" ? [row.callId] : [], - ); - - expect(merge.canMerge).toBe(true); - expect(merge.rows).toHaveLength(6); - expect(new Set(callIds).size).toBe(6); - }); - - it("replaces the overlapping latest tail while preserving loaded history", () => { - const olderUser = userRow({ id: "older-user", sequence: 1 }); - const oldTail = userRow({ id: "live-tail", sequence: 20 }); - const updatedTail = { - ...oldTail, - sourceSeqEnd: oldTail.sourceSeqEnd + 1, - text: "updated tail", - }; - const newStreamingRow = commandRow({ - id: "new-streaming-row", - sequence: 21, - }); - - const merge = mergeLatestTimelineRows({ - latestWindowStartSequence: 20, - loadedRows: [olderUser, oldTail], - latestRows: [updatedTail, newStreamingRow], - }); - - expect(merge.rows.map((row) => row.id)).toEqual([ - "older-user", - "live-tail", - "new-streaming-row", - ]); - expect(merge.rows[1]).toMatchObject({ text: "updated tail" }); - }); - - it("retains every loaded row before the latest raw window boundary", () => { - const prompt = userRow({ id: "prompt", sequence: 1 }); - const straddlingWork = commandRow({ - endSequence: 210, - id: "straddling-work", - sequence: 20, - }); - const steer = userRow({ id: "accepted-steer", sequence: 100 }); - const olderWork = commandRow({ id: "older-work", sequence: 120 }); - const coveredStaleRow = commandRow({ - id: "covered-stale-row", - sequence: 180, - }); - const tail = commandRow({ id: "tail", sequence: 200 }); - const updatedStraddlingWork = { - ...straddlingWork, - sourceSeqEnd: 300, - output: "updated straddling output", - }; - const updatedTail = { - ...tail, - sourceSeqEnd: 300, - output: "updated tail output", - }; - const newTail = commandRow({ id: "new-tail", sequence: 250 }); - - const merge = mergeLatestTimelineRows({ - latestRows: [updatedStraddlingWork, updatedTail, newTail], - latestWindowStartSequence: 150, - loadedRows: [ - prompt, - straddlingWork, - steer, - olderWork, - coveredStaleRow, - tail, - ], - }); - - expect(merge.rows.map((row) => row.id)).toEqual([ - "prompt", - "straddling-work", - "accepted-steer", - "older-work", - "tail", - "new-tail", - ]); - expect(merge.canMerge).toBe(true); - expect(merge.rows[1]).toBe(updatedStraddlingWork); - expect(merge.rows[2]).toBe(steer); - expect(merge.rows[3]).toBe(olderWork); - expect(merge.rows[4]).toBe(updatedTail); - }); - - it("preserves unchanged overlapping row references after a latest refetch", () => { - const olderUser = userRow({ id: "older-user", sequence: 1 }); - const oldTail = userRow({ id: "live-tail", sequence: 20 }); - const loadedRows = [olderUser, oldTail]; - const refetchedTail = { ...oldTail }; - - const merge = mergeLatestTimelineRows({ - latestWindowStartSequence: 20, - loadedRows, - latestRows: [refetchedTail], - }); - - expect(merge.rows).toHaveLength(2); - expect(merge.rows).toBe(loadedRows); - expect(merge.rows[0]).toBe(olderUser); - expect(merge.rows[1]).toBe(oldTail); - }); - - it("replaces changed overlapping row references after a latest refetch", () => { - const olderUser = userRow({ id: "older-user", sequence: 1 }); - const oldTail = userRow({ id: "live-tail", sequence: 20 }); - const updatedTail = { - ...oldTail, - sourceSeqEnd: oldTail.sourceSeqEnd + 1, - text: "updated tail", - }; - - const merge = mergeLatestTimelineRows({ - latestWindowStartSequence: 20, - loadedRows: [olderUser, oldTail], - latestRows: [updatedTail], - }); - - expect(merge.rows).toHaveLength(2); - expect(merge.rows[0]).toBe(olderUser); - expect(merge.rows[1]).toBe(updatedTail); - }); - - it("rebuilds when latest advances past the loaded rows with a gap between", () => { - const oldestCursor = timelineCursor({ id: "oldest", sequence: 1 }); - const latestCursor = timelineCursor({ id: "latest-page", sequence: 40 }); - const current = makeLoadedTimelineState( - [userRow({ id: "oldest", sequence: 1 })], - oldestCursor, - ); - const latestTimeline = makeTimelineResponse( - [userRow({ id: "latest", sequence: 50 })], - latestCursor, - ); - - const next = mergeLoadedTimelineWithLatest({ - current, - latestTimeline, - surfaceKey: "thread-1:default", - }); - - // Sequences 2..49 are in neither response. Keeping both and appending would - // render them adjacent and leave `olderCursor` below the loaded rows, so the - // hidden stretch could never be scrolled to. The fresh window replaces the - // stale one instead, and its cursor pages back through the gap. - expect(next.rows.map((row) => row.id)).toEqual(["latest"]); - expect(next.olderCursor).toEqual(latestCursor); - }); - - it("rebuilds across a raw sequence gap even when a projected row overlaps", () => { - const oldCursor = timelineCursor({ id: "old-page", sequence: 1 }); - const latestCursor = timelineCursor({ id: "latest-page", sequence: 150 }); - const straddlingWork = commandRow({ - endSequence: 100, - id: "straddling-work", - sequence: 20, - }); - const updatedStraddlingWork = { - ...straddlingWork, - sourceSeqEnd: 300, - output: "updated output", - }; - const current = makeLoadedTimelineState([straddlingWork], oldCursor, 100); - const latestTimeline = makeTimelineResponse( - [updatedStraddlingWork], - latestCursor, - 300, - ); - - const next = mergeLoadedTimelineWithLatest({ - current, - latestTimeline, - surfaceKey: "thread-1:default", - }); - - // Row ids describe projected objects, not the raw event coverage. The - // shared command cannot bridge sequences 101..149, so adopting the fresh - // page and its cursor is the only state that can paginate through the gap. - expect(next.rows).toEqual([updatedStraddlingWork]); - expect(next.olderCursor).toEqual(latestCursor); - }); - - it("keeps loaded rows when the latest window abuts them without overlapping", () => { - const oldestCursor = timelineCursor({ id: "oldest", sequence: 1 }); - const current = makeLoadedTimelineState( - [userRow({ id: "oldest", sequence: 1 })], - oldestCursor, - ); - const latestTimeline = makeTimelineResponse( - [userRow({ id: "latest", sequence: 2 })], - timelineCursor({ id: "latest-page", sequence: 2 }), - ); - - const next = mergeLoadedTimelineWithLatest({ - current, - latestTimeline, - surfaceKey: "thread-1:default", - }); - - expect(next.rows.map((row) => row.id)).toEqual(["oldest", "latest"]); - expect(next.olderCursor).toEqual(oldestCursor); - }); - - it("reconciles when a finished turn reaches back past loaded in-turn rows", () => { - // Watching a long turn mid-flight loads rows cut at the budget floor, with - // no user message above them. When the turn finishes it collapses into one - // summary row spanning the whole turn, so the next latest response starts at - // the turn's user message — before everything held. Splicing the two would - // put the prompt after the work it produced. - const inTurnCursor = timelineCursor({ - id: "thread-1:in-turn:500", - sequence: 500, - }); - const liveTail = commandRow({ id: "live-tail", sequence: 520 }); - const current = makeLoadedTimelineState( - [commandRow({ id: "live-work", sequence: 500 }), liveTail], - inTurnCursor, - ); - const finishedCursor = timelineCursor({ id: "older-turn", sequence: 1 }); - const latestTimeline = makeTimelineResponse( - [ - userRow({ id: "turn-prompt", sequence: 10 }), - turnSummaryRow({ id: "turn-summary", sequence: 11 }), - liveTail, - ], - finishedCursor, - ); - - const next = mergeLoadedTimelineWithLatest({ - current, - latestTimeline, - surfaceKey: "thread-1:default", - }); - - expect(next.rows.map((row) => row.id)).toEqual([ - "turn-prompt", - "turn-summary", - "live-tail", - ]); - expect(next.olderCursor).toEqual(finishedCursor); - }); - - it("keeps loaded rows when unprojected events separate them from the follow-up window", () => { - // The shape a follow-up submission produces on a byte-budgeted thread: the - // completed turn's summary spans to `turn/completed`, a provider error that - // began later sorts after it and ends earlier, and the events carrying the - // turn's end never become rows at all. The prompt opening the next turn is - // the first sequence of the fresh window and continues the loaded history - // directly, so nothing may be dropped. Observed on a thread whose loaded - // tail ended at 62634, whose turn summary reached 62635, and whose - // follow-up opened the next window at 62636. - const oldestCursor = timelineCursor({ id: "oldest", sequence: 1 }); - const current = makeLoadedTimelineState( - [ - userRow({ id: "oldest", sequence: 1 }), - turnSummaryRow({ endSequence: 100, id: "turn-summary", sequence: 10 }), - commandRow({ id: "late-error", sequence: 99 }), - ], - oldestCursor, - 100, - ); - const latestTimeline = makeTimelineResponse( - [userRow({ id: "follow-up", sequence: 101 })], - timelineCursor({ id: "follow-up", sequence: 101 }), - 104, - ); - - const next = mergeLoadedTimelineWithLatest({ - current, - latestTimeline, - surfaceKey: "thread-1:default", - }); - - expect(next.rows.map((row) => row.id)).toEqual([ - "oldest", - "turn-summary", - "late-error", - "follow-up", - ]); - expect(next.olderCursor).toEqual(oldestCursor); - }); - - it("keeps loaded rows when a window's first row is backfilled from below the cut", () => { - // A sequence-cut window names the cut in its cursor, but its first row can - // start under it: the projection backfills the running turn's `turn/started` - // row from wherever that turn began. The window still continues the loaded - // history, so the loaded pages stay. - const inTurnCursor = timelineCursor({ - id: "thread-1:in-turn:60", - sequence: 60, - }); - const current = makeLoadedTimelineState( - [commandRow({ id: "loaded-work", sequence: 40 })], - timelineCursor({ id: "thread-1:in-turn:30", sequence: 30 }), - 59, - ); - const latestTimeline = makeTimelineResponse( - [ - turnSummaryRow({ endSequence: 70, id: "turn-summary", sequence: 20 }), - commandRow({ id: "live-work", sequence: 65 }), - ], - inTurnCursor, - 70, - ); - - const next = mergeLoadedTimelineWithLatest({ - current, - latestTimeline, - surfaceKey: "thread-1:default", - }); - - expect(next.rows.map((row) => row.id)).toEqual([ - "loaded-work", - "turn-summary", - "live-work", - ]); - }); - - it("recovers from a stale cursor with a fresh latest cursor without dropping loaded rows", () => { - const staleCursor = timelineCursor({ id: "stale-cursor", sequence: 1 }); - const freshCursor = timelineCursor({ id: "fresh-cursor", sequence: 40 }); - const olderUser = userRow({ id: "older-user", sequence: 1 }); - const oldTail = userRow({ id: "live-tail", sequence: 20 }); - const updatedTail = { - ...oldTail, - sourceSeqEnd: oldTail.sourceSeqEnd + 1, - text: "updated tail", - }; - const latestTimeline = makeTimelineResponse([updatedTail], freshCursor); - - const next = recoverLoadedTimelineAfterStaleCursor({ - current: makeLoadedTimelineState([olderUser, oldTail], staleCursor), - latestTimeline, - surfaceKey: "thread-1:default", - }); - - expect(next.rows.map((row) => row.id)).toEqual(["older-user", "live-tail"]); - expect(next.rows[1]).toMatchObject({ text: "updated tail" }); - expect(next.olderCursor).toEqual(freshCursor); - }); -}); diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx index 79f94c601e..3190db9f6c 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx @@ -5,15 +5,13 @@ import type { ThreadTimelineResponse, TimelineUserConversationRow, } from "@bb/server-contract"; +import { mergeLatestTimelineRows } from "@bb/client-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { BbHttpError, sdk } from "@/lib/sdk"; -import { OPTIMISTIC_TIMELINE_ROW_ID_PREFIX } from "@/lib/optimistic-timeline-row"; +import { OPTIMISTIC_TIMELINE_ROW_ID_PREFIX } from "@bb/client-core"; import { threadTimelineQueryKey } from "@/hooks/queries/query-keys"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { - mergeLatestTimelineRows, - useThreadTimelineController, -} from "./useThreadTimelineController"; +import { useThreadTimelineController } from "./useThreadTimelineController"; vi.mock("@/lib/sdk", async (importOriginal) => { const actual = await importOriginal(); diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index 421cf8c421..d816de331e 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -1,20 +1,20 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import type { - ThreadTimelineResponse, - TimelinePaginationCursor, - TimelineRow, -} from "@bb/server-contract"; +import { useCallback, useEffect, useState } from "react"; +import type { ThreadTimelineResponse, TimelineRow } from "@bb/server-contract"; +import { + areTimelinePaginationCursorsEqual, + buildLoadedTimelineState, + mergeLoadedTimelineWithLatest, + prependOlderTimelineRows, + recoverLoadedTimelineAfterStaleCursor, + type LoadedTimelineState, +} from "@bb/client-core"; import { useConnectionAwareQueryState } from "@/hooks/queries/connection-aware-query-state"; import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { useThreadTimeline } from "@/hooks/queries/thread-queries"; -import { isOptimisticTimelineRowId } from "@/lib/optimistic-timeline-row"; import { BbHttpError, sdk } from "@/lib/sdk"; -export type ThreadTimelineRowFilter = (row: TimelineRow) => boolean; - -export interface UseThreadTimelineControllerArgs { +interface UseThreadTimelineControllerArgs { enabled?: boolean; - rowFilter?: ThreadTimelineRowFilter; surfaceKey?: string; threadId: string; } @@ -36,439 +36,7 @@ export interface UseThreadTimelineControllerResult { timelineRows: TimelineRow[]; } -type NullableTimelinePaginationCursor = TimelinePaginationCursor | null; - -export interface LoadedTimelineState { - /** Inclusive end of the latest server window already merged into `rows`. */ - latestWindowEndSequence: number | null; - olderCursor: NullableTimelinePaginationCursor; - rows: TimelineRow[]; - surfaceKey: string; -} - -interface BuildLoadedTimelineStateArgs { - latestWindowEndSequence: number | null; - latestRows: TimelineRow[]; - olderCursor: NullableTimelinePaginationCursor; - surfaceKey: string; -} - -interface AreTimelinePaginationCursorsEqualArgs { - left: NullableTimelinePaginationCursor; - right: NullableTimelinePaginationCursor; -} - -export interface MergeLatestTimelineRowsArgs { - latestRows: readonly TimelineRow[]; - latestWindowStartSequence: number; - loadedRows: TimelineRow[]; -} - -interface MergeLatestTimelineRowsResult { - canMerge: boolean; - rows: TimelineRow[]; -} - -interface TimelineRowIdentityEntry { - row: TimelineRow; - signature: string; -} - -interface PreserveTimelineRowIdentityArgs { - nextRows: readonly TimelineRow[]; - previousRows: readonly TimelineRow[]; -} - -interface AreTimelineRowReferencesEqualArgs { - left: readonly TimelineRow[]; - right: readonly TimelineRow[]; -} - -export interface PrependOlderTimelineRowsArgs { - loadedRows: readonly TimelineRow[]; - olderRows: readonly TimelineRow[]; -} - -export interface MergeLoadedTimelineWithLatestArgs { - current: LoadedTimelineState; - latestTimeline: ThreadTimelineResponse; - surfaceKey: string; -} - -export interface RecoverLoadedTimelineAfterStaleCursorArgs { - current: LoadedTimelineState; - latestTimeline: ThreadTimelineResponse; - surfaceKey: string; -} - -interface BuildSurfaceKeyArgs { - rowFilter: ThreadTimelineRowFilter | undefined; - surfaceKey: string | undefined; - threadId: string; -} - -function buildSurfaceKey({ - rowFilter, - surfaceKey, - threadId, -}: BuildSurfaceKeyArgs): string { - if (surfaceKey !== undefined) { - return surfaceKey; - } - return rowFilter === undefined ? threadId : `${threadId}:filtered`; -} - -function filterTimelineRows({ - rowFilter, - rows, -}: { - rowFilter: ThreadTimelineRowFilter | undefined; - rows: readonly TimelineRow[]; -}): TimelineRow[] { - return rowFilter === undefined ? [...rows] : rows.filter(rowFilter); -} - -function filterThreadTimelineResponse({ - response, - rowFilter, -}: { - response: ThreadTimelineResponse; - rowFilter: ThreadTimelineRowFilter | undefined; -}): ThreadTimelineResponse { - if (rowFilter === undefined) { - return response; - } - return { - ...response, - rows: response.rows.filter(rowFilter), - }; -} - -function buildLoadedTimelineState({ - latestWindowEndSequence, - latestRows, - olderCursor, - surfaceKey, -}: BuildLoadedTimelineStateArgs): LoadedTimelineState { - return { - latestWindowEndSequence, - olderCursor, - rows: latestRows, - surfaceKey, - }; -} - -function areTimelinePaginationCursorsEqual({ - left, - right, -}: AreTimelinePaginationCursorsEqualArgs): boolean { - if (left === null || right === null) { - return left === right; - } - return left.anchorSeq === right.anchorSeq && left.anchorId === right.anchorId; -} - -function appendTimelineRowsPreservingOrder( - target: TimelineRow[], - rows: readonly TimelineRow[], -): void { - const seenIds = new Set(target.map((row) => row.id)); - for (const row of rows) { - if (seenIds.has(row.id)) { - continue; - } - seenIds.add(row.id); - target.push(row); - } -} - -function timelineRowIdentitySignature(row: TimelineRow): string { - return [ - row.kind, - row.id, - row.threadId, - row.turnId ?? "", - row.sourceSeqStart, - row.sourceSeqEnd, - row.startedAt, - row.createdAt, - ].join("\u001f"); -} - -function buildTimelineRowIdentityMap( - rows: readonly TimelineRow[], -): ReadonlyMap { - const rowsById = new Map(); - for (const row of rows) { - rowsById.set(row.id, { - row, - signature: timelineRowIdentitySignature(row), - }); - } - return rowsById; -} - -function preserveTimelineRowIdentity({ - nextRows, - previousRows, -}: PreserveTimelineRowIdentityArgs): TimelineRow[] { - const previousRowsById = buildTimelineRowIdentityMap(previousRows); - return nextRows.map((row) => { - const previous = previousRowsById.get(row.id); - if (previous && previous.signature === timelineRowIdentitySignature(row)) { - return previous.row; - } - return row; - }); -} - -function areTimelineRowReferencesEqual({ - left, - right, -}: AreTimelineRowReferencesEqualArgs): boolean { - if (left.length !== right.length) return false; - return left.every((row, index) => row === right[index]); -} - -export function prependOlderTimelineRows({ - loadedRows, - olderRows, -}: PrependOlderTimelineRowsArgs): TimelineRow[] { - const rows: TimelineRow[] = []; - appendTimelineRowsPreservingOrder(rows, olderRows); - appendTimelineRowsPreservingOrder(rows, loadedRows); - return rows; -} - -export function mergeLatestTimelineRows({ - latestRows, - latestWindowStartSequence, - loadedRows: retainedRows, -}: MergeLatestTimelineRowsArgs): MergeLatestTimelineRowsResult { - // Optimistic rows are carried by `latestRows` (they are written into the - // timeline cache) and disappear from it once the server's real row lands. - // Retaining a copy here would survive that swap: an id minted client-side - // never overlaps a server id, so the no-overlap branch below would append - // the server row *after* the stale optimistic one and the message would - // render twice. This is only observable when nothing else overlaps — a - // thread whose first message is being sent, e.g. a fresh side chat. - const loadedRows = retainedRows.some((row) => - isOptimisticTimelineRowId(row.id), - ) - ? retainedRows.filter((row) => !isOptimisticTimelineRowId(row.id)) - : retainedRows; - - const identityPreservedLatestRows = preserveTimelineRowIdentity({ - nextRows: latestRows, - previousRows: loadedRows, - }); - - if (loadedRows.length === 0) { - return { - canMerge: true, - rows: identityPreservedLatestRows, - }; - } - - const latestRowsById = new Map( - identityPreservedLatestRows.map((row) => [row.id, row]), - ); - // The latest response is authoritative only from its raw sequence boundary - // onward. Keep every older row regardless of its kind. A row crossing the - // boundary is kept only when the new projection carries the same identity, - // in which case its value is replaced in place below. - const rowsToRetain = loadedRows.filter( - (row) => - row.sourceSeqEnd < latestWindowStartSequence || - latestRowsById.has(row.id), - ); - const retainedRowIds = new Set(rowsToRetain.map((row) => row.id)); - const loadedCommonIds = rowsToRetain.flatMap((row) => - latestRowsById.has(row.id) ? [row.id] : [], - ); - const latestCommonIds = identityPreservedLatestRows.flatMap((row) => - retainedRowIds.has(row.id) ? [row.id] : [], - ); - if ( - loadedCommonIds.length !== latestCommonIds.length || - loadedCommonIds.some((id, index) => id !== latestCommonIds[index]) - ) { - // The two projections disagree about row order. There is no unambiguous - // splice, so the caller must rebuild from the authoritative latest page. - return { canMerge: false, rows: identityPreservedLatestRows }; - } - - // New latest rows belong immediately before their next shared row. This - // preserves the old position of a straddling row (and therefore older rows - // around it), while still honoring server order for newly projected rows. - const rowsBeforeSharedId = new Map(); - let pendingRows: TimelineRow[] = []; - for (const row of identityPreservedLatestRows) { - if (!retainedRowIds.has(row.id)) { - pendingRows.push(row); - continue; - } - if (pendingRows.length > 0) { - rowsBeforeSharedId.set(row.id, pendingRows); - pendingRows = []; - } - } - - const rows: TimelineRow[] = []; - for (const row of rowsToRetain) { - const rowsBefore = rowsBeforeSharedId.get(row.id); - if (rowsBefore) { - rows.push(...rowsBefore); - } - rows.push(latestRowsById.get(row.id) ?? row); - } - rows.push(...pendingRows); - if (areTimelineRowReferencesEqual({ left: loadedRows, right: rows })) { - return { - canMerge: true, - rows: loadedRows, - }; - } - - return { - canMerge: true, - rows, - }; -} - -/** - * First event sequence a window covers. Every pagination cursor names the first - * sequence the page that issued it covered — that is what makes older pages - * chain — so the cursor is the exact lower bound of the window it arrived with. - * No cursor means the page reached the start of the thread. - */ -function timelineWindowStartSequence(timeline: ThreadTimelineResponse): number { - return timeline.timelinePage.olderCursor?.anchorSeq ?? 0; -} - -/** - * Whether the fresh window continues the loaded one, in raw event sequences. - * - * Rows cannot answer this, in three separate ways: - * - * - Most events never become a row — `turn/completed`, token-usage and - * rate-limit updates — so the distance from the last loaded row to the next - * window is routinely non-zero while the history is in fact continuous. A - * follow-up submitted on a budgeted thread lands exactly here: the prompt - * opens the next window one sequence past a `turn/completed` that is not a - * row. - * - Rows are ordered by where they start, not where they end, so the last row - * is not the one that reaches furthest. A turn summary spans its whole turn - * while shorter rows that begin later sort after it. - * - A window's first row can start *below* the window, because the projection - * backfills a turn's `turn/started` row from under the cut. - * - * Each shape reports a break that is not there, and the caller answers a break - * by dropping every loaded page — the timeline visibly truncates to the newest - * window and refills as auto-load pages it back. The sequences the server - * states outright have none of these failure modes. - */ -function timelineWindowsAreContiguous( - current: LoadedTimelineState, - latestTimeline: ThreadTimelineResponse, -): boolean { - return ( - current.latestWindowEndSequence !== null && - latestTimeline.maxSeq >= current.latestWindowEndSequence && - timelineWindowStartSequence(latestTimeline) <= - current.latestWindowEndSequence + 1 - ); -} - -function mergeLoadedTimelineOlderCursor( - current: NullableTimelinePaginationCursor, - latest: NullableTimelinePaginationCursor, -): NullableTimelinePaginationCursor { - if (current === null || latest === null) { - return null; - } - return latest.anchorSeq <= current.anchorSeq ? latest : current; -} - -export function mergeLoadedTimelineWithLatest({ - current, - latestTimeline, - surfaceKey, -}: MergeLoadedTimelineWithLatestArgs): LoadedTimelineState { - if ( - current.surfaceKey !== surfaceKey || - !timelineWindowsAreContiguous(current, latestTimeline) - ) { - return buildLoadedTimelineState({ - latestWindowEndSequence: latestTimeline.maxSeq, - latestRows: latestTimeline.rows, - olderCursor: latestTimeline.timelinePage.olderCursor, - surfaceKey, - }); - } - - const latestMerge = mergeLatestTimelineRows({ - latestRows: latestTimeline.rows, - latestWindowStartSequence: timelineWindowStartSequence(latestTimeline), - loadedRows: current.rows, - }); - if (!latestMerge.canMerge) { - return buildLoadedTimelineState({ - latestWindowEndSequence: latestTimeline.maxSeq, - latestRows: latestTimeline.rows, - olderCursor: latestTimeline.timelinePage.olderCursor, - surfaceKey, - }); - } - - return { - ...current, - latestWindowEndSequence: latestTimeline.maxSeq, - olderCursor: mergeLoadedTimelineOlderCursor( - current.olderCursor, - latestTimeline.timelinePage.olderCursor, - ), - rows: latestMerge.rows, - }; -} - -export function recoverLoadedTimelineAfterStaleCursor({ - current, - latestTimeline, - surfaceKey, -}: RecoverLoadedTimelineAfterStaleCursorArgs): LoadedTimelineState { - if (current.surfaceKey !== surfaceKey) { - return buildLoadedTimelineState({ - latestWindowEndSequence: latestTimeline.maxSeq, - latestRows: latestTimeline.rows, - olderCursor: latestTimeline.timelinePage.olderCursor, - surfaceKey, - }); - } - - const latestMerge = mergeLatestTimelineRows({ - latestRows: latestTimeline.rows, - latestWindowStartSequence: timelineWindowStartSequence(latestTimeline), - loadedRows: current.rows, - }); - if (!latestMerge.canMerge) { - return buildLoadedTimelineState({ - latestWindowEndSequence: latestTimeline.maxSeq, - latestRows: latestTimeline.rows, - olderCursor: latestTimeline.timelinePage.olderCursor, - surfaceKey, - }); - } - - return { - latestWindowEndSequence: latestTimeline.maxSeq, - olderCursor: latestTimeline.timelinePage.olderCursor, - rows: latestMerge.rows, - surfaceKey, - }; -} - -export function isStaleTimelinePaginationCursorError(error: Error): boolean { +function isStaleTimelinePaginationCursorError(error: Error): boolean { return ( error instanceof BbHttpError && error.status === 400 && @@ -478,7 +46,6 @@ export function isStaleTimelinePaginationCursorError(error: Error): boolean { export function useThreadTimelineController({ enabled = true, - rowFilter, surfaceKey: explicitSurfaceKey, threadId, }: UseThreadTimelineControllerArgs): UseThreadTimelineControllerResult { @@ -488,11 +55,7 @@ export function useThreadTimelineController({ enabled, refetchOnMount: true, }); - const surfaceKey = buildSurfaceKey({ - rowFilter, - surfaceKey: explicitSurfaceKey, - threadId, - }); + const surfaceKey = explicitSurfaceKey ?? threadId; const [loadedTimeline, setLoadedTimeline] = useState( () => buildLoadedTimelineState({ @@ -504,15 +67,7 @@ export function useThreadTimelineController({ ); const [isLoadingOlderTimelineRows, setIsLoadingOlderTimelineRows] = useState(false); - const latestTimeline = useMemo(() => { - if (!latestTimelineQuery.data) { - return undefined; - } - return filterThreadTimelineResponse({ - response: latestTimelineQuery.data, - rowFilter, - }); - }, [latestTimelineQuery.data, rowFilter]); + const latestTimeline = latestTimelineQuery.data; useEffect(() => { if (!latestTimeline) { @@ -561,10 +116,7 @@ export function useThreadTimelineController({ beforeAnchorSeq: String(nextOlderCursor.anchorSeq), threadId, }); - const olderRows = filterTimelineRows({ - rowFilter, - rows: response.rows, - }); + const olderRows = [...response.rows]; setLoadedTimeline((current) => { if (current.surfaceKey !== surfaceKey) { return current; @@ -592,12 +144,8 @@ export function useThreadTimelineController({ } const latestTimelineResult = await refetchLatestTimeline(); - const recoveredLatestTimeline = latestTimelineResult.data - ? filterThreadTimelineResponse({ - response: latestTimelineResult.data, - rowFilter, - }) - : latestTimeline; + const recoveredLatestTimeline = + latestTimelineResult.data ?? latestTimeline; setLoadedTimeline((current) => { if (current.surfaceKey !== surfaceKey) { return current; @@ -623,7 +171,6 @@ export function useThreadTimelineController({ latestTimeline, nextOlderCursor, refetchLatestTimeline, - rowFilter, surfaceKey, threadId, ]); diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx index b29581ed8c..d0a532513b 100644 --- a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx +++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx @@ -105,6 +105,7 @@ function TocHost({ hostPaddingX = 0, hostWidth = 1_200, loadOlderTimelineRows = () => {}, + onNavigateToRow, threadId = "thr_toc_test", timelineRows, }: { @@ -113,6 +114,7 @@ function TocHost({ hostPaddingX?: number; hostWidth?: number; loadOlderTimelineRows?: () => void | Promise; + onNavigateToRow?: (rowId: string) => void; threadId?: string; timelineRows: readonly TimelineRow[]; }) { @@ -136,6 +138,7 @@ function TocHost({ timelineRows={timelineRows} hasOlderTimelineRows={hasOlderTimelineRows} loadOlderTimelineRows={loadOlderTimelineRows} + onNavigateToRow={onNavigateToRow} />
); @@ -687,6 +690,45 @@ describe("ThreadTableOfContents", () => { expect(screen.getByText("Agent messages")).not.toBeNull(); }); + it("merges live timeline messages into the cached full outline", async () => { + setOutline([ + { + id: "row_user_1", + role: "user", + preview: "First cached question", + attachmentSummary: null, + }, + { + id: "row_user_2", + role: "user", + preview: "Second cached question", + attachmentSummary: null, + }, + { + id: "row_user_3", + role: "user", + preview: "Stale third question", + attachmentSummary: null, + }, + ]); + + render( + , + ); + openTocPanel(); + + expect(await screen.findByText("First cached question")).not.toBeNull(); + expect( + screen.getByText("Loaded after client-side navigation 3"), + ).not.toBeNull(); + expect( + screen.getByText("Loaded after client-side navigation 4"), + ).not.toBeNull(); + expect(screen.queryByText("Stale third question")).toBeNull(); + }); + it("renders an agent-to-agent message source as a thread mention", async () => { setOutline([ { @@ -795,6 +837,7 @@ describe("ThreadTableOfContents", () => { it("scrolls straight to a message already loaded in the window", async () => { scrollElement.appendChild(timelineRowElement("u2")); const loadOlder = vi.fn(); + const onNavigateToRow = vi.fn(); setOutline([ { id: "u1", @@ -821,12 +864,14 @@ describe("ThreadTableOfContents", () => { timelineRows={[]} hasOlderTimelineRows loadOlderTimelineRows={loadOlder} + onNavigateToRow={onNavigateToRow} />, ); openTocPanel(); fireEvent.click(await screen.findByText("Loaded question")); await waitFor(() => expect(scrollElementIntoView).toHaveBeenCalledTimes(1)); + expect(onNavigateToRow).toHaveBeenCalledWith("u2"); expect(loadOlder).not.toHaveBeenCalled(); }); diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx index 8bcb7c4396..6f813f87ef 100644 --- a/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx +++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx @@ -38,6 +38,8 @@ interface ThreadTableOfContentsProps { hasOlderTimelineRows: boolean; /** Loads the next older timeline page; awaited while jumping to an unloaded row. */ loadOlderTimelineRows: () => void | Promise; + /** Lets timeline windowing mount an offscreen destination before scrolling. */ + onNavigateToRow?: (rowId: string) => void; } // Matches `@container scroll-overlay (min-width: 56rem)` in app.css. @@ -102,6 +104,20 @@ function outlineItemToTocItem(item: ThreadConversationOutlineItem): TocItem { }; } +function mergeLiveTocItems( + outlineItems: readonly TocItem[], + timelineItems: readonly TocItem[], +): TocItem[] { + const timelineItemsById = new Map( + timelineItems.map((item) => [item.id, item]), + ); + const outlineItemIds = new Set(outlineItems.map((item) => item.id)); + return [ + ...outlineItems.map((item) => timelineItemsById.get(item.id) ?? item), + ...timelineItems.filter((item) => !outlineItemIds.has(item.id)), + ]; +} + export function selectTocRailItems({ activeId, items, @@ -224,9 +240,10 @@ function TocItemPreview({ /** * Builds the user/agent item lists for the minimap. Prefers the full - * conversation outline (the whole thread, independent of pagination); falls - * back to the loaded timeline window so the minimap still renders on first - * paint and in environments without the outline endpoint (e.g. stories). + * conversation outline (the whole thread, independent of pagination), then + * overlays the loaded timeline window so the current turn stays live between + * full-outline refreshes. Falls back to the timeline alone on first paint and + * in environments without the outline endpoint (e.g. stories). */ function useConversationTocItems({ outlineItems, @@ -270,7 +287,19 @@ function useConversationTocItems({ return { agentItems, userItems }; }, [timelineRows]); - return outlineTocItems ?? timelineTocItems; + return useMemo(() => { + if (!outlineTocItems) return timelineTocItems; + return { + agentItems: mergeLiveTocItems( + outlineTocItems.agentItems, + timelineTocItems.agentItems, + ), + userItems: mergeLiveTocItems( + outlineTocItems.userItems, + timelineTocItems.userItems, + ), + }; + }, [outlineTocItems, timelineTocItems]); } /** @@ -505,6 +534,7 @@ export function ThreadTableOfContents({ timelineRows, hasOlderTimelineRows, loadOlderTimelineRows, + onNavigateToRow, }: ThreadTableOfContentsProps) { const bottomAnchor = useBottomAnchoredScroll(); const [rootElement, setRootElement] = useState(null); @@ -641,6 +671,7 @@ export function ThreadTableOfContents({ options: { block: "start", inline: "nearest" }, }); }; + onNavigateToRow?.(id); let row = findTimelineRowElement(getScrollElement(), id); if (row) { @@ -687,7 +718,7 @@ export function ThreadTableOfContents({ setPendingJumpId(null); } }, - [bottomAnchor], + [bottomAnchor, onNavigateToRow], ); if (userItems.length < TOC_MIN_USER_MESSAGES) { diff --git a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx index b65142ef72..e354727d8a 100644 --- a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx +++ b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx @@ -81,7 +81,7 @@ const OTHER_OPTION_LABEL = "Other…"; const USER_QUESTION_FREE_TEXT_MIN_HEIGHT = 84; const USER_QUESTION_FREE_TEXT_MAX_HEIGHT = 158; -export type QuestionShortcutChoice = +type QuestionShortcutChoice = | { kind: "option"; value: string } | { kind: "other" } | null; diff --git a/apps/app/src/components/thread/user-questions/useStickyFooterAvailableHeight.ts b/apps/app/src/components/thread/user-questions/useStickyFooterAvailableHeight.ts index 4c9574a2b1..afa400654a 100644 --- a/apps/app/src/components/thread/user-questions/useStickyFooterAvailableHeight.ts +++ b/apps/app/src/components/thread/user-questions/useStickyFooterAvailableHeight.ts @@ -4,7 +4,7 @@ import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll- /** Marks the sticky footer wrapper of a bottom-anchored scroll body. */ export const SCROLL_FOOTER_ATTRIBUTE = "data-scroll-footer"; /** Marks each footer element whose height follows this hook. */ -export const STICKY_FOOTER_FLEX_ATTRIBUTE = "data-sticky-footer-flex"; +const STICKY_FOOTER_FLEX_ATTRIBUTE = "data-sticky-footer-flex"; /** * Measures how tall `ref` may grow while the sticky footer that contains it diff --git a/apps/app/src/components/thread/user-questions/user-question-form-state.ts b/apps/app/src/components/thread/user-questions/user-question-form-state.ts index 4076957ffa..e32872325f 100644 --- a/apps/app/src/components/thread/user-questions/user-question-form-state.ts +++ b/apps/app/src/components/thread/user-questions/user-question-form-state.ts @@ -21,7 +21,7 @@ export interface QuestionAnswerState { export type QuestionFormState = Record; -export function questionHasOptions( +function questionHasOptions( question: PendingInteractionUserQuestionQuestion, ): boolean { return (question.options?.length ?? 0) > 0; diff --git a/apps/app/src/components/tools/Automations.stories.tsx b/apps/app/src/components/tools/Automations.stories.tsx index 3f2f250500..72c0685a00 100644 --- a/apps/app/src/components/tools/Automations.stories.tsx +++ b/apps/app/src/components/tools/Automations.stories.tsx @@ -1,35 +1,21 @@ -import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; -import { Link, useLocation, useNavigate } from "react-router-dom"; +import { useState, type CSSProperties, type ReactNode } from "react"; import { AutomationDetailView } from "bb-plugin-automations/detail-view"; import { AutomationOverviewView, type AutomationCollectionMode, } from "bb-plugin-automations/overview-view"; import type { - AutomationExecutionOptionsResponse, AutomationResponse, AutomationRunResponse, AutomationsOverviewResponse, } from "bb-plugin-automations/rpc-types"; import { ResourceListState } from "@bb/shared-ui/resource-list"; -import { AppBreadcrumbs } from "@/components/layout/AppBreadcrumbs"; -import { resolveAutomationBreadcrumbs } from "@/components/tools/tools-navigation"; export default { title: "Automations", }; const noop = () => {}; -const executionOptions: AutomationExecutionOptionsResponse = { - models: [ - { - id: "claude:claude-opus-5", - model: "claude-opus-5", - displayName: "Opus 5", - }, - ], - permissionModes: ["accept-edits", "auto", "full"], -}; const now = new Date(2027, 0, 15, 9).getTime(); function automation( @@ -52,6 +38,7 @@ function automation( prompt: `Run ${name.toLowerCase()}.`, providerId: "claude", model: "claude-opus-5", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", workspace: { type: "personal" } }, }, @@ -189,81 +176,6 @@ export function BrowseTemplates() { return ; } -const AUTOMATIONS_ROOT = "/plugins/automations/automations"; -const AUTOMATION_DETAIL = `${AUTOMATIONS_ROOT}/proj_personal/nightly-digest`; -const AUTOMATION_MISSING = `${AUTOMATIONS_ROOT}/proj_personal/missing-automation`; - -function BreadcrumbFlowHarness() { - const location = useLocation(); - const loadedLabel = - location.pathname === AUTOMATION_DETAIL ? "Nightly digest" : null; - const breadcrumbs = resolveAutomationBreadcrumbs( - location.pathname, - loadedLabel, - ); - - return ( - -
-
- {breadcrumbs ? ( - - ) : null} -
- -

- Current route: {location.pathname} -

-
-

- Narrow detail header -

-
- -
-
-
-
- ); -} - -export function BreadcrumbNavigation() { - const navigate = useNavigate(); - useEffect(() => { - navigate(`${AUTOMATIONS_ROOT}/browse`, { replace: true }); - }, [navigate]); - return ; -} - const DETAIL_AUTOMATION = automation("nightly-digest", "Nightly digest", { trigger: { triggerType: "schedule", @@ -275,6 +187,7 @@ const DETAIL_AUTOMATION = automation("nightly-digest", "Nightly digest", { prompt: "Summarize yesterday's commits and open pull requests.", providerId: "claude", model: "claude-opus-5[1m]", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", workspace: { type: "personal" } }, }, @@ -295,6 +208,7 @@ const PROJECT_AUTOMATION: AutomationResponse = { prompt: "Summarize yesterday's commits and open pull requests.", providerId: "claude", model: "claude-opus-5[1m]", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", @@ -321,6 +235,7 @@ const PROVIDER_AUTOMATIONS = [ prompt: "Summarize yesterday's commits and open pull requests.", providerId: "codex", model: "gpt-5.6-sol", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", workspace: { type: "personal" } }, }, @@ -334,6 +249,7 @@ const PROVIDER_AUTOMATIONS = [ prompt: "Summarize yesterday's commits and open pull requests.", providerId: "pi", model: "pi-model", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", workspace: { type: "personal" } }, }, @@ -347,6 +263,7 @@ const PROVIDER_AUTOMATIONS = [ prompt: "Summarize yesterday's commits and open pull requests.", providerId: "acp-cursor", model: "cursor-small", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", workspace: { type: "personal" } }, }, @@ -360,6 +277,7 @@ const PROVIDER_AUTOMATIONS = [ prompt: "Summarize yesterday's commits and open pull requests.", providerId: "custom-provider", model: "custom-model-v2", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", workspace: { type: "personal" } }, }, @@ -521,9 +439,6 @@ function AutomationDetail({ }} actionPending={false} editing={false} - executionOptions={executionOptions} - permissionModes={["accept-edits", "auto", "full"]} - executionOptionsError={null} onToggle={noop} onEdit={noop} onCancelEdit={noop} diff --git a/apps/app/src/components/tools/ExtensionsDetailStates.stories.tsx b/apps/app/src/components/tools/ExtensionsDetailStates.stories.tsx index 227954d6b1..07e9175994 100644 --- a/apps/app/src/components/tools/ExtensionsDetailStates.stories.tsx +++ b/apps/app/src/components/tools/ExtensionsDetailStates.stories.tsx @@ -36,14 +36,12 @@ import { PluginProvenancePill, } from "@/components/tools/PluginDetail"; import { - BbLogo, ProviderLogo, SkillProvenanceTooltip, } from "@/components/tools/SkillsCollection"; -import { - SkillDetailView, - SkillOwnershipBadge, -} from "@/components/tools/SkillDetailView"; +import { BbLogo } from "@/components/ui/bb-logo"; +import { ProvenancePill } from "@/components/tools/ProvenancePill"; +import { SkillDetailView } from "@/components/tools/SkillDetailView"; /** * Every state each tool type's detail page can be in, rendered as the real @@ -1257,17 +1255,14 @@ export function ResourceControlStates() { + } meaning="A skill that ships with bb." /> getSettingsRoutePath("appearance"), ), + ...namedSlotItems( + pluginId, + slots.sourceCodeRenderers, + "source-code-renderer", + "Replaces how source code is displayed everywhere in the app.", + ), + ...namedSlotItems( + pluginId, + slots.diffRenderers, + "diff-renderer", + "Replaces how diffs are displayed everywhere in the app.", + ), ...namedSlotItems( pluginId, slots.threadPanelActions, @@ -504,19 +488,16 @@ function PluginRuntimeStatusAlert({ runtimeStatus, onReload, reloadPending, - reloadable, }: { plugin: PluginListItem; runtimeStatus: PluginRuntimeStatusPresentation; onReload: () => void; reloadPending: boolean; - reloadable?: boolean; }) { const canReload = - reloadable ?? - (plugin.status === "error" || - plugin.status === "degraded" || - (plugin.status === "needs-configuration" && !plugin.hasSettings)); + plugin.status === "error" || + plugin.status === "degraded" || + (plugin.status === "needs-configuration" && !plugin.hasSettings); const condition = plugin.status === "needs-configuration" && plugin.statusDetail?.trim() ? plugin.statusDetail @@ -571,11 +552,9 @@ function PluginRuntimeStatusAlert({ export function PluginHealthBanner({ plugin, runtimeStatus, - reloadable, }: { plugin: PluginListItem; runtimeStatus: PluginRuntimeStatusPresentation | null; - reloadable?: boolean; }) { const queryClient = useQueryClient(); const reload = useMutation({ @@ -594,7 +573,6 @@ export function PluginHealthBanner({ plugin={plugin} runtimeStatus={runtimeStatus} reloadPending={reload.isPending} - reloadable={reloadable} onReload={() => reload.mutate()} /> ); @@ -673,12 +651,7 @@ export function PluginSchedules({ plugin }: { plugin: PluginListItem }) { {plugin.schedules.map((schedule) => ( - } + glyph={} name={schedule.name} detail={ schedule.lastError ?? diff --git a/apps/app/src/components/tools/PluginDetail.tsx b/apps/app/src/components/tools/PluginDetail.tsx index aa4f3e69a9..0f3d65a05d 100644 --- a/apps/app/src/components/tools/PluginDetail.tsx +++ b/apps/app/src/components/tools/PluginDetail.tsx @@ -34,6 +34,7 @@ import { PluginLogo, } from "@/components/plugin/management/plugin-ui"; import { pluginRuntimeStatusPresentation } from "@/components/plugin/management/plugin-status"; +import { ExperimentalUrlLink } from "@/components/plugin/ExperimentalUrlLink"; import { PluginHealthBanner, PluginIncludes, @@ -78,6 +79,18 @@ export function pluginRemovalLabel(plugin: PluginListItem): string { return pluginIsLocalSource(plugin) ? "Remove from bb" : "Uninstall"; } +/** + * What a removal deletes, matching the server's `remove`: settings, secrets, + * and schedules go with the registration on every source kind; only managed + * git/npm files are deleted from disk. Moving a local plugin is an install of + * the new path, which keeps that configuration. + */ +export function pluginRemovalDescription(plugin: PluginListItem): string { + return pluginIsLocalSource(plugin) + ? `Remove "${plugin.id}" from bb and delete its settings, secrets, and schedules? Its source files stay on disk. To move it to another directory, install the new path instead; that keeps its settings.` + : `Uninstall "${plugin.id}" and delete its managed files, settings, secrets, and schedules?`; +} + function PluginPath({ path }: { path: string }) { const { copied, copy } = useClipboardCopy({ text: path, @@ -114,7 +127,7 @@ function PluginPath({ path }: { path: string }) { * The repository link's text: the URL without its scheme, so a GitHub entry * reads as `github.com/owner/repo` and a reader knows the destination. */ -export function repositoryLinkLabel(url: string): string { +function repositoryLinkLabel(url: string): string { return url.replace(/^https?:\/\//u, "").replace(/\/+$/u, ""); } @@ -147,14 +160,12 @@ export function CatalogPluginDetail({ {entry.author.url === null ? ( entry.author.name ) : ( - {entry.author.name} - + )} )} @@ -211,39 +222,10 @@ export function CatalogPluginDetailBanner({ ); } -/** - * The installed plugin page's highest-priority runtime condition. - * - * These render outside ToolsScrollPage rather than inside the detail column. - * Only present-tense operational health belongs in this selector; acquisition - * compatibility uses CatalogPluginDetailBanner, while release opportunities - * and history stay with the version controls in the detail page. - */ -export type PluginDetailBannerKind = - | "failed" - | "degraded" - | "incompatible" - | "missing" - | "needs-configuration"; - -export function pluginDetailBannerKind( - plugin: PluginListItem, - hasFrontendFailure: boolean, -): PluginDetailBannerKind | null { - if (!plugin.enabled) return null; - if (plugin.status === "error") return "failed"; - if (plugin.status === "degraded") return "degraded"; - if (plugin.status === "incompatible") return "incompatible"; - if (plugin.status === "missing") return "missing"; - if (plugin.status === "needs-configuration") return "needs-configuration"; - if (hasFrontendFailure) return "failed"; - return null; -} - function pluginHealthBannerState( plugin: PluginListItem, frontendDiagnostic: PluginFrontendDiagnostic | undefined, -): { plugin: PluginListItem; reloadable?: boolean } | null { +): { plugin: PluginListItem } | null { if (!plugin.enabled) return null; if (pluginRuntimeStatusPresentation(plugin) !== null) return { plugin }; @@ -281,7 +263,6 @@ export function PluginDetailBanners({ plugin }: { plugin: PluginListItem }) { ); } @@ -415,12 +396,10 @@ export function PluginDetail({ /> } overflowMenu={ - overflowItems.length > 0 ? ( - - ) : undefined + } > diff --git a/apps/app/src/components/tools/SkillDetailView.tsx b/apps/app/src/components/tools/SkillDetailView.tsx index c4bf399231..319bc43368 100644 --- a/apps/app/src/components/tools/SkillDetailView.tsx +++ b/apps/app/src/components/tools/SkillDetailView.tsx @@ -21,18 +21,18 @@ import { FilePreview } from "@/components/secondary-panel/FilePreview.js"; import { ProvenancePill } from "@/components/tools/ProvenancePill"; import { useClipboardCopy } from "@/lib/clipboard"; -export type SkillDetailTitleBadge = { +type SkillDetailTitleBadge = { label: string; tooltip: ReactNode; accessibleLabel?: string; }; -export type SkillDetailContentState = +type SkillDetailContentState = | { kind: "loading" } | { kind: "error"; message: string; onRetry: () => void } | { kind: "ready"; content: string }; -export interface SkillDetailViewProps { +interface SkillDetailViewProps { leading?: ReactNode; title: string; path: string; @@ -45,29 +45,9 @@ export interface SkillDetailViewProps { selectedPath: string; onSelectFile: (path: string) => void; contentState: SkillDetailContentState; - contentActions?: ReactNode; - editor?: ReactNode; footer?: ReactNode; } -export function SkillOwnershipBadge({ - label, - tooltip, - accessibleLabel, -}: { - label: string; - tooltip: ReactNode; - accessibleLabel?: string; -}) { - return ( - - ); -} - function SkillPath({ path, href }: { path: string; href?: string }) { const { copied, copy } = useClipboardCopy({ text: path, @@ -252,8 +232,6 @@ export function SkillDetailView({ selectedPath, onSelectFile, contentState, - contentActions, - editor, footer, }: SkillDetailViewProps) { const directoryPath = getSkillDirectoryPath(path); @@ -261,7 +239,7 @@ export function SkillDetailView({ const selectedFileIsMarkdown = selectedPath.toLowerCase().endsWith(".md"); const titleMeta = titleBadge === undefined ? undefined : ( - - {files.length > 1 && editor === undefined ? ( + {files.length > 1 ? ( ) : null} - - {editor ?? - (contentState.kind === "loading" ? ( - + {contentState.kind === "loading" ? ( + + Loading {selectedDisplayPath}… + + ) : contentState.kind === "error" ? ( + +
- Loading {selectedDisplayPath}… - - ) : contentState.kind === "error" ? ( - +

{contentState.message}

+
+ -
- ) : ( - - ))} + Retry + +
+ ) : ( + + )}
{footer} diff --git a/apps/app/src/components/tools/SkillsCollection.tsx b/apps/app/src/components/tools/SkillsCollection.tsx index e20b36c634..8a095c9e34 100644 --- a/apps/app/src/components/tools/SkillsCollection.tsx +++ b/apps/app/src/components/tools/SkillsCollection.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import type { SkillProvider, SkillSummary } from "@bb/server-contract"; -import bbLogoUrl from "../../../../../assets/bb-logo.svg"; import { ResourceInfiniteScrollSentinel, useResourceInfiniteItems, @@ -20,6 +19,7 @@ import { ResourceToolbar, } from "@bb/shared-ui/resource-list"; import { cn } from "@bb/shared-ui/lib/utils"; +import { BbLogo } from "@/components/ui/bb-logo"; import { ConfirmDeleteDialog, ConfirmDeleteDialogContent, @@ -47,6 +47,11 @@ const RESOURCE_SKILL_SOURCE_FILTERS: readonly ResourceSkillSourceFilter[] = [ "user", ]; +const SOURCE_FILTER_OPTIONS = RESOURCE_SKILL_SOURCE_FILTERS.map((source) => ({ + id: source, + label: skillSourceFilterLabel(source), +})); + /** * Names a provider the way the rest of the app does: the server's display name * first. The icon's aria label is a per-tier fallback — every unknown `acp-*` @@ -132,17 +137,6 @@ export function ProviderLogo({ ); } -export function BbLogo({ className = "size-4" }: { className?: string }) { - return ( - - ); -} - export function SkillProvenanceTooltip({ prefix, providerId, @@ -313,7 +307,7 @@ function SkillRow({ ); } -export interface SkillsOverviewProps { +interface SkillsOverviewProps { skills: readonly SkillSummary[]; /** * Provider display names from the server roster. Provider ids are @@ -326,8 +320,6 @@ export interface SkillsOverviewProps { query?: string; activeMode?: SkillsCollectionMode; browseContent?: ReactNode; - /** Unused since the mode tabs moved to the Extensions top nav. */ - onModeChange?: (mode: SkillsCollectionMode) => void; /** Opens the composer to create a skill, optionally seeded with a full prompt. */ onCreateSkill: (prompt?: string) => void; onSelectSkill: (skill: SkillSummary) => void; @@ -423,14 +415,6 @@ export function SkillsOverview({ !providerCounts.has(provider) && !providerFilters.includes(provider), })); }, [providerCounts, providerDisplayNames, providerFilters]); - const sourceOptions = useMemo( - () => - RESOURCE_SKILL_SOURCE_FILTERS.map((source) => ({ - id: source, - label: skillSourceFilterLabel(source), - })), - [], - ); useEffect(() => { if (sortMode === "provider" && providerBucketCount <= 1) { setSortMode("alpha"); @@ -593,7 +577,7 @@ export function SkillsOverview({ { id: "type", label: "Type", - options: sourceOptions, + options: SOURCE_FILTER_OPTIONS, selectedValues: sourceFilters, onChange: (values) => setSourceFilters( @@ -638,7 +622,7 @@ export function SkillsOverview({ ); } -export interface SkillDetailDialogViewProps { +interface SkillDetailDialogViewProps { skill: SkillSummary | null; /** See {@link SkillsOverviewProps.providerDisplayNames}. */ providerDisplayNames: ProviderDisplayNames; diff --git a/apps/app/src/components/tools/SkillsLibrary.tsx b/apps/app/src/components/tools/SkillsLibrary.tsx index 757e674d11..39868e2c4f 100644 --- a/apps/app/src/components/tools/SkillsLibrary.tsx +++ b/apps/app/src/components/tools/SkillsLibrary.tsx @@ -25,7 +25,7 @@ import { } from "@/components/tools/SkillsCollection"; import { useSystemProviders } from "@/hooks/queries/system-queries"; import { isSkillEditable } from "@/components/tools/skill-taxonomy"; -import { CREATE_SKILL_PROMPT } from "@/lib/create-resource-prompts"; +import { CREATE_SKILL_PROMPT } from "@bb/client-core"; import { buildRegistrySkillReferencePrompt, fetchRegistrySkillDetail, diff --git a/apps/app/src/components/tools/ToolsSidebar.tsx b/apps/app/src/components/tools/ToolsSidebar.tsx index 55c28485ca..7cfc2062c1 100644 --- a/apps/app/src/components/tools/ToolsSidebar.tsx +++ b/apps/app/src/components/tools/ToolsSidebar.tsx @@ -8,8 +8,8 @@ import { } from "@/components/sidebar/SectionSidebar"; import { resolveToolsActivePage, + TOOLS_NAV_ITEMS, TOOLS_PAGES, - TOOLS_SECTIONS, } from "./tools-navigation"; /** @@ -48,30 +48,25 @@ export function ToolsSidebar({ showTopReserve={showTopReserve} testIdPrefix="tools" > - {Object.values(TOOLS_SECTIONS) - .sort((left, right) => - // Plugins first, matching TOOLS_PAGES order. - left.id === "plugins" ? -1 : right.id === "plugins" ? 1 : 0, - ) - .map((section, index) => ( -
0 ? "mt-4" : undefined}> - {section.label} -
- {TOOLS_PAGES.filter((page) => page.section === section.id).map( - (page) => ( - - - - ), - )} -
+ {TOOLS_NAV_ITEMS.map((section, index) => ( +
0 ? "mt-4" : undefined}> + {section.label} +
+ {TOOLS_PAGES.filter((page) => page.section === section.id).map( + (page) => ( + + + + ), + )}
- ))} +
+ ))} ); } diff --git a/apps/app/src/components/tools/automation-overview.test.tsx b/apps/app/src/components/tools/automation-overview.test.tsx index 311c92cc04..4689ee2c28 100644 --- a/apps/app/src/components/tools/automation-overview.test.tsx +++ b/apps/app/src/components/tools/automation-overview.test.tsx @@ -29,6 +29,7 @@ const INSTALLED_AUTOMATIONS: AutomationsOverviewResponse["automations"] = [ prompt: "Summarize yesterday's commits.", providerId: "claude", model: "claude-opus-5", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", workspace: { type: "personal" } }, }, diff --git a/apps/app/src/components/tools/detail-page-recipes.test.tsx b/apps/app/src/components/tools/detail-page-recipes.test.tsx index fa854d966a..8dd80447d1 100644 --- a/apps/app/src/components/tools/detail-page-recipes.test.tsx +++ b/apps/app/src/components/tools/detail-page-recipes.test.tsx @@ -23,13 +23,73 @@ import { PERSONAL_PROJECT_ID } from "@bb/domain"; import type { SkillSummary } from "@bb/server-contract"; import type { AgentExecutionUpdate, - AutomationExecutionOptionsResponse, AutomationResponse, } from "bb-plugin-automations/rpc-types"; +import type { + ExperimentalPermissionModePickerProps, + ExperimentalProviderModelPickerProps, +} from "@get-bb/plugin-sdk/app"; import { AutomationDetailView as AutomationDetailViewBase, AutomationRunStatusIndicator, } from "bb-plugin-automations/detail-view"; + +vi.mock("@get-bb/plugin-sdk/app", async (importOriginal) => ({ + ...(await importOriginal()), + experimental_ProviderModelPicker: ({ + value, + onChange, + routing, + disabled, + }: ExperimentalProviderModelPickerProps) => ( + + ), + experimental_PermissionModePicker: ({ + providerId, + value, + onChange, + disabled, + }: ExperimentalPermissionModePickerProps) => ( + + ), +})); import { EMPTY_PLUGIN_UPDATE_STATE, type PluginListItem, @@ -699,6 +759,7 @@ const AUTOMATION: AutomationResponse = { prompt: "Summarize yesterday's commits.", providerId: "claude", model: "claude-opus-5", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", workspace: { type: "personal" } }, }, @@ -714,48 +775,19 @@ const AUTOMATION: AutomationResponse = { updatedAt: 1_700_000_000_000, }; -const AUTOMATION_EXECUTION_OPTIONS: AutomationExecutionOptionsResponse = { - models: [ - { - id: "claude:claude-opus-5", - model: "claude-opus-5", - displayName: "Claude-Opus-5", - }, - { - id: "claude:claude-sonnet-5", - model: "claude-sonnet-5", - displayName: "Claude-Sonnet-5", - }, - ], - permissionModes: ["accept-edits", "auto", "full"], -}; - type TestAutomationDetailProps = Omit< ComponentProps, - | "editing" - | "executionOptions" - | "executionOptionsError" - | "permissionModes" - | "onCancelEdit" - | "onUpdateAgent" + "editing" | "onCancelEdit" | "onUpdateAgent" > & Partial< Pick< ComponentProps, - | "editing" - | "executionOptions" - | "executionOptionsError" - | "permissionModes" - | "onCancelEdit" - | "onUpdateAgent" + "editing" | "onCancelEdit" | "onUpdateAgent" > >; function AutomationDetailView({ editing = false, - executionOptions = AUTOMATION_EXECUTION_OPTIONS, - executionOptionsError = null, - permissionModes = AUTOMATION_EXECUTION_OPTIONS.permissionModes, onCancelEdit = () => {}, onUpdateAgent = async () => {}, ...props @@ -764,9 +796,6 @@ function AutomationDetailView({ @@ -853,19 +882,15 @@ describe("Automation detail recipe", () => { expect(savedPrompt.textContent).toBe("Summarize yesterday's commits."); expect(screen.queryByRole("button", { name: "Save Prompt" })).toBeNull(); const disabledModelSelector = container.querySelector( - '[data-disabled-automation-selector="Provider and model"]', + '[data-testid="bb-provider-model-picker"]', ) as HTMLButtonElement; const disabledPermissionSelector = container.querySelector( - '[data-disabled-automation-selector="Permission mode"]', + '[data-testid="bb-permission-mode-picker"]', ) as HTMLButtonElement; expect(disabledModelSelector.disabled).toBe(true); expect(disabledPermissionSelector.disabled).toBe(true); expect(readOnlyPromptShell.contains(disabledModelSelector)).toBe(true); expect(readOnlyPromptShell.contains(disabledPermissionSelector)).toBe(true); - expect(disabledModelSelector.getAttribute("data-state")).toBeNull(); - expect( - disabledModelSelector.parentElement?.getAttribute("data-state"), - ).toBe(null); const readOnlyPromptFooter = container.querySelector( '[data-automation-prompt-footer=""]', ) as HTMLElement; @@ -901,43 +926,17 @@ describe("Automation detail recipe", () => { promptFooter.querySelectorAll('[data-option-display=""]'), ).toHaveLength(1); const accessSelector = promptFooter.querySelector( - '[data-automation-selector="Permission mode"]', + '[data-testid="bb-permission-mode-picker"]', ) as HTMLButtonElement; expect(accessSelector.disabled).toBe(false); expect(accessSelector.getAttribute("aria-label")).toBe("Permission mode"); - expect( - accessSelector.querySelector('[data-icon="ChevronDown"]'), - ).not.toBeNull(); expect(promptPanel.textContent).toContain("Opus 5"); expect(promptPanel.textContent).toContain("Claude"); - // Scoped to the action row, which is where the model selector lives. The - // form also contains the footer's Project/Environment labels, and the - // compact environment label for a project-default environment is literally - // "Default", so asserting against the whole form would silently guard the - // wrong subject. - expect(promptActionRow.textContent).not.toContain("Reasoning"); - expect(promptActionRow.textContent).not.toContain("Default"); - expect( - container.querySelector('[data-automation-read-only-label=""]'), - ).toBeNull(); const modelSelector = promptPanel.querySelector( - '[data-automation-selector="Provider and model"]', + '[data-testid="bb-provider-model-picker"]', ) as HTMLButtonElement; expect(modelSelector.disabled).toBe(false); - expect(modelSelector.getAttribute("aria-label")).toBe( - "Provider and model: Claude, Opus 5", - ); - expect( - modelSelector.querySelector('[data-icon="ChevronDown"]'), - ).not.toBeNull(); - expect( - container.querySelector('[data-automation-provider-icon="claude"] svg'), - ).not.toBeNull(); - expect( - container.querySelector( - '[data-automation-provider-icon="claude"] svg.block', - ), - ).not.toBeNull(); + expect(modelSelector.textContent).toContain("medium"); const savePrompt = screen.getByRole("button", { name: "Save Prompt" }); expect(promptPanel.contains(savePrompt)).toBe(true); expect(savePrompt.querySelector('[data-icon="Check"]')).not.toBeNull(); @@ -956,10 +955,10 @@ describe("Automation detail recipe", () => { }) as HTMLTextAreaElement; const reopenedPanel = reopenedPrompt.closest("form") as HTMLElement; const reopenedModelSelector = reopenedPanel.querySelector( - '[data-automation-selector="Provider and model"]', + '[data-testid="bb-provider-model-picker"]', ) as HTMLButtonElement; const reopenedAccessSelector = container.querySelector( - '[data-automation-selector="Permission mode"]', + '[data-testid="bb-permission-mode-picker"]', ) as HTMLButtonElement; const reopenedSavePrompt = screen.getByRole("button", { name: "Save Prompt", @@ -967,11 +966,8 @@ describe("Automation detail recipe", () => { fireEvent.change(reopenedPrompt, { target: { value: "Summarize the last two days." }, }); - fireEvent.keyDown(reopenedModelSelector, { key: "Enter" }); - await screen.findByRole("listbox"); - fireEvent.click(await screen.findByRole("option", { name: "Sonnet 5" })); - fireEvent.keyDown(reopenedAccessSelector, { key: "Enter" }); - fireEvent.click(await screen.findByRole("option", { name: "Full Access" })); + fireEvent.click(reopenedModelSelector); + fireEvent.click(reopenedAccessSelector); expect((reopenedSavePrompt as HTMLButtonElement).disabled).toBe(false); expect( (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement) @@ -980,7 +976,10 @@ describe("Automation detail recipe", () => { fireEvent.click(reopenedSavePrompt); expect(updateAgent).toHaveBeenCalledWith({ prompt: "Summarize the last two days.", + providerId: "claude", model: "claude-sonnet-5", + reasoningLevel: "high", + serviceTier: "fast", permissionMode: "full", }); expect( @@ -991,45 +990,7 @@ describe("Automation detail recipe", () => { ).toBeNull(); }); - it("does not make permission editing wait for model discovery", () => { - const { container } = render( - - {}, - retry: () => {}, - }} - actionPending={false} - editing - executionOptions={null} - permissionModes={["accept-edits", "auto", "full"]} - onToggle={() => {}} - onEdit={() => {}} - onRunNow={() => {}} - onDelete={() => {}} - onOpenThread={() => {}} - /> - , - ); - - const permissionSelector = container.querySelector( - '[data-automation-selector="Permission mode"]', - ) as HTMLButtonElement; - const modelSelector = container.querySelector( - '[data-automation-selector="Provider and model"]', - ) as HTMLButtonElement; - expect(permissionSelector.disabled).toBe(false); - expect(modelSelector.disabled).toBe(true); - }); - - it("uses the composer metadata treatment without inventing reasoning", () => { + it("keeps project and environment metadata beside the host picker", () => { const { container } = render( { prompt: "Summarize yesterday's commits.", providerId: "claude", model: "claude-opus-5", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", @@ -1087,116 +1049,15 @@ describe("Automation detail recipe", () => { expect(promptFooter.textContent).toContain("bb"); expect(promptFooter.textContent).toContain("~/Code/bb"); expect(promptFooter.textContent).toContain("Approve for me"); - expect(promptShell.textContent).not.toContain("Reasoning"); + expect(promptShell.textContent).toContain("medium"); expect( promptShell.querySelectorAll('[data-option-display=""]'), ).toHaveLength(2); expect( - promptShell.querySelectorAll("[data-disabled-automation-selector]"), - ).toHaveLength(2); + promptShell.querySelectorAll('[data-testid="bb-provider-model-picker"]'), + ).toHaveLength(1); }); - it.each([ - { - providerId: "claude", - model: "claude-opus-5[1m]", - providerLabel: "Claude", - modelLabel: "Opus 5 (1M)", - iconId: "claude", - }, - { - providerId: "codex", - model: "gpt-5.6-sol", - providerLabel: "Codex", - modelLabel: "5.6 Sol", - iconId: "codex", - }, - { - providerId: "pi", - model: "pi-model", - providerLabel: "Pi", - modelLabel: "Pi Model", - iconId: "pi", - }, - { - providerId: "acp-cursor", - model: "cursor-small", - providerLabel: "Cursor", - modelLabel: "Cursor Small", - iconId: "acp-cursor", - }, - { - providerId: "custom-provider", - model: "custom-model-v2", - providerLabel: "Custom-provider", - modelLabel: "Custom Model v2", - iconId: null, - }, - ])( - "renders the $providerLabel provider identity in saved prompt metadata", - ({ providerId, model, providerLabel, modelLabel, iconId }) => { - const { container } = render( - - {}, - retry: () => {}, - }} - actionPending={false} - onToggle={() => {}} - onEdit={() => {}} - onRunNow={() => {}} - onDelete={() => {}} - onOpenThread={() => {}} - /> - , - ); - - const selector = container.querySelector( - '[data-disabled-automation-selector="Provider and model"]', - ) as HTMLButtonElement; - expect(selector.getAttribute("aria-label")).toBe( - `Provider and model: ${providerLabel}, ${modelLabel}. Read only`, - ); - expect(selector.textContent).toContain(modelLabel); - expect( - selector.querySelector('[data-promptbox-compact-label=""]'), - ).toBeNull(); - if (iconId) { - expect( - selector.querySelector( - `[data-automation-provider-icon="${iconId}"] svg`, - ), - ).not.toBeNull(); - } else { - const fallback = selector.querySelector( - `[data-automation-provider-label="${providerId}"]`, - ); - expect(fallback?.textContent).toBe(providerLabel); - } - }, - ); - it("does not treat a project named Local as the personal project", () => { const { container } = render( @@ -1209,6 +1070,7 @@ describe("Automation detail recipe", () => { prompt: "Summarize yesterday's commits.", providerId: "codex", model: "gpt-5", + reasoningLevel: "medium", permissionMode: "auto", environment: { type: "host", @@ -1251,12 +1113,8 @@ describe("Automation detail recipe", () => { promptFooter.querySelectorAll('[data-option-display=""]'), ).toHaveLength(2); expect( - container - .querySelector( - '[data-disabled-automation-selector="Provider and model"]', - ) - ?.getAttribute("aria-label"), - ).toBe("Provider and model: Codex, 5. Read only"); + container.querySelector('[data-testid="bb-provider-model-picker"]'), + ).not.toBeNull(); }); it("shows the stored script with capped overflow and no environment values", () => { diff --git a/apps/app/src/components/tools/plugin-detail-banner.tsx b/apps/app/src/components/tools/plugin-detail-banner.tsx index 35d7331c9a..7ddb5369e9 100644 --- a/apps/app/src/components/tools/plugin-detail-banner.tsx +++ b/apps/app/src/components/tools/plugin-detail-banner.tsx @@ -2,12 +2,11 @@ import type { AriaRole, ReactNode } from "react"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; -export type PluginBannerTone = "destructive" | "warning" | "success"; +type PluginBannerTone = "destructive" | "warning"; const TONE_ICON: Record = { destructive: "text-destructive", warning: "text-warning", - success: "text-success", }; /** @@ -30,7 +29,6 @@ export function PluginBannerBar({ detail, action, separator = true, - testId, role, }: { tone: PluginBannerTone; @@ -39,13 +37,11 @@ export function PluginBannerBar({ detail?: ReactNode; action?: ReactNode; separator?: boolean; - testId?: string; role?: AriaRole; }) { return (
@@ -126,11 +124,7 @@ export function PluginDetailGlyph({ tabIndex={0} className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - + {label} diff --git a/apps/app/src/components/tools/tools-navigation.ts b/apps/app/src/components/tools/tools-navigation.ts index 97beed7bfd..508a48dc97 100644 --- a/apps/app/src/components/tools/tools-navigation.ts +++ b/apps/app/src/components/tools/tools-navigation.ts @@ -26,14 +26,14 @@ export type ToolsSectionId = "skills" | "plugins"; */ export const TOOLS_PAGE_BAND_CLASSES = "mx-auto w-full max-w-5xl px-4 md:px-5"; -export interface ToolsSectionDefinition { +interface ToolsSectionDefinition { id: ToolsSectionId; label: string; icon: IconName; to: string; } -export const TOOLS_SECTIONS = { +const TOOLS_SECTIONS = { skills: { id: "skills", label: "Skills", @@ -53,12 +53,12 @@ export const TOOLS_SECTIONS = { * the Library; plugins call it Installed. Breadcrumbs and the collection tab * both read this, so renaming happens in one place. */ -export const TOOLS_OWNED_COLLECTION_LABEL = { +const TOOLS_OWNED_COLLECTION_LABEL = { skills: "My skills", plugins: "Installed", } as const satisfies Record; -export const TOOLS_OWNED_COLLECTION_VIEW = { +const TOOLS_OWNED_COLLECTION_VIEW = { skills: "library", plugins: "installed", } as const satisfies Record; @@ -69,7 +69,7 @@ export function getToolsOwnedCollectionRoutePath(id: ToolsSectionId): string { export const TOOLS_NAV_ITEMS = [TOOLS_SECTIONS.plugins, TOOLS_SECTIONS.skills]; -export interface ToolsBreadcrumbSegment { +interface ToolsBreadcrumbSegment { label: string; to?: string; } @@ -271,7 +271,7 @@ export function resolveToolsBreadcrumbs( } /** One Extensions page the sidebar lists: identity, label, icon, route. */ -export interface ToolsPageDefinition { +interface ToolsPageDefinition { id: | "plugins-browse" | "plugins-installed" @@ -366,14 +366,13 @@ export function resolveToolsActivePage( */ export function resolveToolsAreaHeaderMeta( pathname: string, - toolsHubEnabled: boolean, resourceLabel?: string | null, search = "", ): | { kind: "extensions-title"; title: string } | { kind: "breadcrumbs"; breadcrumbs: ToolsBreadcrumbSegment[] } | null { - if (toolsHubEnabled && isToolsRoutePath(pathname)) { + if (isToolsRoutePath(pathname)) { const pluginCreateBreadcrumbs = resolvePluginCreateBreadcrumbs( pathname, search, diff --git a/apps/app/src/components/ui/README.md b/apps/app/src/components/ui/README.md index ad358d0edd..3ac2597dbc 100644 --- a/apps/app/src/components/ui/README.md +++ b/apps/app/src/components/ui/README.md @@ -43,8 +43,8 @@ single-consumer feature UI: - Components with no expected reuse outside the app. Thin app wrappers are expected when a primitive needs app policy. For example, -`components/ui` owns the generic `Toaster`, while the app owns `AppToaster` -because it injects the preferred theme. +the app owns `AppToaster` around the generic `sonner` `Toaster` because it +injects the preferred theme. ## Litmus Test diff --git a/apps/app/src/components/ui/activity-row-styles.ts b/apps/app/src/components/ui/activity-row-styles.ts deleted file mode 100644 index 5c532246ab..0000000000 --- a/apps/app/src/components/ui/activity-row-styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - activityIconClass, - activityMetaClass, - activityRowClass, - activityTextClass, - type ActivityRowState, -} from "@bb/shared-ui/activity-row-styles"; diff --git a/apps/app/src/components/ui/app-route-anchor.tsx b/apps/app/src/components/ui/app-route-anchor.tsx index 505c090f12..341a02db2f 100644 --- a/apps/app/src/components/ui/app-route-anchor.tsx +++ b/apps/app/src/components/ui/app-route-anchor.tsx @@ -14,14 +14,11 @@ import { useNavigate, type NavigateOptions } from "react-router-dom"; import { isRoutePath, resolveRouteHref } from "@/lib/route-paths"; import { getDesktopBrowserApi } from "@/lib/bb-desktop"; -export interface RouteNavigationProviderProps { +interface RouteNavigationProviderProps { children: ReactNode; } -export interface RouteAnchorProps extends Omit< - ComponentPropsWithoutRef<"a">, - "href" -> { +interface RouteAnchorProps extends Omit, "href"> { href: string | undefined; } @@ -29,16 +26,13 @@ interface ShouldHandleRouteAnchorClickArgs { event: ReactMouseEvent; } -export interface RouteNavigateOptions { +interface RouteNavigateOptions { replace?: boolean; state?: NavigateOptions["state"]; } /** Navigate to an absolute app route (`/projects/...`); see {@link useRouteNavigate}. */ -export type RouteNavigate = ( - path: string, - options?: RouteNavigateOptions, -) => void; +type RouteNavigate = (path: string, options?: RouteNavigateOptions) => void; const RouteNavigationContext = createContext(null); diff --git a/apps/app/src/components/ui/app-toast-descriptions.tsx b/apps/app/src/components/ui/app-toast-descriptions.tsx index b3deff6052..417a3c7ac5 100644 --- a/apps/app/src/components/ui/app-toast-descriptions.tsx +++ b/apps/app/src/components/ui/app-toast-descriptions.tsx @@ -1,7 +1,3 @@ -interface AppToastCommandDescriptionProps { - command: string; -} - interface AppToastCommitDescriptionProps { commitSha: string; commitSubject: string; @@ -9,24 +5,6 @@ interface AppToastCommitDescriptionProps { const GIT_SHA_DETAIL_LENGTH = 7; -export function AppToastCommandDescription({ - command, -}: AppToastCommandDescriptionProps) { - return ( - - - Running - {" "} - - {command} - - - ); -} - export function AppToastCommitDescription({ commitSha, commitSubject, diff --git a/apps/app/src/components/ui/app-toast.tsx b/apps/app/src/components/ui/app-toast.tsx index b9ac105a59..fb0ca2e76b 100644 --- a/apps/app/src/components/ui/app-toast.tsx +++ b/apps/app/src/components/ui/app-toast.tsx @@ -36,7 +36,7 @@ export interface AppToastOptions description?: ReactNode; } -export interface AppToastContentProps { +interface AppToastContentProps { action?: Action; cancel?: Action; description?: ReactNode; diff --git a/apps/app/src/components/ui/bb-logo.tsx b/apps/app/src/components/ui/bb-logo.tsx new file mode 100644 index 0000000000..643d46bb4a --- /dev/null +++ b/apps/app/src/components/ui/bb-logo.tsx @@ -0,0 +1,18 @@ +import { cn } from "@bb/shared-ui/lib/utils"; +import bbLogoUrl from "../../../../../assets/bb-logo.svg"; + +/** + * bb's own mark, for rows where bb is one listed thing among others — beside a + * provider's logo in Updates, or beside a provider's skills in the tools list. + * Decorative in every one of those places: the row already names it. + */ +export function BbLogo({ className = "size-4" }: { className?: string }) { + return ( + + ); +} diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx index d2ad8fb515..ab5b1a1985 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx @@ -97,6 +97,7 @@ interface RenderArgs { rowIds: string[]; showCapturePrependAnchorControl?: boolean; showScrollToBottomControl?: boolean; + virtualized?: boolean; } function CapturePrependAnchorControl() { @@ -122,7 +123,13 @@ function renderTimeline({ rowIds, showCapturePrependAnchorControl = false, showScrollToBottomControl = false, + virtualized = false, }: RenderArgs) { + const rows = rowIds.map((rowId) => ( +
+ {rowId} +
+ )); const view = render( Footer
} @@ -132,11 +139,13 @@ function renderTimeline({ > {showCapturePrependAnchorControl ? : null} {showScrollToBottomControl ? : null} - {rowIds.map((rowId) => ( -
- {rowId} + {virtualized ? ( +
+
{rows}
- ))} + ) : ( + rows + )} , ); @@ -240,6 +249,41 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { }); }); + it("captures rows nested in a virtualizer spacer", () => { + const { scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["row-a", "row-b", "row-c"], + virtualized: true, + }); + mockScrollAreaRect(scrollArea); + mockRowRect(requireHTMLElement(rowElements.get("row-a")!), { + top: -120, + bottom: -20, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-b")!), { + top: -20, + bottom: 80, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-c")!), { + top: 80, + bottom: 180, + }); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 150, + }); + + fireEvent.wheel(scrollArea); + fireEvent.scroll(scrollArea); + + expect(readAnchor("thread-a")).toEqual({ + rowId: "row-b", + offsetWithinRow: 20, + atBottom: false, + }); + }); + it("finds the visible anchor with logarithmic row measurements", () => { const rowIds = Array.from({ length: 128 }, (_, index) => `row-${index}`); const { scrollArea, rowElements } = renderTimeline({ diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index 6f939a22f3..b7e86e432d 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -54,7 +54,7 @@ export interface BottomAnchorContextValue { captureScrollAnchor: () => void; } -export interface BottomAnchoredScrollBodyProps { +interface BottomAnchoredScrollBodyProps { children: ReactNode; footer: ReactNode; scrollOverlay?: ReactNode; @@ -68,12 +68,12 @@ export interface BottomAnchoredScrollBodyProps { scrollAnchorThreadId?: string; } -export interface ScrollElementIntoViewArgs { +interface ScrollElementIntoViewArgs { element: HTMLElement; options?: ScrollIntoViewOptions; } -export interface ScrollElementIntoViewClampedToMaxScrollArgs { +interface ScrollElementIntoViewClampedToMaxScrollArgs { element: HTMLElement; } @@ -98,7 +98,10 @@ const SCROLL_ANCHOR_RESTORE_MAX_ATTEMPTS = 8; const TIMELINE_ROW_ID_SELECTOR = "[data-timeline-row-id]"; const TOP_LEVEL_TIMELINE_ROW_LIST_SELECTOR = '[data-timeline-row-list="top-level"]'; -const DIRECT_TIMELINE_ROW_SELECTOR = `:scope > ${TIMELINE_ROW_ID_SELECTOR}`; +const DIRECT_TIMELINE_ROW_SELECTOR = [ + `:scope > ${TIMELINE_ROW_ID_SELECTOR}`, + `:scope > [data-timeline-virtual-spacer] > ${TIMELINE_ROW_ID_SELECTOR}`, +].join(", "); const SCROLL_INTENT_KEYS = new Set([ "ArrowDown", "ArrowUp", @@ -112,6 +115,16 @@ const SCROLL_INTENT_KEYS = new Set([ export const BottomAnchorContext = createContext(null); +/** + * A virtualized timeline pins this one row during initial navigation restore; + * otherwise the saved row would not exist in the DOM for the scroll body to + * measure. It remains separate from BottomAnchorContext so embedded/test + * consumers do not need to implement virtualizer policy. + */ +export const TimelineScrollRestoreRowIdContext = createContext( + null, +); + export function useBottomAnchoredScroll(): BottomAnchorContextValue | null { return useContext(BottomAnchorContext); } @@ -303,6 +316,15 @@ export function BottomAnchoredScrollBody({ }>({ lastWriteAt: 0, trailingTimeout: null }); const userDetachedFromBottomRef = useRef(false); const [isAtBottom, setIsAtBottom] = useState(true); + const initialScrollRestoreRowId = useMemo(() => { + if (scrollAnchorThreadId === undefined) return null; + const anchor = store.get( + threadTimelineScrollAnchorAtomFamily(scrollAnchorThreadId), + ); + return anchor !== null && anchor !== undefined && !anchor.atBottom + ? anchor.rowId + : null; + }, [scrollAnchorThreadId, store]); const getScrollElement = useCallback(() => scrollAreaRef.current, []); @@ -827,65 +849,69 @@ export function BottomAnchoredScrollBody({ return ( -
-
+ +
- {/* `.scroll-bottom-anchor-content` sets `overflow-anchor: none` on +
+ {/* `.scroll-bottom-anchor-content` sets `overflow-anchor: none` on this wrapper only. Scroll anchoring skips an excluded element's whole subtree, so one class on one element redirects anchoring to the trailing sentinel without a descendant rule that would restyle every timeline node each time the bottom attaches or detaches. Browsers without scroll anchoring (WebKit) never get the class: the toggle would be a pure invalidation cost. */} -
- {children} -
-
- {footer ? ( - // The sticky footer is excluded from anchor selection outright: - // it moves with the scrollport, so anchoring to it (or to a - // control inside it) would turn its own height changes into - // scroll jumps. Static exclusion keeps the previous - // `.scroll-bottom-anchor-content *` coverage of this subtree - // without a toggling class; while the wrapper is not excluded - // it always wins selection anyway, so nothing else changes.
- {footer} + {children}
- ) : null} +
+ {footer ? ( + // The sticky footer is excluded from anchor selection outright: + // it moves with the scrollport, so anchoring to it (or to a + // control inside it) would turn its own height changes into + // scroll jumps. Static exclusion keeps the previous + // `.scroll-bottom-anchor-content *` coverage of this subtree + // without a toggling class; while the wrapper is not excluded + // it always wins selection anyway, so nothing else changes. +
+ {footer} +
+ ) : null} +
+ {scrollOverlay ? ( +
+
{scrollOverlay}
+
+ ) : null}
- {scrollOverlay ? ( -
-
{scrollOverlay}
-
- ) : null} -
+ ); } diff --git a/apps/app/src/components/ui/chromeStyleTokens.ts b/apps/app/src/components/ui/chromeStyleTokens.ts deleted file mode 100644 index 52fb116987..0000000000 --- a/apps/app/src/components/ui/chromeStyleTokens.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - CHROME_SECTION_LABEL_CLASS, - CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS, -} from "@bb/shared-ui/chrome-style-tokens"; diff --git a/apps/app/src/components/ui/conversation.tsx b/apps/app/src/components/ui/conversation.tsx index 153ac4eb3e..26acd2c2ba 100644 --- a/apps/app/src/components/ui/conversation.tsx +++ b/apps/app/src/components/ui/conversation.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { cn } from "@bb/shared-ui/lib/utils"; -export interface ConversationTimelineProps { +interface ConversationTimelineProps { children: ReactNode; className?: string; } diff --git a/apps/app/src/components/ui/detail-card.tsx b/apps/app/src/components/ui/detail-card.tsx index 9ca7daed9c..d46c112388 100644 --- a/apps/app/src/components/ui/detail-card.tsx +++ b/apps/app/src/components/ui/detail-card.tsx @@ -44,9 +44,9 @@ function labelWidthStyle( return { "--detail-label-width": labelWidth } as CSSProperties; } -export type DetailCardAppearance = "card" | "flat"; +type DetailCardAppearance = "card" | "flat"; -export interface DetailCardProps { +interface DetailCardProps { children: ReactNode; className?: string; /** @@ -86,7 +86,7 @@ export function DetailCard({ ); } -export interface DetailRowProps { +interface DetailRowProps { label: ReactNode; children: ReactNode; className?: string; diff --git a/apps/app/src/components/ui/detail-scroll-size.ts b/apps/app/src/components/ui/detail-scroll-size.ts index fe2dbe3e82..62dde702b4 100644 --- a/apps/app/src/components/ui/detail-scroll-size.ts +++ b/apps/app/src/components/ui/detail-scroll-size.ts @@ -17,12 +17,7 @@ * to spell `max-h-[…px]` out explicitly. The numeric pixel values stay * adjacent so a future tier change touches one block. */ -export const detailScrollSizeValues = [ - "summary", - "base", - "delegation", -] as const; -export type DetailScrollSize = (typeof detailScrollSizeValues)[number]; +export type DetailScrollSize = "summary" | "base" | "delegation"; const DETAIL_SCROLL_MAX_HEIGHT_CLASS_BY_SIZE: Record = { diff --git a/apps/app/src/components/ui/diff-stats-tally.tsx b/apps/app/src/components/ui/diff-stats-tally.tsx index 74b08f3582..270059d39f 100644 --- a/apps/app/src/components/ui/diff-stats-tally.tsx +++ b/apps/app/src/components/ui/diff-stats-tally.tsx @@ -1,7 +1,7 @@ import { formatDiffCount } from "@bb/thread-view"; import { cn } from "@bb/shared-ui/lib/utils"; -export interface DiffStatsTallyProps { +interface DiffStatsTallyProps { insertions: number; deletions: number; /** Drop a side when its count is 0 (e.g. show only `-2` instead of `+0 -2`). */ diff --git a/apps/app/src/components/ui/disclosure.tsx b/apps/app/src/components/ui/disclosure.tsx index 43d8466dee..e052238e02 100644 --- a/apps/app/src/components/ui/disclosure.tsx +++ b/apps/app/src/components/ui/disclosure.tsx @@ -50,7 +50,7 @@ export function getCollapsibleHeaderToneClass(isExpanded: boolean): string { : COLLAPSIBLE_HEADER_COLLAPSED_TONE_CLASS; } -export interface CollapsibleHeaderProps { +interface CollapsibleHeaderProps { summaryContent: ReactNode; toneClassName: string; summaryClassName?: string; @@ -111,25 +111,22 @@ export function CollapsibleHeader({ ); } -export interface ExpandablePanelProps { +interface ExpandablePanelProps { isExpanded: boolean; summaryContent: ReactNode; headerToneClass: string; onToggle?: () => void; collapsedContent?: ReactNode; forceHeaderChevronVisible?: boolean; - headerButtonClassName?: string; summaryContentClassName?: string; children?: ReactNode; renderBody?: () => ReactNode; className?: string; headerClassName?: string; - bodyClassName?: string; contentClassName?: string; } interface AnimatedExpandablePanelContentProps { - bodyClassName?: string; collapsedContent: ReactNode; contentClassName?: string; isExpanded: boolean; @@ -137,7 +134,6 @@ interface AnimatedExpandablePanelContentProps { } function AnimatedExpandablePanelContent({ - bodyClassName, collapsedContent, contentClassName, isExpanded, @@ -189,10 +185,7 @@ function AnimatedExpandablePanelContent({ return (
(null); const expandedBody = useMemo(() => { @@ -325,7 +312,6 @@ export function ExpandablePanel({
{hasCollapsedContent ? (
diff --git a/apps/app/src/components/ui/event-code-block.tsx b/apps/app/src/components/ui/event-code-block.tsx index 5f6987e5b1..b05aaac641 100644 --- a/apps/app/src/components/ui/event-code-block.tsx +++ b/apps/app/src/components/ui/event-code-block.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { cn } from "@bb/shared-ui/lib/utils"; -export interface EventCodeBlockProps { +interface EventCodeBlockProps { children: ReactNode; className?: string; tone?: "default" | "danger"; diff --git a/apps/app/src/components/ui/expandable-line.tsx b/apps/app/src/components/ui/expandable-line.tsx index 1948673b63..80e374599d 100644 --- a/apps/app/src/components/ui/expandable-line.tsx +++ b/apps/app/src/components/ui/expandable-line.tsx @@ -1,9 +1,8 @@ import { useRef, useState, type CSSProperties, type ReactNode } from "react"; -export interface ExpandableLineProps { +interface ExpandableLineProps { fullText: string; children: ReactNode; - className?: string; collapsedClassName: string; collapsedStyle?: CSSProperties; expandedClassName?: string; @@ -14,7 +13,6 @@ const DEFAULT_EXPANDED_CLASS_NAME = "whitespace-pre-wrap break-words"; export function ExpandableLine({ fullText, children, - className, collapsedClassName, collapsedStyle, expandedClassName = DEFAULT_EXPANDED_CLASS_NAME, @@ -41,7 +39,6 @@ export function ExpandableLine({ className={[ "block w-full cursor-pointer select-text text-left leading-tight transition-[max-height] duration-200 ease-out", isExpanded ? expandedClassName : collapsedClassName, - className, ] .filter(Boolean) .join(" ")} diff --git a/apps/app/src/components/ui/file-path-link.tsx b/apps/app/src/components/ui/file-path-link.tsx index acdf27c763..a8b82d9c0d 100644 --- a/apps/app/src/components/ui/file-path-link.tsx +++ b/apps/app/src/components/ui/file-path-link.tsx @@ -1,13 +1,11 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { TruncateStart } from "./truncate-start.js"; -import { Icon } from "@bb/shared-ui/icon"; interface FilePathLinkProps { path: string; /** Override the displayed text. Defaults to `path`. The `path` value is always used for the title tooltip. */ displayName?: string; onClick?: () => void; - variant?: "external"; className?: string; } @@ -15,7 +13,6 @@ export function FilePathLink({ path, displayName, onClick, - variant, className, }: FilePathLinkProps) { const baseClassName = "min-w-0 text-left text-xs leading-5"; @@ -41,9 +38,6 @@ export function FilePathLink({ onClick={onClick} > {text} - {variant === "external" ? ( - - ) : null} ); } diff --git a/apps/app/src/components/ui/height-transition.tsx b/apps/app/src/components/ui/height-transition.tsx index 8bfc5e442c..c01c03d720 100644 --- a/apps/app/src/components/ui/height-transition.tsx +++ b/apps/app/src/components/ui/height-transition.tsx @@ -141,11 +141,9 @@ function cancelIntrinsicHeightRestore( resizeState.restoreTimerId = null; } -export interface HeightTransitionProps { +interface HeightTransitionProps { visible: boolean; children: ReactNode; - durationMs?: number; - className?: string; } /** @@ -157,12 +155,7 @@ export interface HeightTransitionProps { * physics. Children stay mounted across the transition so consumer state * (e.g. an expandable panel's open flag) survives a hide/show cycle. */ -export function HeightTransition({ - visible, - children, - durationMs = HEIGHT_TRANSITION_DURATION_MS, - className, -}: HeightTransitionProps) { +export function HeightTransition({ visible, children }: HeightTransitionProps) { const wrapperRef = useRef(null); const innerRef = useRef(null); const store = useStore(); @@ -217,10 +210,7 @@ export function HeightTransition({ return (
{/* @@ -249,10 +239,8 @@ export function HeightTransition({ ); } -export interface AutoHeightContainerProps { +interface AutoHeightContainerProps { children: ReactNode; - className?: string; - durationMs?: number; /** * A revision for authoritative layout replacements that should not animate * through their intermediate height. Normal child growth still animates. @@ -303,12 +291,10 @@ function useSnapHeightGrowth(): boolean { export function AutoHeightContainer({ children, - className, - durationMs: requestedDurationMs = HEIGHT_TRANSITION_DURATION_MS, snapRevision, }: AutoHeightContainerProps) { const snapGrowth = useSnapHeightGrowth(); - const durationMs = snapGrowth ? 0 : requestedDurationMs; + const durationMs = snapGrowth ? 0 : HEIGHT_TRANSITION_DURATION_MS; const wrapperRef = useRef(null); const innerRef = useRef(null); const snapToCurrentHeightRef = useRef<(() => void) | null>(null); @@ -411,7 +397,6 @@ export function AutoHeightContainer({ return (
> = { GitPullRequestClosed: "Closed pull request glyph", GitPullRequestDraft: "Draft pull request glyph", Info: "Right panel “thread info” tab, informational banners", - Laptop: "Persistent host icon (resolved via getHostIconName)", + Laptop: "Persistent host icon (resolved via PersistentHostIconName)", ListTodo: "Plan prompt action, todo prompt-stack card header", Loading: "Loading03 thread row working spinner", Mail: "Mark unread thread action", MailOpen: "Mark read thread action", - Maximize2: "Expand right panel, enter zen mode, open Mermaid diagram dialog", + Maximize2: "Expand right panel, open Mermaid diagram dialog", MessageSquarePlus: "“New chat” button in sidebar", Mic: "Voice toggle in prompt", - Minimize2: "Restore conversation split, exit zen mode", + Minimize2: "Restore conversation split", MoreHorizontal: "Triple-dot actions menu trigger (project list, projects, threads, project sources, hosts)", NewTab: "Right-panel New tab tab", diff --git a/apps/app/src/components/ui/image-lightbox.tsx b/apps/app/src/components/ui/image-lightbox.tsx index 62b7eb15b4..9443753417 100644 --- a/apps/app/src/components/ui/image-lightbox.tsx +++ b/apps/app/src/components/ui/image-lightbox.tsx @@ -3,13 +3,7 @@ import { Button } from "@bb/shared-ui/button"; import { Dialog, DialogClose, DialogContent, DialogTitle } from "@bb/shared-ui/dialog"; import { Icon } from "@bb/shared-ui/icon"; -export const imageLightboxKeyActionValues = [ - "close", - "next", - "previous", -] as const; -export type ImageLightboxKeyAction = - (typeof imageLightboxKeyActionValues)[number]; +type ImageLightboxKeyAction = "close" | "next" | "previous"; const IMAGE_TRANSPARENCY_CHECKER_BASE = "color-mix(in oklch, var(--ink) 5%, var(--canvas))"; @@ -22,7 +16,7 @@ export const IMAGE_TRANSPARENCY_CHECKER_STYLE: CSSProperties = { backgroundSize: "16px 16px", }; -export interface ImageLightboxKeyActionInput { +interface ImageLightboxKeyActionInput { event: Pick< KeyboardEvent, "altKey" | "ctrlKey" | "defaultPrevented" | "key" | "metaKey" @@ -30,13 +24,13 @@ export interface ImageLightboxKeyActionInput { hasNavigation: boolean; } -export interface WrappedImageIndexInput { +interface WrappedImageIndexInput { currentIndex: number; direction: "next" | "previous"; itemCount: number; } -export interface ImageLightboxProps { +interface ImageLightboxProps { hasMultipleImages?: boolean; imageAlt: string; imageSrc: string | null; diff --git a/apps/app/src/components/ui/markdown-code-block.ts b/apps/app/src/components/ui/markdown-code-block.ts index 052e414822..d7df34b754 100644 --- a/apps/app/src/components/ui/markdown-code-block.ts +++ b/apps/app/src/components/ui/markdown-code-block.ts @@ -1,8 +1,8 @@ -export interface GetMarkdownCodeLanguageArgs { +interface GetMarkdownCodeLanguageArgs { className: string | undefined; } -export interface IsMarkdownCodeBlockArgs { +interface IsMarkdownCodeBlockArgs { codeText: string; language: string | null; } diff --git a/apps/app/src/components/ui/markdown-code-highlight.test.ts b/apps/app/src/components/ui/markdown-code-highlight.test.ts new file mode 100644 index 0000000000..b122f8bac9 --- /dev/null +++ b/apps/app/src/components/ui/markdown-code-highlight.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { highlightMarkdownCode } from "./markdown-code-highlight.js"; + +function tokens(html: string): Array<[string, string]> { + return [ + ...html.matchAll(/class="sh__token--(\w+)"[^>]*>([^<]*)<\/span>/g), + ].map((match) => [match[1]!, match[2]!]); +} + +function tokenTypes(html: string): string[] { + return tokens(html).map(([type]) => type); +} + +describe("highlightMarkdownCode", () => { + const shell = "# install the plugin\nbb plugin install ./plugins/monokai"; + + it.each(["sh", "bash", "shell", "zsh", "console", "shellscript"])( + "lexes a `#` comment in a %s fence as a comment, not a JS sign", + (language) => { + const html = highlightMarkdownCode({ code: shell, language }); + expect(tokens(html)).toContainEqual(["comment", "# install the plugin"]); + // The JS lexer reads `/plugins/monokai` as a regex literal (string). + expect(tokenTypes(html)).not.toContain("string"); + }, + ); + + it.each([null, "ruby"])( + "keeps the JavaScript lexer for a fence with language %j", + (language) => { + const html = highlightMarkdownCode({ + code: "const a = 1 // hi", + language, + }); + expect(tokens(html)).toContainEqual(["keyword", "const"]); + expect(tokens(html)).toContainEqual(["comment", "// hi"]); + }, + ); + + it("keeps the previously mapped aliases highlighted", () => { + expect( + tokens(highlightMarkdownCode({ code: "# c\nx = 1", language: "py" })), + ).toContainEqual(["comment", "# c"]); + expect( + tokens( + highlightMarkdownCode({ code: "int main() {}", language: "hpp" }), + ), + ).toContainEqual(["class", "int"]); + expect( + tokens(highlightMarkdownCode({ code: "a { color: red }", language: "less" })), + ).toContainEqual(["property", "color"]); + expect( + tokens(highlightMarkdownCode({ code: "fun f() {}", language: "kt" })), + ).toContainEqual(["keyword", "fun"]); + }); + + it("highlights languages agents emit that v1 never mapped", () => { + expect( + tokens(highlightMarkdownCode({ code: "# top\nkey: v", language: "yaml" })), + ).toContainEqual(["comment", "# top"]); + expect( + tokens(highlightMarkdownCode({ code: "-- c\nSELECT 1", language: "sql" })), + ).toContainEqual(["comment", "-- c"]); + }); +}); diff --git a/apps/app/src/components/ui/markdown-code-highlight.ts b/apps/app/src/components/ui/markdown-code-highlight.ts index 1b87b2086d..cee5fb2664 100644 --- a/apps/app/src/components/ui/markdown-code-highlight.ts +++ b/apps/app/src/components/ui/markdown-code-highlight.ts @@ -1,31 +1,22 @@ -import { highlight } from "sugar-high"; -import { c, css, go, java, python, rust } from "sugar-high/presets"; +import { highlight, type LanguageName } from "sugar-high"; +import { lang } from "sugar-high/lang"; -// sugar-high's core highlighter targets JavaScript/JSX/TypeScript. These presets -// extend it to the other languages agents emit most often. A language without a -// preset falls through to the core highlighter, which still tokenizes +// sugar-high resolves fence aliases it knows (`sh`/`bash`/`zsh` -> shell, +// `py` -> python, `c++`/`cc` -> cpp, `yml` -> yaml, ...). These cover the +// aliases agents emit that it does not know. A language it cannot resolve +// falls through to the core JavaScript highlighter, which still tokenizes // identifiers, strings, and comments rather than failing. -const PRESET_BY_LANGUAGE: Record = { - rust, - rs: rust, - python, - py: python, - go, - c, - "c++": c, - cpp: c, - cc: c, - h: c, - hpp: c, - java, - kotlin: java, - kt: java, - css, - scss: css, - less: css, +const EXTRA_LANGUAGE_ALIASES: Record = { + console: "shell", + shellscript: "shell", + h: "c", + hpp: "cpp", + hh: "cpp", + hxx: "cpp", + less: "css", }; -export interface HighlightMarkdownCodeArgs { +interface HighlightMarkdownCodeArgs { code: string; language: string | null; } @@ -41,6 +32,9 @@ export function highlightMarkdownCode({ code, language, }: HighlightMarkdownCodeArgs): string { - const preset = language === null ? undefined : PRESET_BY_LANGUAGE[language]; - return highlight(code, preset); + const resolved = + language === null + ? undefined + : (lang(language) ?? EXTRA_LANGUAGE_ALIASES[language]); + return highlight(code, { lang: resolved }); } diff --git a/apps/app/src/components/ui/markdown-katex-loader.ts b/apps/app/src/components/ui/markdown-katex-loader.ts index 7f40cefef0..41b4b458c8 100644 --- a/apps/app/src/components/ui/markdown-katex-loader.ts +++ b/apps/app/src/components/ui/markdown-katex-loader.ts @@ -16,7 +16,7 @@ let loadedRehypeKatex: RehypeKatex | null = null; let rehypeKatexImportPromise: Promise | null = null; const listeners = new Set<() => void>(); -export function loadRehypeKatex(): Promise { +function loadRehypeKatex(): Promise { if (rehypeKatexImportPromise === null) { rehypeKatexImportPromise = import("./markdown-katex.js").then( (katexModule) => { diff --git a/apps/app/src/components/ui/markdown-link-routing.ts b/apps/app/src/components/ui/markdown-link-routing.ts index 3fe40a5952..d45a463607 100644 --- a/apps/app/src/components/ui/markdown-link-routing.ts +++ b/apps/app/src/components/ui/markdown-link-routing.ts @@ -8,23 +8,23 @@ import type { } from "./markdown-local-file-link.js"; /** One action in a local file link's right-click menu. */ -export interface MarkdownLocalFileContextMenuAction { +interface MarkdownLocalFileContextMenuAction { id: string; label: ReactNode; onSelect: () => void; type?: "action"; } -export interface MarkdownLocalFileContextMenuSeparator { +interface MarkdownLocalFileContextMenuSeparator { id: string; type: "separator"; } -export type MarkdownLocalFileContextMenuLeafItem = +type MarkdownLocalFileContextMenuLeafItem = | MarkdownLocalFileContextMenuAction | MarkdownLocalFileContextMenuSeparator; -export interface MarkdownLocalFileContextMenuSubmenu { +interface MarkdownLocalFileContextMenuSubmenu { id: string; items: MarkdownLocalFileContextMenuLeafItem[]; label: ReactNode; @@ -39,7 +39,7 @@ export type MarkdownLocalFileContextMenuItem = * Right-click menu items for local file links. Null/empty = no menu; left-click * behavior is unchanged either way. */ -export type MarkdownLocalFileContextMenuItemsProvider = ( +type MarkdownLocalFileContextMenuItemsProvider = ( link: MarkdownPreviewLocalFileLink, ) => MarkdownLocalFileContextMenuItem[] | null; diff --git a/apps/app/src/components/ui/markdown-link.ts b/apps/app/src/components/ui/markdown-link.ts index 87ca26a7ef..ebab73a2df 100644 --- a/apps/app/src/components/ui/markdown-link.ts +++ b/apps/app/src/components/ui/markdown-link.ts @@ -1,4 +1,4 @@ -export interface MarkdownPreviewLink { +interface MarkdownPreviewLink { /** The anchor's resolved (sanitized) href. */ href: string; } diff --git a/apps/app/src/components/ui/markdown-local-file-link.ts b/apps/app/src/components/ui/markdown-local-file-link.ts index 9038b291bc..55dd73c9e7 100644 --- a/apps/app/src/components/ui/markdown-local-file-link.ts +++ b/apps/app/src/components/ui/markdown-local-file-link.ts @@ -5,7 +5,7 @@ import { import { createFilePreviewLineRange, type FilePreviewLineRange, -} from "@/lib/file-preview"; +} from "@bb/client-core"; export interface MarkdownPreviewLocalFileLink { lineRange: FilePreviewLineRange | null; @@ -24,11 +24,11 @@ export type MarkdownPreviewLocalFileLinkHandler = ( link: MarkdownPreviewLocalFileLink, ) => boolean; -export interface MarkdownTrustedAbsoluteLocalFileLinkRouting { +interface MarkdownTrustedAbsoluteLocalFileLinkRouting { kind: "trusted-host"; } -export interface MarkdownContainedAbsoluteLocalFileLinkRouting { +interface MarkdownContainedAbsoluteLocalFileLinkRouting { kind: "contained"; rootPath: string; } @@ -65,12 +65,11 @@ interface ParseLineRangeArgs { startValue: string; } -export interface ResolveRelativeLocalFileHrefArgs - extends MarkdownRelativeLocalFileLinkRouting { +interface ResolveRelativeLocalFileHrefArgs extends MarkdownRelativeLocalFileLinkRouting { href: string | undefined; } -export interface ParseLocalFileHrefArgs { +interface ParseLocalFileHrefArgs { absoluteLinks: MarkdownAbsoluteLocalFileLinkRouting; href: string | undefined; } @@ -372,8 +371,7 @@ function isLinkContainedInRoot({ export function parseLocalFileHref({ absoluteLinks, href, -}: ParseLocalFileHrefArgs, -): MarkdownPreviewLocalFileLink | null { +}: ParseLocalFileHrefArgs): MarkdownPreviewLocalFileLink | null { if (!href) { return null; } diff --git a/apps/app/src/components/ui/markdown-math-fences.test.ts b/apps/app/src/components/ui/markdown-math-fences.test.ts new file mode 100644 index 0000000000..4a4fc027a3 --- /dev/null +++ b/apps/app/src/components/ui/markdown-math-fences.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMathFences } from "./markdown-math-fences.js"; + +const SUFFIX = ["", "## After", "", "- item"].join("\n"); + +describe("normalizeMathFences", () => { + it("splits a glued opener and trailing closer onto their own lines", () => { + const input = ["$$T_{a}", "\\approx 73$$", SUFFIX].join("\n"); + expect(normalizeMathFences(input)).toBe( + ["$$", "T_{a}", "\\approx 73", "$$", SUFFIX].join("\n"), + ); + }); + + it("closes a bare opener at a trailing `$$`", () => { + const input = ["$$", "\\frac{1}{2}$$", SUFFIX].join("\n"); + expect(normalizeMathFences(input)).toBe( + ["$$", "\\frac{1}{2}", "$$", SUFFIX].join("\n"), + ); + }); + + it("moves opener meta into the block when the closer is bare", () => { + const input = ["$$\\frac{1}{2}", "$$", SUFFIX].join("\n"); + expect(normalizeMathFences(input)).toBe( + ["$$", "\\frac{1}{2}", "$$", SUFFIX].join("\n"), + ); + }); + + it("closes each block at its own delimiter", () => { + const input = ["$$a", "b$$", "", "text", "", "$$", "c$$"].join("\n"); + expect(normalizeMathFences(input)).toBe( + ["$$", "a", "b", "$$", "", "text", "", "$$", "c", "$$"].join("\n"), + ); + }); + + it("returns the same string for canonical blocks, inline math, and prose", () => { + for (const input of [ + ["$$", "\\frac{1}{2}", "$$", SUFFIX].join("\n"), + ["Mass-energy is $$E = mc^2$$ exactly.", SUFFIX].join("\n"), + ["$$a$$ and $$b$$", SUFFIX].join("\n"), + "It went from $5 to $10 and costs $$$.", + "no math here", + ]) { + expect(normalizeMathFences(input)).toBe(input); + } + }); + + it("leaves a span with no closing delimiter alone", () => { + const input = ["$$T_{a}", "\\approx 73", SUFFIX].join("\n"); + expect(normalizeMathFences(input)).toBe(input); + }); + + it("does not rewrite inside fenced code or across a code fence", () => { + const inCode = ["```tex", "$$T_{a}", "b$$", "```"].join("\n"); + expect(normalizeMathFences(inCode)).toBe(inCode); + const acrossFence = ["$$T_{a}", "```", "b$$", "```"].join("\n"); + expect(normalizeMathFences(acrossFence)).toBe(acrossFence); + }); + + it("leaves indented code alone", () => { + const input = [" $$T_{a}", " b$$"].join("\n"); + expect(normalizeMathFences(input)).toBe(input); + }); + + it("handles CRLF line endings", () => { + const input = "$$T_{a}\r\n\\approx 73$$\r\n\r\n## After"; + expect(normalizeMathFences(input)).toBe( + "$$\nT_{a}\n\\approx 73\n$$\n\r\n## After", + ); + }); +}); diff --git a/apps/app/src/components/ui/markdown-math-fences.ts b/apps/app/src/components/ui/markdown-math-fences.ts new file mode 100644 index 0000000000..951d4a2cca --- /dev/null +++ b/apps/app/src/components/ui/markdown-math-fences.ts @@ -0,0 +1,119 @@ +import { + isMarkdownFenceClose, + type MarkdownFence, + parseMarkdownFenceStart, + trimMarkdownLineCarriageReturn, +} from "./markdown-prompt-blockquote-boundaries.js"; + +// `$$` at line start (≤3 spaces of indent, like any fence) followed by text +// without another `$`. micromark treats exactly this as a display-math opening +// fence whose text is the (never rendered) meta string; `$$x$$` on one line has +// a `$` in the remainder and stays inline math. +const OPEN_WITH_META_PATTERN = /^( {0,3})\$\$[ \t]*([^$]+?)[ \t]*$/u; +const BARE_FENCE_PATTERN = /^( {0,3})\$\$[ \t]*$/u; +// A content line that ends with `$$` but does not start with it. The captured +// group is the TeX that precedes the glued closing delimiter. +const TRAILING_CLOSE_PATTERN = /^(.*?[^$\s])[ \t]*\$\$[ \t]*$/u; + +interface MathFenceClose { + index: number; + tex: string | null; +} + +function findMathFenceClose( + lines: readonly string[], + from: number, +): MathFenceClose | null { + for (let index = from; index < lines.length; index += 1) { + const line = trimMarkdownLineCarriageReturn(lines[index] ?? ""); + if (parseMarkdownFenceStart(line) !== null) { + return null; + } + if (BARE_FENCE_PATTERN.test(line)) { + return { index, tex: null }; + } + const trailing = TRAILING_CLOSE_PATTERN.exec(line); + if (trailing !== null) { + return { index, tex: trailing[1]! }; + } + } + return null; +} + +/** + * Rewrites LaTeX-style display math whose `$$` delimiters are glued to the + * TeX into the fence shape `remark-math` understands. + * + * `micromark-extension-math` parses `$$` display math like a fenced code + * block: the opening line may carry a meta string (`$$T_{a}` opens a block + * with meta `T_{a}`, which is dropped from the output) and the closing fence + * must be `$$` alone on its own line. Models routinely emit + * + * $$T_{a} + * \approx 73$$ + * + * which opens a block that never closes, so everything after it becomes math + * content and renders as one `.katex-error` (#1778). This pass turns that + * span, plus the `$$\n…$$` and `$$…\n$$` variants, into + * + * $$ + * T_{a} + * \approx 73 + * $$ + * + * before the text reaches the parser. Fenced code blocks are skipped, a span + * with no closing delimiter is left alone, and canonical blocks and inline + * `$$x$$` come out unchanged. + */ +export function normalizeMathFences(markdown: string): string { + if (!markdown.includes("$$")) { + return markdown; + } + const lines = markdown.split("\n"); + const normalized: string[] = []; + let activeFence: MarkdownFence | null = null; + let index = 0; + while (index < lines.length) { + const rawLine = lines[index] ?? ""; + const line = trimMarkdownLineCarriageReturn(rawLine); + if (activeFence !== null) { + normalized.push(rawLine); + if (isMarkdownFenceClose(line, activeFence)) { + activeFence = null; + } + index += 1; + continue; + } + activeFence = parseMarkdownFenceStart(line); + if (activeFence !== null) { + normalized.push(rawLine); + index += 1; + continue; + } + + const open = + OPEN_WITH_META_PATTERN.exec(line) ?? BARE_FENCE_PATTERN.exec(line); + const close = open === null ? null : findMathFenceClose(lines, index + 1); + if (open === null || close === null) { + normalized.push(rawLine); + index += 1; + continue; + } + + const indent = open[1]!; + normalized.push(`${indent}$$`); + if (open[2] !== undefined) { + normalized.push(`${indent}${open[2]}`); + } + for (let body = index + 1; body < close.index; body += 1) { + normalized.push(lines[body] ?? ""); + } + if (close.tex !== null) { + normalized.push(close.tex); + } + normalized.push(`${indent}$$`); + index = close.index + 1; + } + const result = normalized.join("\n"); + return result === markdown ? markdown : result; +} diff --git a/apps/app/src/components/ui/markdown-mermaid-diagram.tsx b/apps/app/src/components/ui/markdown-mermaid-diagram.tsx index d934f71d8e..d92a284068 100644 --- a/apps/app/src/components/ui/markdown-mermaid-diagram.tsx +++ b/apps/app/src/components/ui/markdown-mermaid-diagram.tsx @@ -34,7 +34,7 @@ import { useAppThemeEpoch } from "@/hooks/useAppTheme"; import type { Theme } from "@/hooks/useTheme"; import { cn } from "@bb/shared-ui/lib/utils"; -export interface MarkdownMermaidDiagramProps { +interface MarkdownMermaidDiagramProps { preferredTheme: Theme; source: string; } diff --git a/apps/app/src/components/ui/markdown-mermaid-render-cache.ts b/apps/app/src/components/ui/markdown-mermaid-render-cache.ts index 9c8d537acf..6efdc3972d 100644 --- a/apps/app/src/components/ui/markdown-mermaid-render-cache.ts +++ b/apps/app/src/components/ui/markdown-mermaid-render-cache.ts @@ -20,7 +20,7 @@ export interface RenderedMermaidDiagram { svg: string; } -export interface MermaidRenderCacheKeyArgs { +interface MermaidRenderCacheKeyArgs { appThemeEpoch: number; preferredTheme: Theme; source: string; @@ -33,7 +33,7 @@ export interface MermaidRenderCacheKeyArgs { export const MERMAID_SOURCE_RENDER_DEBOUNCE_MS = 300; /** Diagrams enter the render gate this far before they scroll into view. */ -export const MERMAID_VIEWPORT_ROOT_MARGIN = "256px 0px"; +const MERMAID_VIEWPORT_ROOT_MARGIN = "256px 0px"; export const MERMAID_RENDER_CACHE_LIMIT = 32; diff --git a/apps/app/src/components/ui/markdown-message-directives.test.tsx b/apps/app/src/components/ui/markdown-message-directives.test.tsx index c04891a06a..aa3b3445d7 100644 --- a/apps/app/src/components/ui/markdown-message-directives.test.tsx +++ b/apps/app/src/components/ui/markdown-message-directives.test.tsx @@ -435,11 +435,8 @@ describe("ConversationMessageContent assistant directives", () => { id="msg_a" threadId="thr_a" turnId="turn_a" - sourceSeqStart={1} - sourceSeqEnd={1} showActions={false} text={'::inline-vis{file="a.html"}'} - turnRequest={null} projectId="proj_a" /> @@ -478,11 +475,8 @@ describe("ConversationMessageContent assistant directives", () => { id="msg_a" threadId="thr_a" turnId="turn_a" - sourceSeqStart={1} - sourceSeqEnd={1} showActions={false} text={'::inline-vis{file="charts/demo.html"}'} - turnRequest={null} projectId="proj_a" workspaceRootPath="/workspace/project" onOpenLocalFileLink={onOpenLocalFileLink} @@ -520,11 +514,8 @@ describe("ConversationMessageContent assistant directives", () => { id="msg_a" threadId="thr_a" turnId="turn_a" - sourceSeqStart={1} - sourceSeqEnd={1} showActions={false} text={'::inline-vis{file="plan.md"}'} - turnRequest={null} projectId="proj_a" onOpenPluginPanel={onOpenPluginPanel} /> @@ -561,11 +552,8 @@ describe("ConversationMessageContent assistant directives", () => { id="msg_a" threadId="thr_a" turnId="turn_a" - sourceSeqStart={1} - sourceSeqEnd={1} showActions={false} text={'::inline-vis{file="../secret.html"}'} - turnRequest={null} workspaceRootPath="/workspace/project" onOpenLocalFileLink={onOpenLocalFileLink} /> diff --git a/apps/app/src/components/ui/markdown-message-directives.tsx b/apps/app/src/components/ui/markdown-message-directives.tsx index f92065016e..c92953d071 100644 --- a/apps/app/src/components/ui/markdown-message-directives.tsx +++ b/apps/app/src/components/ui/markdown-message-directives.tsx @@ -40,7 +40,7 @@ const MESSAGE_DIRECTIVE_HAST_NAME = "bb-message-directive"; // `data-directive-index` for the component to read back. const MESSAGE_DIRECTIVE_INDEX_PROPERTY = "dataDirectiveIndex"; -export type MessageDirectiveRegistryEntry = ResolvedMessageDirective; +type MessageDirectiveRegistryEntry = ResolvedMessageDirective; /** Directive name → unique registration, or an explicit cross-plugin collision. */ export type MessageDirectiveRegistry = ReadonlyMap< @@ -69,18 +69,6 @@ export type MarkdownMessageDirectiveOpenThreadPanel = ( }, ) => boolean; -/** - * Resolved form held during a single MarkdownPreview render: registry + message - * identity + the index-aligned mount table filled by the remark transform. - */ -export interface ResolvedMessageDirectives { - mounts: MountedMessageDirective[]; - message: PluginMessageDirectiveProps["message"]; - openWorkspaceFile: PluginMessageDirectiveProps["openWorkspaceFile"]; - openThreadPanel: MarkdownMessageDirectiveOpenThreadPanel | null; - registry: MessageDirectiveRegistry; -} - /** * `remark-directive` emits three node kinds from a single `:` grammar. Only the * leaf form (`::name`) mounts a plugin component; the text form is handled diff --git a/apps/app/src/components/ui/markdown-preview.test.tsx b/apps/app/src/components/ui/markdown-preview.test.tsx index 139d92cfa4..28e48f1592 100644 --- a/apps/app/src/components/ui/markdown-preview.test.tsx +++ b/apps/app/src/components/ui/markdown-preview.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, @@ -35,18 +36,65 @@ afterEach(() => { vi.clearAllMocks(); }); -describe("MarkdownPreview", () => { - it("observes content width only when the preview renders a table", () => { - const observed: Element[] = []; - class ResizeObserverMock { - constructor(_callback: ResizeObserverCallback) {} - observe(target: Element) { - observed.push(target); - } - unobserve() {} - disconnect() {} +function mockResizeObserverDeliveries(): { + notifyResize: () => void; + observerCount: () => number; + observed: Element[]; +} { + const observed: Element[] = []; + const observers: Array<{ + callback: ResizeObserverCallback; + instance: ResizeObserver; + targets: Set; + }> = []; + + class ResizeObserverMock { + private readonly record: (typeof observers)[number]; + constructor(callback: ResizeObserverCallback) { + this.record = { + callback, + instance: this as unknown as ResizeObserver, + targets: new Set(), + }; + observers.push(this.record); + } + observe(target: Element): void { + observed.push(target); + this.record.targets.add(target); + } + unobserve(target: Element): void { + this.record.targets.delete(target); + } + disconnect(): void { + this.record.targets.clear(); } - vi.stubGlobal("ResizeObserver", ResizeObserverMock); + } + + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + return { + observed, + observerCount: () => observers.length, + notifyResize: () => { + act(() => { + for (const { callback, instance, targets } of observers) { + if (targets.size === 0) continue; + callback( + Array.from( + targets, + (target) => ({ target }) as unknown as ResizeObserverEntry, + ), + instance, + ); + } + }); + }, + }; +} + +describe("MarkdownPreview", () => { + it("shares one observer and observes content width only for table previews", () => { + const { notifyResize, observed, observerCount } = + mockResizeObserverDeliveries(); vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ bottom: 100, height: 100, @@ -64,24 +112,39 @@ describe("MarkdownPreview", () => { plain.unmount(); const { container } = render( - , + <> + + + , + ); + const breakouts = Array.from( + container.querySelectorAll("table"), + (table) => table.parentElement?.parentElement, ); - const table = container.querySelector("table"); - const breakout = table?.parentElement?.parentElement; - expect(observed).toHaveLength(1); - expect(observed[0]?.hasAttribute("data-markdown-preview")).toBe(true); - expect(breakout?.style.getPropertyValue("--md-content-w")).toBe("320px"); + expect(observerCount()).toBe(1); + expect(observed).toHaveLength(2); + expect( + observed.every((element) => + element.hasAttribute("data-markdown-preview"), + ), + ).toBe(true); + expect( + breakouts.every( + (breakout) => breakout?.style.getPropertyValue("--md-content-w") === "", + ), + ).toBe(true); + notifyResize(); + expect( + breakouts.every( + (breakout) => + breakout?.style.getPropertyValue("--md-content-w") === "320px", + ), + ).toBe(true); }); it("caps the table breakout at the nearest horizontally clipped ancestor", () => { - class ResizeObserverMock { - constructor(_callback: ResizeObserverCallback) {} - observe() {} - unobserve() {} - disconnect() {} - } - vi.stubGlobal("ResizeObserver", ResizeObserverMock); + const { notifyResize } = mockResizeObserverDeliveries(); // Every element is 300px wide at x=100 unless it sets data-left/data-width. vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( function (this: HTMLElement) { @@ -121,6 +184,7 @@ describe("MarkdownPreview", () => { const flush = renderClipped(400); const flushBreakout = flush.container.querySelector("table")?.parentElement?.parentElement; + notifyResize(); expect( flushBreakout?.style.getPropertyValue("--md-table-breakout-max"), ).toBe("300px"); @@ -130,6 +194,7 @@ describe("MarkdownPreview", () => { const roomy = renderClipped(600); const roomyBreakout = roomy.container.querySelector("table")?.parentElement?.parentElement; + notifyResize(); expect( roomyBreakout?.style.getPropertyValue("--md-table-breakout-max"), ).toBe("500px"); @@ -149,6 +214,7 @@ describe("MarkdownPreview", () => { ); const rootedBreakout = rooted.container.querySelector("table")?.parentElement?.parentElement; + notifyResize(); expect( rootedBreakout?.style.getPropertyValue("--md-table-breakout-max"), ).toBe("300px"); @@ -156,16 +222,7 @@ describe("MarkdownPreview", () => { }); it("skips height-only resize events for tables", () => { - let callback: ResizeObserverCallback | null = null; - class ResizeObserverMock { - constructor(cb: ResizeObserverCallback) { - callback = cb; - } - observe() {} - unobserve() {} - disconnect() {} - } - vi.stubGlobal("ResizeObserver", ResizeObserverMock); + const { notifyResize } = mockResizeObserverDeliveries(); let width = 320; vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( () => ({ @@ -186,17 +243,18 @@ describe("MarkdownPreview", () => { ); const breakout = container.querySelector("table")?.parentElement ?.parentElement as HTMLElement; + expect(breakout.style.getPropertyValue("--md-content-w")).toBe(""); + notifyResize(); expect(breakout.style.getPropertyValue("--md-content-w")).toBe("320px"); - expect(callback).not.toBeNull(); // Same width, different height: no style write. breakout.style.setProperty("--md-content-w", "sentinel"); - callback!([], {} as ResizeObserver); + notifyResize(); expect(breakout.style.getPropertyValue("--md-content-w")).toBe("sentinel"); // A width change re-measures. width = 480; - callback!([], {} as ResizeObserver); + notifyResize(); expect(breakout.style.getPropertyValue("--md-content-w")).toBe("480px"); }); @@ -349,7 +407,7 @@ describe("MarkdownPreview", () => { expect(resolveSrc).toHaveBeenCalledTimes(2); }); - it("lets link routing open absolute app-origin URLs", () => { + it("keeps absolute app-origin URLs on the app-route path", () => { const onOpenLink = vi.fn(() => true); const href = `${window.location.origin}/threads/thr_localhost`; @@ -362,7 +420,10 @@ describe("MarkdownPreview", () => { fireEvent.click(screen.getByRole("link", { name: "local thread" })); - expect(onOpenLink).toHaveBeenCalledWith({ href }); + expect(onOpenLink).not.toHaveBeenCalled(); + expect( + screen.getByRole("link", { name: "local thread" }).getAttribute("href"), + ).toBe(href); }); it("rewrites localhost link hrefs without changing the visible text", () => { @@ -448,4 +509,41 @@ describe("MarkdownPreview", () => { ); expect(container.textContent).toContain("keeps rendering."); }); + + it("closes a display math block whose `$$` delimiters are glued to the TeX (#1778)", async () => { + // `$$T_…` opens a math fence with the TeX as dropped meta and a trailing + // `…$$` never closes it, so the rest of the message used to render as one + // `.katex-error`. + const { container } = render( + , + ); + + await waitFor(() => + expect(container.querySelector(".katex-display")).not.toBeNull(), + ); + expect(container.querySelector(".katex-error")).toBeNull(); + // The first formula line is rendered, not dropped as fence meta. + expect( + container.querySelector(".katex-display annotation")?.textContent, + ).toContain("appearance"); + expect(container.querySelector("h2")?.textContent).toBe( + "Content after the formula", + ); + expect(container.querySelectorAll("li")).toHaveLength(2); + expect( + container.querySelector('a[href="https://example.com"]')?.textContent, + ).toBe("This should remain a link"); + }); }); diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx index 485f10d1b3..b3cce124f8 100644 --- a/apps/app/src/components/ui/markdown-preview.tsx +++ b/apps/app/src/components/ui/markdown-preview.tsx @@ -38,6 +38,7 @@ import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { ImageLightbox } from "./image-lightbox.js"; +import { normalizeMathFences } from "./markdown-math-fences.js"; import { markdownMayContainMath, useRehypeKatex, @@ -105,18 +106,16 @@ import { useRawThreadMentionResources, } from "@/components/thread/ThreadTitleMentions.js"; -export interface MarkdownPreviewProps { +interface MarkdownPreviewProps { allowHtml?: boolean; className?: string; content: string; - expandedImageAlt?: string; /** * Controls whether Markdown image nodes mount browser image subresources. * Use `"alt-text"` for untrusted generated previews that should retain a * readable placeholder without issuing a request to the image URL. */ imagePolicy?: MarkdownImagePolicy; - imageLightboxTitle?: string; linkRouting?: MarkdownLinkRouting; /** * When supplied, serialized `@thread:` tokens and exact raw persisted @@ -151,7 +150,7 @@ export interface MarkdownPreviewProps { urlTransform?: UrlTransform; } -export type MarkdownImagePolicy = "alt-text" | "render"; +type MarkdownImagePolicy = "alt-text" | "render"; export interface MarkdownThreadMentions { mentions: readonly PromptTextMention[]; @@ -475,11 +474,7 @@ const areMarkdownPreviewPropsEqual: MarkdownPreviewPropsEqual = ( (previous.allowHtml ?? false) === (next.allowHtml ?? false) && previous.className === next.className && previous.content === next.content && - (previous.expandedImageAlt ?? "Expanded image") === - (next.expandedImageAlt ?? "Expanded image") && (previous.imagePolicy ?? "render") === (next.imagePolicy ?? "render") && - (previous.imageLightboxTitle ?? "Expanded image preview") === - (next.imageLightboxTitle ?? "Expanded image preview") && previous.urlTransform === next.urlTransform && areMarkdownThreadMentionsEqual({ next: next.threadMentions, @@ -671,9 +666,13 @@ function MarkdownAnchor({ return; } - // Let timeline/terminal hosts claim web links first. Absolute app-origin - // URLs can still be browser destinations even though they resolve to an - // app route. + // Internal BB destinations belong to RouteAnchor so they participate in + // SPA history. URL preference routing only sees non-route destinations. + if (isAppRouteHref) { + return; + } + + // Let timeline/terminal/navigation hosts claim ordinary web links. if ( linkRouting?.onOpenLink && rewrittenHref && @@ -682,10 +681,6 @@ function MarkdownAnchor({ event.preventDefault(); return; } - - if (isAppRouteHref) { - return; - } }; const anchor = ( @@ -1370,6 +1365,116 @@ function setMarkdownContentWidthVariable({ element.style.setProperty(MARKDOWN_CONTENT_WIDTH_VARIABLE, `${width}px`); } +interface MarkdownTableGeometryRegistration { + breakout: HTMLElement; + clip: HTMLElement | null; + content: HTMLElement; + lastClipWidth: number; + lastContentWidth: number; +} + +type MarkdownTableBreakoutLimitMeasurement = + | { kind: "remove" } + | { kind: "set"; value: string } + | { kind: "unchanged" }; + +interface MarkdownTableGeometryMeasurement { + breakout: HTMLElement; + breakoutLimit: MarkdownTableBreakoutLimitMeasurement; + contentWidth: number; +} + +const markdownTableRegistrationsByElement = new Map< + HTMLElement, + Set +>(); +let sharedMarkdownTableResizeObserver: ResizeObserver | null = null; + +function measureMarkdownTableGeometry( + registrations: Iterable, +): void { + // Complete every geometry read before writing either CSS variable. Writing + // one table's variables first would make the next table's read recalculate + // layout while a long timeline's initial observer delivery is in progress. + const measurements: MarkdownTableGeometryMeasurement[] = []; + for (const registration of registrations) { + const { breakout, clip, content } = registration; + const contentWidth = content.getBoundingClientRect().width; + const clipWidth = clip?.clientWidth ?? -1; + if ( + contentWidth === registration.lastContentWidth && + clipWidth === registration.lastClipWidth + ) { + continue; + } + registration.lastContentWidth = contentWidth; + registration.lastClipWidth = clipWidth; + measurements.push({ + breakout, + breakoutLimit: readMarkdownTableBreakoutLimit({ breakout, clip }), + contentWidth, + }); + } + + for (const { breakout, breakoutLimit, contentWidth } of measurements) { + setMarkdownContentWidthVariable({ + element: breakout, + width: contentWidth, + }); + applyMarkdownTableBreakoutLimit({ breakout, measurement: breakoutLimit }); + } +} + +function getSharedMarkdownTableResizeObserver(): ResizeObserver { + sharedMarkdownTableResizeObserver ??= new ResizeObserver((entries) => { + const registrations = new Set(); + for (const entry of entries) { + if (!(entry.target instanceof HTMLElement)) continue; + for (const registration of markdownTableRegistrationsByElement.get( + entry.target, + ) ?? []) { + registrations.add(registration); + } + } + measureMarkdownTableGeometry(registrations); + }); + return sharedMarkdownTableResizeObserver; +} + +function observeMarkdownTableGeometry( + registration: MarkdownTableGeometryRegistration, +): () => void { + const elements = + registration.clip === null || registration.clip === registration.content + ? [registration.content] + : [registration.content, registration.clip]; + const observer = getSharedMarkdownTableResizeObserver(); + for (const element of elements) { + let registrations = markdownTableRegistrationsByElement.get(element); + if (!registrations) { + registrations = new Set(); + markdownTableRegistrationsByElement.set(element, registrations); + observer.observe(element); + } + registrations.add(registration); + } + + return () => { + for (const element of elements) { + const registrations = markdownTableRegistrationsByElement.get(element); + registrations?.delete(registration); + if (registrations?.size === 0) { + markdownTableRegistrationsByElement.delete(element); + sharedMarkdownTableResizeObserver?.unobserve(element); + } + } + if (markdownTableRegistrationsByElement.size === 0) { + sharedMarkdownTableResizeObserver?.disconnect(); + sharedMarkdownTableResizeObserver = null; + } + }; +} + function useMarkdownTableContentWidthVariable() { const breakoutRef = useRef(null); @@ -1380,38 +1485,22 @@ function useMarkdownTableContentWidthVariable() { return; } const clip = findHorizontalClipAncestor(content); - - // Streamed text grows the preview and the clip ancestor in height only. - // Skip those events: every table would otherwise read layout and write a - // style, and the write forces the next table's read to recalculate. - let lastContentWidth = -1; - let lastClipWidth = -1; - const measure = () => { - const contentWidth = content.getBoundingClientRect().width; - const clipWidth = clip?.clientWidth ?? -1; - if (contentWidth === lastContentWidth && clipWidth === lastClipWidth) { - return; - } - lastContentWidth = contentWidth; - lastClipWidth = clipWidth; - setMarkdownContentWidthVariable({ - element: breakout, - width: contentWidth, - }); - setMarkdownTableBreakoutLimitVariable({ breakout, clip }); + const registration: MarkdownTableGeometryRegistration = { + breakout, + clip, + content, + lastClipWidth: -1, + lastContentWidth: -1, }; - measure(); if (typeof ResizeObserver === "undefined") { + measureMarkdownTableGeometry([registration]); return; } - const observer = new ResizeObserver(measure); - observer.observe(content); - if (clip) { - observer.observe(clip); - } - return () => observer.disconnect(); + // The initial observer delivery gives us the geometry before paint without + // a synchronous layout read for every table while a long timeline mounts. + return observeMarkdownTableGeometry(registration); }, []); return breakoutRef; @@ -1445,21 +1534,20 @@ function findHorizontalClipAncestor(element: HTMLElement): HTMLElement | null { } /** - * Sets the widest breakout that keeps the table inside `clip`. The breakout is - * centered on its containing block (the breakout's parent), so the usable + * Reads the widest breakout that keeps the table inside `clip`. The breakout + * is centered on its containing block (the breakout's parent), so the usable * width is the parent content width plus twice the smaller side gap. */ -function setMarkdownTableBreakoutLimitVariable({ +function readMarkdownTableBreakoutLimit({ breakout, clip, }: { breakout: HTMLElement; clip: HTMLElement | null; -}): void { +}): MarkdownTableBreakoutLimitMeasurement { const parent = breakout.parentElement; if (!clip || !parent) { - breakout.style.removeProperty(MARKDOWN_TABLE_BREAKOUT_LIMIT_VARIABLE); - return; + return { kind: "remove" }; } // Positions are taken at scroll offset 0 of `clip`, so a horizontally // scrolled container does not change the result. @@ -1473,7 +1561,7 @@ function setMarkdownTableBreakoutLimitVariable({ cssPixels(parentStyle.paddingRight); const parentWidth = parentRight - parentLeft; if (parentWidth <= 0) { - return; + return { kind: "unchanged" }; } const clipLeft = clip.getBoundingClientRect().left + clip.clientLeft; const clipRight = clipLeft + clip.clientWidth; @@ -1481,10 +1569,24 @@ function setMarkdownTableBreakoutLimitVariable({ 0, Math.min(parentLeft - clipLeft, clipRight - parentRight), ); - breakout.style.setProperty( - MARKDOWN_TABLE_BREAKOUT_LIMIT_VARIABLE, - `${parentWidth + 2 * room}px`, - ); + return { kind: "set", value: `${parentWidth + 2 * room}px` }; +} + +function applyMarkdownTableBreakoutLimit({ + breakout, + measurement, +}: { + breakout: HTMLElement; + measurement: MarkdownTableBreakoutLimitMeasurement; +}): void { + if (measurement.kind === "remove") { + breakout.style.removeProperty(MARKDOWN_TABLE_BREAKOUT_LIMIT_VARIABLE); + } else if (measurement.kind === "set") { + breakout.style.setProperty( + MARKDOWN_TABLE_BREAKOUT_LIMIT_VARIABLE, + measurement.value, + ); + } } function cssPixels(value: string): number { @@ -1558,9 +1660,7 @@ function MarkdownPreviewComponent({ allowHtml = false, className, content, - expandedImageAlt = "Expanded image", imagePolicy = "render", - imageLightboxTitle = "Expanded image preview", linkRouting, threadMentions, promptMentions, @@ -1611,10 +1711,13 @@ function MarkdownPreviewComponent({ : markdownContent, [markdownContent, promptMentions], ); - const { frontmatter, body } = useMemo( - () => splitMarkdownFrontmatter(promptMarkdownContent), - [promptMarkdownContent], - ); + const { frontmatter, body } = useMemo(() => { + const split = splitMarkdownFrontmatter(promptMarkdownContent); + return { + frontmatter: split.frontmatter, + body: normalizeMathFences(split.body), + }; + }, [promptMarkdownContent]); // The remark transform fills this shared mount table on every parse. Keep it // stable while assistant text streams so the custom React component type // also stays stable and an already-complete directive does not remount when @@ -1761,8 +1864,8 @@ function MarkdownPreviewComponent({ setExpandedImageUrl(null)} /> diff --git a/apps/app/src/components/ui/markdown-prompt-blockquote-boundaries.ts b/apps/app/src/components/ui/markdown-prompt-blockquote-boundaries.ts index 215c0ccc38..02e8fd5a97 100644 --- a/apps/app/src/components/ui/markdown-prompt-blockquote-boundaries.ts +++ b/apps/app/src/components/ui/markdown-prompt-blockquote-boundaries.ts @@ -1,11 +1,11 @@ const MARKDOWN_FENCE_START_PATTERN = /^(?: {0,3})(`{3,}|~{3,})/u; -interface MarkdownFence { +export interface MarkdownFence { character: string; length: number; } -function trimMarkdownLineCarriageReturn(line: string): string { +export function trimMarkdownLineCarriageReturn(line: string): string { return line.endsWith("\r") ? line.slice(0, -1) : line; } @@ -17,7 +17,7 @@ function isPromptMarkdownBlockquoteLine(line: string): boolean { return /^ {0,3}>/u.test(trimMarkdownLineCarriageReturn(line)); } -function parseMarkdownFenceStart(line: string): MarkdownFence | null { +export function parseMarkdownFenceStart(line: string): MarkdownFence | null { const match = MARKDOWN_FENCE_START_PATTERN.exec( trimMarkdownLineCarriageReturn(line), ); @@ -28,7 +28,10 @@ function parseMarkdownFenceStart(line: string): MarkdownFence | null { return { character: marker[0]!, length: marker.length }; } -function isMarkdownFenceClose(line: string, fence: MarkdownFence): boolean { +export function isMarkdownFenceClose( + line: string, + fence: MarkdownFence, +): boolean { const value = trimMarkdownLineCarriageReturn(line); const leadingSpaces = /^ {0,3}/u.exec(value)?.[0].length ?? 0; let index = leadingSpaces; diff --git a/apps/app/src/components/ui/markdown-prompt-mentions.tsx b/apps/app/src/components/ui/markdown-prompt-mentions.tsx index ee7bf5e6c6..8861590c03 100644 --- a/apps/app/src/components/ui/markdown-prompt-mentions.tsx +++ b/apps/app/src/components/ui/markdown-prompt-mentions.tsx @@ -55,7 +55,7 @@ export interface IndexedPromptMention { serializedText: string; } -export interface SubstitutePromptMentionsResult { +interface SubstitutePromptMentionsResult { /** `text` with each mention span replaced by its sentinel. */ content: string; /** Resolved mentions, indexed to match the sentinel each one produced. */ diff --git a/apps/app/src/components/ui/markdown-thread-mentions.tsx b/apps/app/src/components/ui/markdown-thread-mentions.tsx index aeccb0e83f..ec1ccf1c58 100644 --- a/apps/app/src/components/ui/markdown-thread-mentions.tsx +++ b/apps/app/src/components/ui/markdown-thread-mentions.tsx @@ -15,6 +15,10 @@ import { resolveThreadMentionResource, } from "@/components/thread/timeline/ConversationMessageMentions.js"; import { + isMentionBoundary, + isMentionEndBoundary, + isRawThreadIdBoundary, + isRawThreadIdEndBoundary, useRawThreadMentionResource, useSidebarThreadMentionResource, useThreadMentionResource, @@ -210,33 +214,7 @@ function collectAuthoredMarkdownLinkNodes(tree: Nodes): WeakSet { return linkNodes; } -function isMentionBoundary(text: string, index: number): boolean { - const previous = text[index - 1]; - return previous === undefined || !/[\p{L}\p{N}_.+-]/u.test(previous); -} - -function isRawThreadIdBoundary(text: string, index: number): boolean { - const previous = text[index - 1]; - return ( - previous !== "/" && previous !== "\\" && isMentionBoundary(text, index) - ); -} - -function isMentionEndBoundary(text: string, index: number): boolean { - const next = text[index]; - if (next === undefined) return true; - if (next === ".") { - const afterPeriod = text[index + 1]; - return afterPeriod === undefined || /[\s,;:!?)}\]"'’”]/u.test(afterPeriod); - } - return !/[\p{L}\p{N}_.+\/-]/u.test(next); -} - -function isRawThreadIdEndBoundary(text: string, index: number): boolean { - return text[index] !== "\\" && isMentionEndBoundary(text, index); -} - -export interface RawThreadIdTextSegment { +interface RawThreadIdTextSegment { rawThreadId: string | null; text: string; } diff --git a/apps/app/src/components/ui/overflow-fade.tsx b/apps/app/src/components/ui/overflow-fade.tsx index c27c486976..be9249a64a 100644 --- a/apps/app/src/components/ui/overflow-fade.tsx +++ b/apps/app/src/components/ui/overflow-fade.tsx @@ -1,10 +1,10 @@ import { cn } from "@bb/shared-ui/lib/utils"; -export type OverflowFadePlacement = "above" | "below" | "left" | "right"; +type OverflowFadePlacement = "above" | "below" | "left" | "right"; export type OverflowFadeTone = "background" | "sidebar" | "surface-raised"; -export type OverflowFadeSize = "default" | "sm"; +type OverflowFadeSize = "default" | "sm"; -export interface OverflowFadeProps { +interface OverflowFadeProps { className?: string; placement: OverflowFadePlacement; tone?: OverflowFadeTone; diff --git a/apps/app/src/components/ui/page-shell.tsx b/apps/app/src/components/ui/page-shell.tsx index 37813573d6..04685bdcaa 100644 --- a/apps/app/src/components/ui/page-shell.tsx +++ b/apps/app/src/components/ui/page-shell.tsx @@ -4,9 +4,9 @@ import { PAGE_SHELL_CONTENT_STYLE } from "./page-shell-content-style.js"; import { cn } from "@bb/shared-ui/lib/utils"; import { OverflowFade } from "./overflow-fade.js"; -export type PageShellScrollBehavior = "bottom-anchor" | "static"; +type PageShellScrollBehavior = "bottom-anchor" | "static"; -export interface PageShellBaseProps { +interface PageShellBaseProps { children: ReactNode; footer?: ReactNode; scrollOverlay?: ReactNode; @@ -17,7 +17,7 @@ export interface PageShellBaseProps { maxWidthClassName?: string; } -export interface PageShellProps extends PageShellBaseProps { +interface PageShellProps extends PageShellBaseProps { scrollBehavior?: PageShellScrollBehavior; // Only meaningful with `scrollBehavior="bottom-anchor"`: persists and restores // the timeline scroll position per thread so switching away and back doesn't diff --git a/apps/app/src/components/ui/scroll-to-bottom-button.tsx b/apps/app/src/components/ui/scroll-to-bottom-button.tsx index 72b6e52b71..c4c3d2a109 100644 --- a/apps/app/src/components/ui/scroll-to-bottom-button.tsx +++ b/apps/app/src/components/ui/scroll-to-bottom-button.tsx @@ -1,23 +1,19 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { Icon } from "@bb/shared-ui/icon"; -export interface ScrollToBottomButtonProps { +interface ScrollToBottomButtonProps { visible: boolean; active?: boolean; onClick: () => void; - className?: string; - ariaLabel?: string; } export function ScrollToBottomButton({ visible, active = false, onClick, - className, - ariaLabel = "Scroll to latest event", }: ScrollToBottomButtonProps) { return ( -
+
- ); - })} + + + {option.label} + + {option.description ? ( + + {option.description} + + ) : null} + + + + ); + })} +
); } @@ -157,12 +155,12 @@ interface DetailRowProps { function DetailRow({ label, children }: DetailRowProps) { return ( -
- {label} -
+ + {label} +
{children}
-
+ ); } @@ -171,6 +169,7 @@ export function MachineSettingsView() { const navigate = useNavigate(); const hostsQuery = useHosts(); const systemConfig = useSystemConfig(); + const { localDaemonHostId, platform: localDaemonPlatform } = useHostDaemon(); const sidebarNavigationQuery = useSidebarNavigation(); const updateInventory = useUpdateInventory(); const renameHost = useRenameHost(); @@ -182,10 +181,11 @@ export function MachineSettingsView() { const hosts = hostsQuery.data; const host = hosts?.find((candidate) => candidate.id === hostId) ?? null; - const primaryHostId = - selectPrimaryHost(hosts, systemConfig.data?.primaryHostId ?? null)?.id ?? - null; + const primaryHostId = systemConfig.data?.primaryHostId ?? null; const isPrimary = host !== null && host.id === primaryHostId; + const showMachineIdentityBadges = (hosts?.length ?? 0) > 1; + const isThisMachine = + showMachineIdentityBadges && host !== null && host.id === localDaemonHostId; const projects: MachineProject[] = useMemo(() => { const navigation = sidebarNavigationQuery.data?.projects ?? []; @@ -199,24 +199,36 @@ export function MachineSettingsView() { const machine = updateInventory.machines.find( (candidate) => candidate.host.id === hostId, ); - const providerSummary = useMemo(() => { + // This links to Settings → Updates, so it must count what that page lists — + // updates, not install prompts — or it sends the reader to a page that has + // nothing matching the number they just clicked. + const updateIssueCount = (machine?.issues ?? []).filter( + isProviderCliUpdateIssue, + ).length; + const installedProviders = useMemo(() => { const status = machine?.providerStatus; - if (!status) return null; - return Object.values(status) - .filter((entry) => entry.installed) - .map((entry) => - entry.currentVersion - ? `${entry.displayName} ${entry.currentVersion}` - : entry.displayName, - ) - .join(" · "); + if (!status) return []; + return Object.entries(status).flatMap(([providerId, entry]) => { + if (!entry.installed) return []; + return [ + { + ...entry, + providerId, + ProviderIcon: getProviderIconInfo(providerId)?.icon, + }, + ]; + }); }, [machine?.providerStatus]); const now = Date.now(); const platformLabel = - isPrimary && systemConfig.data?.primaryHostPlatform - ? PLATFORM_LABELS[systemConfig.data.primaryHostPlatform] - : null; + host !== null && + host.id === localDaemonHostId && + localDaemonPlatform !== null + ? PLATFORM_LABELS[localDaemonPlatform] + : isPrimary && systemConfig.data?.primaryHostPlatform + ? PLATFORM_LABELS[systemConfig.data.primaryHostPlatform] + : null; if (hosts === undefined) { return ( @@ -240,7 +252,7 @@ export function MachineSettingsView() { Machines

- This machine is no longer paired. + Machine is no longer paired.

@@ -260,30 +272,39 @@ export function MachineSettingsView() { Machines -
+
- -

+

{host.name}

- {isPrimary ? this machine : null} + {isThisMachine ? ( + This machine + ) : null} + {showMachineIdentityBadges && isPrimary ? ( + Primary + ) : null} +
+
+ +

+ {headerMeta({ host, platformLabel, now })} +

-

- {headerMeta({ host, platformLabel, now })} -

- + { + renameHost.reset(); + setRenameOpen(true); + }, + }, + ]} + />
@@ -312,8 +333,67 @@ export function MachineSettingsView() { /> - -
+ + + + {host.status !== "connected" ? ( + Unavailable while offline + ) : machine?.statusPending ? ( + Checking… + ) : machine?.statusError ? ( + Status unavailable + ) : ( + <> + {installedProviders.length > 0 ? ( + + {installedProviders.map((entry) => ( + + {entry.ProviderIcon ? ( + + + + ) : null} + {entry.displayName} + {entry.currentVersion ? ( + {entry.currentVersion} + ) : null} + + ))} + + ) : ( + None installed + )} + {updateIssueCount > 0 ? ( + + + {updateIssueCount} to fix + + + ) : null} + + )} + + + + + + {projects.length === 0 ? ( None @@ -333,31 +413,6 @@ export function MachineSettingsView() { )} - - {host.status !== "connected" ? ( - Unavailable while offline - ) : machine?.statusPending ? ( - Checking… - ) : machine?.statusError ? ( - Status unavailable - ) : ( - <> - - {providerSummary && providerSummary.length > 0 - ? providerSummary - : "None installed"} - - {machine && machine.issues.length > 0 ? ( - - {machine.issues.length} to fix - - ) : null} - - )} - {updateStatus ?? "Up to date"} {hostCanRetryUpdate(host) ? ( @@ -381,7 +436,7 @@ export function MachineSettingsView() { ) : null} -
+
- + + + + +
diff --git a/apps/app/src/views/ProjectSettingsView.tsx b/apps/app/src/views/ProjectSettingsView.tsx index a9e5a1557e..e8bf152e01 100644 --- a/apps/app/src/views/ProjectSettingsView.tsx +++ b/apps/app/src/views/ProjectSettingsView.tsx @@ -6,7 +6,6 @@ import { useParams } from "react-router-dom"; import "@bb/shared-ui/icon-extended"; import { findLocalPathProjectSourceForHost, - isLocalPathProjectSource, type Host, type LocalPathProjectSource, } from "@bb/domain"; @@ -87,10 +86,7 @@ export function ProjectSettingsView() { { onSuccess: closeDialog }, ); } else if (target.kind === "update") { - const source = sources.find( - (candidate): candidate is LocalPathProjectSource => - isLocalPathProjectSource(candidate) && candidate.hostId === hostId, - ); + const source = sources.find((candidate) => candidate.hostId === hostId); if (!source) return; updateLocalSource.mutate( { projectId, sourceId: source.id, path }, @@ -129,10 +125,7 @@ export function ProjectSettingsView() { const pickerHostSourcePaths = useMemo(() => { if (!pickerHostId) return []; return sources - .filter( - (source): source is LocalPathProjectSource => - isLocalPathProjectSource(source) && source.hostId === pickerHostId, - ) + .filter((source) => source.hostId === pickerHostId) .map((source) => source.path); }, [pickerHostId, sources]); const pathExistence = useHostPathExistence( @@ -246,9 +239,7 @@ export function ProjectSettingsView() { {sources.map((source) => { const isPickerHostSource = - isLocalPathProjectSource(source) && - pickerHostId != null && - source.hostId === pickerHostId; + pickerHostId != null && source.hostId === pickerHostId; const isInvalid = isPickerHostSource && isHostPathMissing(pathExistence, source.path); diff --git a/apps/app/src/views/RootComposeMobileRecents.tsx b/apps/app/src/views/RootComposeMobileRecents.tsx index f0df169e52..1ad8475780 100644 --- a/apps/app/src/views/RootComposeMobileRecents.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router-dom"; import type { ThreadListEntry } from "@bb/domain"; import { ThreadStatusGlyph } from "@/components/sidebar/ThreadRow"; import { SIDEBAR_WORKING_STATUS_COLOR_CLASS } from "@/components/sidebar/sidebarRowClasses"; -import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { Icon } from "@bb/shared-ui/icon"; import { getThreadRoutePath, isProjectlessProjectId } from "@/lib/route-paths"; @@ -18,7 +18,7 @@ import { isUnreadDoneThread, resolveThreadListIndicator, type ThreadListIndicatorState, -} from "@/lib/thread-activity"; +} from "@bb/client-core"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { cn } from "@bb/shared-ui/lib/utils"; import { usePromptDraftHasInput } from "@/hooks/usePromptDraftStorage"; @@ -41,7 +41,7 @@ interface MobileRecentThreadRowProps { thread: ThreadListEntry; } -export interface RootComposeMobileRecentsProps { +interface RootComposeMobileRecentsProps { highlightedThreadId: string | null; projectNamesById: ReadonlyMap; showCreatingRow: boolean; diff --git a/apps/app/src/views/RootComposePanelTabContent.test.tsx b/apps/app/src/views/RootComposePanelTabContent.test.tsx new file mode 100644 index 0000000000..f44f4ee18a --- /dev/null +++ b/apps/app/src/views/RootComposePanelTabContent.test.tsx @@ -0,0 +1,244 @@ +// @vitest-environment jsdom + +import type { ComponentProps, ReactNode } from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createTerminalFixedPanelTab, + createWorkspaceFilePreviewFixedPanelTab, +} from "@/lib/fixed-panel-tabs-state"; +import { buildFileOpenerPanelTab } from "@/components/plugin/file-opener-tabs"; +import { RootComposePanelTabContent } from "./RootComposePanelTabContent"; + +vi.mock("@/components/secondary-panel/lazySecondaryPanelComponents", () => ({ + LazyFilePreview: () => null, + LazyHostFilePreviewTabContent: () => null, + LazyNewTabPage: () => null, + LazyProjectFilePreviewTabContent: ({ + activePath, + environmentId, + hostId, + projectId, + }: { + activePath: string; + environmentId: string | null; + hostId: string | null; + projectId: string; + }) => ( +
+ ), + LazyThreadStorageFilePreviewTabContent: () => null, + LazyThreadTerminalPanel: ({ terminalId }: { terminalId?: string }) => ( +
+ ), + LazyWorkspaceFilePreviewTabContent: ({ + activePath, + environmentId, + }: { + activePath: string; + environmentId: string; + }) => ( +
+ ), +})); + +vi.mock("@/components/plugin/PluginPanelActions", () => ({ + PluginPanelTabContent: ({ + fileOpenerOriginal, + }: { + fileOpenerOriginal?: ReactNode; + }) => fileOpenerOriginal ?? null, +})); + +vi.mock("@/hooks/queries/environment-queries", () => ({ + useEnvironment: (environmentId: string | null) => ({ + data: + environmentId === null + ? undefined + : { + hostId: `host-${environmentId}`, + path: `/workspace/${environmentId}`, + }, + }), +})); + +vi.mock("@/components/secondary-panel/useThreadStorageViewer", () => ({ + useThreadStorageViewer: () => ({ threadStorageRootPath: null }), +})); + +vi.mock("@/hooks/useHostDaemon", () => ({ + useHostDaemon: () => ({ isLocalDaemonHost: () => true }), +})); + +vi.mock("@/hooks/useLocalOpenTargets", () => ({ + useLocalOpenTargets: () => ({ + canOpenPreferredFileTarget: false, + openPathInPreferredFileTarget: vi.fn(), + }), +})); + +vi.mock("@/components/commands/AppCommandProvider", () => ({ + useAppCommandHandler: () => undefined, +})); + +type PanelContentProps = ComponentProps; + +const noop = () => {}; +const baseProps = { + activeTabId: null, + canCreateTerminal: true, + currentProjectId: "project-current", + isPanelOpen: true, + isPanelPersistedOpen: true, + isProjectless: false, + onActivateTab: noop, + onAutoFocusNewTabHandled: noop, + onAutoFocusTerminalHandled: noop, + onOpenBrowser: noop, + onOpenPanelLink: () => false, + onSelectFileSearchResult: noop, + onSelectionAddToChat: noop, + onStartTerminal: noop, + primaryHostId: "host-primary", + pluginActions: [], + projectSources: [], + projects: [], + rootPanelEnvironmentId: "env-current", + rootPanelThreadId: "thread-current", + rootProjectHostId: "host-current", + shouldAutoFocusNewTab: false, + shouldAutoFocusTerminal: false, + terminalTarget: { + kind: "environment", + environmentId: "env-current", + }, +} satisfies Omit; + +afterEach(cleanup); + +describe("RootComposePanelTabContent", () => { + it("renders each visible split pane from its own file tab model", () => { + const firstTab = createWorkspaceFilePreviewFixedPanelTab({ + environmentId: "env-first", + projectId: "project-current", + tab: { + lineRange: null, + path: "src/first.ts", + source: { kind: "working-tree" }, + statusLabel: null, + }, + }); + const secondTab = createWorkspaceFilePreviewFixedPanelTab({ + environmentId: "env-second", + projectId: "project-current", + tab: { + lineRange: null, + path: "src/second.ts", + source: { kind: "working-tree" }, + statusLabel: null, + }, + }); + + render( + <> + + + , + ); + + expect( + screen + .getByTestId("workspace-src/first.ts") + .getAttribute("data-environment-id"), + ).toBe("env-first"); + expect( + screen + .getByTestId("workspace-src/second.ts") + .getAttribute("data-environment-id"), + ).toBe("env-second"); + }); + + it("binds each split terminal body to its own terminal id", () => { + const firstTab = createTerminalFixedPanelTab({ terminalId: "term-first" }); + const secondTab = createTerminalFixedPanelTab({ + terminalId: "term-second", + }); + + render( + <> + + + , + ); + + expect(screen.getByTestId("terminal-term-first")).toBeTruthy(); + expect(screen.getByTestId("terminal-term-second")).toBeTruthy(); + }); + + it("keeps a persisted plugin opener route after compose context changes", () => { + const tab = buildFileOpenerPanelTab( + { id: "markdown", pluginId: "docs" }, + { + path: "persisted/readme.md", + source: { + kind: "workspace", + environmentId: null, + experimental_hostId: "host-opened", + projectId: "project-opened", + threadId: null, + }, + }, + { + environmentId: null, + kind: "workspace-file-preview", + projectId: "project-stale", + tab: { + lineRange: null, + path: "stale/readme.md", + source: { kind: "working-tree" }, + statusLabel: null, + }, + threadId: null, + }, + ); + + render( + , + ); + + const preview = screen.getByTestId("project-persisted/readme.md"); + expect(preview.getAttribute("data-environment-id")).toBeNull(); + expect(preview.getAttribute("data-host-id")).toBe("host-opened"); + expect(preview.getAttribute("data-project-id")).toBe("project-opened"); + }); +}); diff --git a/apps/app/src/views/RootComposePanelTabContent.tsx b/apps/app/src/views/RootComposePanelTabContent.tsx new file mode 100644 index 0000000000..3ff2690134 --- /dev/null +++ b/apps/app/src/views/RootComposePanelTabContent.tsx @@ -0,0 +1,531 @@ +import { useMemo, type ReactNode } from "react"; +import type { OpenInTargetContext } from "@bb/host-daemon-contract"; +import type { SidebarProject } from "@/hooks/queries/project-queries"; +import { findLocalPathProjectSourceForHost } from "@bb/domain"; +import type { PluginFileOpenerSource } from "@get-bb/plugin-sdk"; +import type { + PluginPanelFixedPanelTab, + SecondaryFileFixedPanelTab, +} from "@/lib/fixed-panel-tabs-state"; +import type { SecondaryPanelPaneRenderContext } from "@/components/secondary-panel/ThreadSecondaryPanel"; +import { + LazyFilePreview, + LazyHostFilePreviewTabContent, + LazyNewTabPage, + LazyProjectFilePreviewTabContent, + LazyThreadStorageFilePreviewTabContent, + LazyThreadTerminalPanel, + LazyWorkspaceFilePreviewTabContent, +} from "@/components/secondary-panel/lazySecondaryPanelComponents"; +import type { FileSearchSelection } from "@/components/secondary-panel/useThreadFileTabs"; +import { + PluginPanelTabContent, + type PluginPanelActionEntry, +} from "@/components/plugin/PluginPanelActions"; +import { + createFileOpenerOriginalTab, + parseFileOpenerParams, + type FileOpenerOriginalTab, +} from "@/components/plugin/file-opener-tabs"; +import { useEnvironment } from "@/hooks/queries/environment-queries"; +import { useThreadStorageViewer } from "@/components/secondary-panel/useThreadStorageViewer"; +import { useHostDaemon } from "@/hooks/useHostDaemon"; +import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; +import { + buildOpenInEditorHandler, + resolveEnvironmentOpenContext, +} from "./thread-detail/threadWorkspaceOpenPath"; +import { getFilePreviewLineRangeStart } from "@bb/client-core"; +import { resolveAbsoluteFilePath } from "@/lib/absolute-file-path"; +import { useAppCommandHandler } from "@/components/commands/AppCommandProvider"; +import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; + +export const ROOT_COMPOSE_FIXED_PANEL_STATE_ID = "root-compose"; + +export type RootComposeTerminalTarget = + | { kind: "environment"; environmentId: string } + | { kind: "host_path"; cwd: string | null; hostId: string }; + +interface RootComposePanelTabContentProps { + activeTabId: string | null; + canCreateTerminal: boolean; + currentProjectId: string; + isPanelOpen: boolean; + isPanelPersistedOpen: boolean; + isProjectless: boolean; + onActivateTab: (tabId: string) => void; + onAutoFocusNewTabHandled: () => void; + onAutoFocusTerminalHandled: () => void; + onOpenBrowser: () => void; + onOpenPanelLink: MarkdownPreviewLinkHandler; + onSelectFileSearchResult: (selection: FileSearchSelection) => void; + onSelectionAddToChat: (text: string) => void; + onStartTerminal: () => void; + pane: SecondaryPanelPaneRenderContext; + primaryHostId: string | null; + pluginActions: readonly PluginPanelActionEntry[]; + projectSources: SidebarProject["sources"]; + projects: readonly SidebarProject[] | undefined; + rootPanelEnvironmentId: string | null; + rootPanelThreadId: string | null; + rootProjectHostId: string | null; + shouldAutoFocusNewTab: boolean; + shouldAutoFocusTerminal: boolean; + tab: SecondaryFileFixedPanelTab; + terminalTarget: RootComposeTerminalTarget | null; +} + +interface RootComposeFilePreviewTabContentProps { + currentProjectId: string; + isFocused: boolean; + isPanelOpen: boolean; + isProjectless: boolean; + fileOpenerSource: PluginFileOpenerSource | null; + onSelectionAddToChat: (text: string) => void; + pluginPanelTab?: PluginPanelFixedPanelTab; + primaryHostId: string | null; + projectSources: SidebarProject["sources"]; + projects: readonly SidebarProject[] | undefined; + rootPanelEnvironmentId: string | null; + rootPanelThreadId: string | null; + rootProjectHostId: string | null; + tab: FileOpenerOriginalTab; +} + +function resolveHostOpenContext(args: { + hostId: string | null; + isLocal: boolean; + serverOrigin: string; +}): OpenInTargetContext | null { + if (args.hostId === null) return null; + if (args.isLocal) return { kind: "local" }; + return { + kind: "remote-ssh", + serverOrigin: args.serverOrigin, + hostId: args.hostId, + }; +} + +export function resolveRootComposeProjectFileRouting({ + fileOpenerSource, + selectedEnvironmentId, + selectedHostId, +}: { + fileOpenerSource: PluginFileOpenerSource | null; + selectedEnvironmentId: string | null; + selectedHostId: string | null; +}): { environmentId: string | null; hostId: string | null } { + // Root file tabs survive compose context changes, so a plugin opener's + // persisted project route must outrank the newly selected environment/host. + if ( + fileOpenerSource?.kind === "workspace" && + fileOpenerSource.environmentId === null && + fileOpenerSource.projectId !== null + ) { + return { + environmentId: null, + hostId: fileOpenerSource.experimental_hostId ?? null, + }; + } + return { + environmentId: selectedEnvironmentId, + hostId: selectedHostId, + }; +} + +export function RootComposePanelTabContent({ + activeTabId, + canCreateTerminal, + currentProjectId, + isPanelOpen, + isPanelPersistedOpen, + isProjectless, + onActivateTab, + onAutoFocusNewTabHandled, + onAutoFocusTerminalHandled, + onOpenBrowser, + onOpenPanelLink, + onSelectFileSearchResult, + onSelectionAddToChat, + onStartTerminal, + pane, + primaryHostId, + pluginActions, + projectSources, + projects, + rootPanelEnvironmentId, + rootPanelThreadId, + rootProjectHostId, + shouldAutoFocusNewTab, + shouldAutoFocusTerminal, + tab, + terminalTarget, +}: RootComposePanelTabContentProps) { + switch (tab.kind) { + case "browser": + return null; + case "terminal": + return terminalTarget === null ? null : ( + + ); + case "new-tab": + return ( + { + onActivateTab(tab.id); + onSelectFileSearchResult(selection); + }} + recentItemsThreadId={ROOT_COMPOSE_FIXED_PANEL_STATE_ID} + onOpenBrowser={ + rootPanelThreadId + ? () => { + onActivateTab(tab.id); + onOpenBrowser(); + } + : undefined + } + onStartTerminal={ + canCreateTerminal + ? () => { + onActivateTab(tab.id); + onStartTerminal(); + } + : undefined + } + pluginActions={pluginActions} + showFileSearch={!isProjectless} + /> + ); + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + return ( + + ); + case "plugin-panel": { + const fileOpenerFile = parseFileOpenerParams(tab.paramsJson); + const originalTab = createFileOpenerOriginalTab(tab); + if (originalTab === null) { + return ( + + ); + } + return ( + + ); + } + } +} + +function RootComposeFilePreviewTabContent({ + currentProjectId, + fileOpenerSource, + isFocused, + isPanelOpen, + isProjectless, + onSelectionAddToChat, + pluginPanelTab, + primaryHostId, + projectSources, + projects, + rootPanelEnvironmentId, + rootPanelThreadId, + rootProjectHostId, + tab, +}: RootComposeFilePreviewTabContentProps) { + const environmentId = + fileOpenerSource === null + ? (tab.environmentId ?? rootPanelEnvironmentId) + : fileOpenerSource.environmentId; + const environmentQuery = useEnvironment(environmentId, { + enabled: environmentId !== null, + staleTime: 5_000, + }); + const environment = environmentQuery.data; + const storageThreadId = + tab.kind === "thread-storage-file-preview" + ? fileOpenerSource === null + ? (tab.threadId ?? rootPanelThreadId) + : fileOpenerSource.threadId + : null; + const { threadStorageRootPath } = useThreadStorageViewer({ + fileListEnabled: storageThreadId !== null, + threadId: storageThreadId ?? undefined, + }); + const projectPreviewId = + tab.kind === "workspace-file-preview" && tab.environmentId === null + ? fileOpenerSource?.kind === "workspace" + ? fileOpenerSource.projectId + : (tab.projectId ?? currentProjectId) + : null; + const previewProjectSources = + projectPreviewId === null + ? [] + : projectPreviewId === currentProjectId + ? projectSources + : (projects?.find((project) => project.id === projectPreviewId) + ?.sources ?? []); + const projectFilePreviewRouting = resolveRootComposeProjectFileRouting({ + fileOpenerSource, + selectedEnvironmentId: rootPanelEnvironmentId, + selectedHostId: rootProjectHostId, + }); + const projectSourceRoutingHostId = + projectFilePreviewRouting.environmentId === null + ? (projectFilePreviewRouting.hostId ?? primaryHostId) + : null; + const projectPreviewRootPath = + projectPreviewId === null + ? null + : projectFilePreviewRouting.environmentId !== null + ? (environment?.path ?? null) + : projectSourceRoutingHostId !== null + ? (findLocalPathProjectSourceForHost( + previewProjectSources, + projectSourceRoutingHostId, + )?.path ?? null) + : null; + const projectPreviewHostId = + projectPreviewRootPath === null + ? null + : projectFilePreviewRouting.environmentId !== null + ? (environment?.hostId ?? null) + : projectSourceRoutingHostId; + const { isLocalDaemonHost } = useHostDaemon(); + const serverOrigin = window.location.origin; + const environmentOpenContext = resolveEnvironmentOpenContext({ + environment, + threadEnvironmentIsLocal: environment + ? isLocalDaemonHost(environment.hostId) + : false, + serverOrigin, + }); + const projectOpenContext = resolveHostOpenContext({ + hostId: projectPreviewHostId, + isLocal: isLocalDaemonHost(projectPreviewHostId), + serverOrigin, + }); + const openContext = + tab.kind === "workspace-file-preview" && tab.environmentId === null + ? projectOpenContext + : environmentOpenContext; + const { canOpenPreferredFileTarget, openPathInPreferredFileTarget } = + useLocalOpenTargets({ + enabled: openContext !== null, + ...(openContext ? { openContext } : {}), + }); + const workspaceRootPath = environment?.path ?? null; + const relativeFileRootPath = + tab.kind === "workspace-file-preview" + ? tab.environmentId === null + ? projectPreviewRootPath + : workspaceRootPath + : tab.kind === "thread-storage-file-preview" + ? threadStorageRootPath + : null; + const openRelativeFileInEditor = useMemo( + () => + buildOpenInEditorHandler({ + rootPath: relativeFileRootPath, + canOpenPreferredTarget: canOpenPreferredFileTarget, + openInPreferredTarget: openPathInPreferredFileTarget, + }), + [ + canOpenPreferredFileTarget, + openPathInPreferredFileTarget, + relativeFileRootPath, + ], + ); + const hostFileLineNumber = getFilePreviewLineRangeStart({ + lineRange: tab.kind === "host-file-preview" ? tab.lineRange : null, + }); + const openHostFileInEditor = + tab.kind === "host-file-preview" && canOpenPreferredFileTarget + ? (path: string) => { + void openPathInPreferredFileTarget({ + lineNumber: hostFileLineNumber, + path, + }); + } + : undefined; + const onOpenInEditor = + tab.kind === "host-file-preview" + ? openHostFileInEditor + : openRelativeFileInEditor; + + useAppCommandHandler("workspace.openPreferred", () => { + if (!isFocused || onOpenInEditor === undefined) return false; + onOpenInEditor(tab.path); + return true; + }); + + let original: ReactNode; + switch (tab.kind) { + case "workspace-file-preview": { + const copyPath = resolveAbsoluteFilePath({ + path: tab.path, + rootPath: + tab.environmentId === null + ? projectPreviewRootPath + : workspaceRootPath, + }); + original = + tab.environmentId !== null ? ( + + ) : projectPreviewId !== null ? ( + + ) : ( + + ); + break; + } + case "host-file-preview": { + const threadId = + fileOpenerSource === null + ? (tab.threadId ?? rootPanelThreadId) + : fileOpenerSource.threadId; + original = + threadId && environmentId ? ( + + ) : ( + + ); + break; + } + case "thread-storage-file-preview": { + const copyPath = resolveAbsoluteFilePath({ + path: tab.path, + rootPath: threadStorageRootPath, + }); + original = storageThreadId ? ( + + ) : ( + + ); + break; + } + } + + return pluginPanelTab === undefined ? ( + original + ) : ( + + ); +} diff --git a/apps/app/src/views/RootComposeRightPanelToggle.test.tsx b/apps/app/src/views/RootComposeRightPanelToggle.test.tsx index bd7c727436..fea13e1ddf 100644 --- a/apps/app/src/views/RootComposeRightPanelToggle.test.tsx +++ b/apps/app/src/views/RootComposeRightPanelToggle.test.tsx @@ -2,29 +2,11 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - resolveRootComposePanelTogglePlacement, - RootComposeRightPanelToggle, -} from "./RootComposeView"; +import { RootComposeRightPanelToggle } from "./RootComposeView"; afterEach(cleanup); describe("RootComposeRightPanelToggle", () => { - it("hands the floating control off to aligned panel chrome when open", () => { - expect( - resolveRootComposePanelTogglePlacement({ - isHosted: false, - isOpen: false, - }), - ).toEqual({ inlinePanelToggle: "button", showPinnedToggle: true }); - expect( - resolveRootComposePanelTogglePlacement({ isHosted: false, isOpen: true }), - ).toEqual({ inlinePanelToggle: "button", showPinnedToggle: false }); - expect( - resolveRootComposePanelTogglePlacement({ isHosted: true, isOpen: true }), - ).toEqual({ inlinePanelToggle: "button", showPinnedToggle: false }); - }); - it("uses a disclosure state without painting the whole click target as selected", () => { const onToggle = vi.fn(); diff --git a/apps/app/src/views/RootComposeSecondaryContent.test.tsx b/apps/app/src/views/RootComposeSecondaryContent.test.tsx index e9cd633fa4..fc55198b9d 100644 --- a/apps/app/src/views/RootComposeSecondaryContent.test.tsx +++ b/apps/app/src/views/RootComposeSecondaryContent.test.tsx @@ -34,7 +34,6 @@ interface RenderRootComposeArgs { isCompactViewport: boolean; isSecondaryPanelOpen: boolean; isTopRow?: boolean; - panelTogglePositionClassName?: string; } type TestDesktopWindow = { @@ -156,17 +155,15 @@ function createSecondaryPanel( return { activeTab: null, canUseGitUi: false, - fileTabs: [], + tabs: [], + fixedTabs: [], isOpen, metadataContent: null, onCollapse: noop, onClose: noop, - onFileTabReorder: noop, + onTabReorder: noop, onOpenNewTab: noop, - onPanelChange: noop, onPanelFocus: noop, - showGitDiffTab: false, - showInfoTab: false, }; } @@ -201,10 +198,6 @@ function renderRootCompose(args: RenderRootComposeArgs) { undefined} - panelTogglePositionClassName={ - renderArgs.panelTogglePositionClassName ?? - ROOT_COMPOSE_PINNED_PANEL_TOGGLE_POSITION_CLASS - } secondaryPanel={createSecondaryPanel(renderArgs.isSecondaryPanelOpen)} >
@@ -224,10 +217,6 @@ function renderRootCompose(args: RenderRootComposeArgs) { undefined} - panelTogglePositionClassName={ - renderArgs.panelTogglePositionClassName ?? - ROOT_COMPOSE_PINNED_PANEL_TOGGLE_POSITION_CLASS - } secondaryPanel={createSecondaryPanel( renderArgs.isSecondaryPanelOpen, )} diff --git a/apps/app/src/views/RootComposeSecondaryContent.tsx b/apps/app/src/views/RootComposeSecondaryContent.tsx index dbe4d1477a..08da6614ca 100644 --- a/apps/app/src/views/RootComposeSecondaryContent.tsx +++ b/apps/app/src/views/RootComposeSecondaryContent.tsx @@ -43,7 +43,7 @@ export const ROOT_COMPOSE_PINNED_PANEL_TOGGLE_POSITION_CLASS = type RootSecondaryPanelProps = Omit< ComponentProps, - | "browserDeck" + | "renderBrowserDeck" | "drawerFallback" | "isConversationCollapsed" | "onToggleConversationCollapse" @@ -51,7 +51,10 @@ type RootSecondaryPanelProps = Omit< | "showNewTabButton" > & { renderBrowserDeck?: (args: { + activeBrowserTabId: string | null; + canHandleBrowserCommands: boolean; canShowNativeBrowserView: boolean; + onNativeFocus: () => void; }) => ReactNode; }; @@ -60,7 +63,6 @@ interface RootComposeSecondaryContentProps { contentClassName?: string; isSecondaryPanelOpen: boolean; onToggleSecondaryPanel: () => void; - panelTogglePositionClassName: string; secondaryPanel: RootSecondaryPanelProps; } @@ -82,7 +84,6 @@ export function RootComposeSecondaryContent({ contentClassName, isSecondaryPanelOpen, onToggleSecondaryPanel, - panelTogglePositionClassName, secondaryPanel, }: RootComposeSecondaryContentProps) { const paneContext = useOptionalPaneContext(); @@ -114,7 +115,7 @@ export function RootComposeSecondaryContent({ data-testid="root-compose-drag-strip-toggle-cutout" className={cn( "absolute", - panelTogglePositionClassName, + ROOT_COMPOSE_PINNED_PANEL_TOGGLE_POSITION_CLASS, COARSE_POINTER_HEADER_ICON_BUTTON_CLASS, MACOS_APP_REGION_NO_DRAG_CLASS, )} @@ -160,7 +161,15 @@ export function RootComposeSecondaryContent({ } - browserDeck={renderBrowserDeck?.({ canShowNativeBrowserView })} + renderBrowserDeck={(activeBrowserTabId, pane) => + renderBrowserDeck?.({ + activeBrowserTabId, + canHandleBrowserCommands: + canShowNativeBrowserView && pane.isFocused, + canShowNativeBrowserView, + onNativeFocus: pane.onFocusPane, + }) + } renderAsDrawer={presentation === "drawer"} isConversationCollapsed={false} onToggleConversationCollapse={onToggleMainCollapse} diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts index ee28b82aec..9e76e33706 100644 --- a/apps/app/src/views/RootComposeView.test.ts +++ b/apps/app/src/views/RootComposeView.test.ts @@ -17,13 +17,13 @@ import { hasPromptOptionValueChanged, mergeMissingPromptDraftAttachments, resolveNewThreadProjectDefaultsState, + resolveNewThreadSubmitDisabledReason, restorePromptDraftAfterOptionChange, + type ResolveNewThreadSubmitDisabledReasonArgs, } from "@/components/promptbox/NewThreadComposer"; -import { subscribeComposerFocusRequests } from "@/lib/composer-focus-requests"; -import { getProjectStoredPromptAttachmentPaths } from "@/lib/prompt-draft"; -import { THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY } from "@/lib/thread-handoff-request"; +import { getProjectStoredPromptAttachmentPaths } from "@bb/client-core"; +import { THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY } from "@bb/client-core"; import { - buildRootComposeNewTabFileTab, buildRootComposeTerminalSessions, buildMobileRecentThreads, canCreateRootComposeTerminal, @@ -31,12 +31,11 @@ import { readSectionIdFromLocationState, readRootComposeSectionTargetFromLocationState, readInitialPromptFromLocationState, - requestRootComposePluginFocus, - resolveRootComposePanelThreadId, shouldReplaceInitialPromptFromLocationState, shouldStartComposingFromLocationState, shouldNavigateAfterThreadCreate, } from "./RootComposeView"; +import { resolveRootComposeProjectFileRouting } from "./RootComposePanelTabContent"; import { resolveProjectSourceWorktreeDisabledReason, resolveComposeHostId, @@ -45,42 +44,51 @@ import { resolveRootComposeProviderRouting, } from "./root-compose-environment-selection"; -describe("requestRootComposePluginFocus", () => { - it("routes host focus through the subscriber that reveals the root composer", () => { - let focusRequests = 0; - const unsubscribe = subscribeComposerFocusRequests( - "bb.promptDraft.new-thread", - () => { - focusRequests += 1; - }, - ); - - requestRootComposePluginFocus("bb.promptDraft.new-thread"); +describe("root-compose project file routing", () => { + it("uses a persisted opener host instead of the newly selected context", () => { + expect( + resolveRootComposeProjectFileRouting({ + fileOpenerSource: { + kind: "workspace", + threadId: null, + environmentId: null, + projectId: "proj_opened", + experimental_hostId: "host_opened", + }, + selectedEnvironmentId: "env_selected", + selectedHostId: "host_selected", + }), + ).toEqual({ environmentId: null, hostId: "host_opened" }); + }); - expect(focusRequests).toBe(1); - unsubscribe(); + it("keeps primary-host routing when a persisted opener omits a host", () => { + expect( + resolveRootComposeProjectFileRouting({ + fileOpenerSource: { + kind: "workspace", + threadId: null, + environmentId: null, + projectId: "proj_opened", + }, + selectedEnvironmentId: null, + selectedHostId: "host_selected", + }), + ).toEqual({ environmentId: null, hostId: null }); }); -}); -describe("new-thread right-panel tabs", () => { - it("renders the launcher as the same visible, closable tab used by threads", () => { - const onClose = () => undefined; - const onSelect = () => undefined; - const tab = buildRootComposeNewTabFileTab({ - activeTabId: "new-tab", - onClose, - onSelect, - tabId: "new-tab", + it("retains live routing for a native project file tab", () => { + expect( + resolveRootComposeProjectFileRouting({ + fileOpenerSource: null, + selectedEnvironmentId: "env_selected", + selectedHostId: "host_selected", + }), + ).toEqual({ + environmentId: "env_selected", + hostId: "host_selected", }); - - expect(tab.filename).toBe("New tab"); - expect(tab.isActive).toBe(true); - expect(tab.isHidden).toBeUndefined(); - expect(tab.onClose).toBe(onClose); - expect(tab.onSelect).toBe(onSelect); }); }); - describe("resolveNewThreadProjectDefaultsState", () => { const storedDefaults = { providerId: "codex", @@ -143,6 +151,97 @@ describe("resolveNewThreadProjectDefaultsState", () => { }); }); +describe("resolveNewThreadSubmitDisabledReason", () => { + const readyState = { + branchMutationBlockerTitle: null, + isCopyingAttachments: false, + isLoadingModels: false, + isSubmitting: false, + isUploading: false, + managedWorktreeUnavailableReason: null, + modelLoadError: null, + projectDefaultsStatus: "resolved", + projectDefaultsUnavailable: false, + promptInputEmpty: false, + providerDisplayName: "Codex", + selectedProviderId: "codex", + selectedThreadModel: "gpt-5.6-sol", + submissionEnvironmentUnavailable: false, + } satisfies ResolveNewThreadSubmitDisabledReasonArgs; + + it.each< + [ + label: string, + change: Partial, + reason: string, + ] + >([ + [ + "model loading after a machine switch", + { isLoadingModels: true }, + "Loading models from the selected machine...", + ], + [ + "provider setup failure", + { + modelLoadError: { + providerId: "codex", + code: "auth_required", + }, + }, + "Could not load models for Codex. Authentication is required.", + ], + [ + "project-default failure", + { + projectDefaultsStatus: "error", + projectDefaultsUnavailable: true, + }, + "Could not load the project's execution defaults.", + ], + [ + "an incomplete environment selection", + { submissionEnvironmentUnavailable: true }, + "Select an environment.", + ], + [ + "an unavailable worktree", + { + managedWorktreeUnavailableReason: + "Project source has no commits. Create an initial commit before creating a worktree", + }, + "Project source has no commits. Create an initial commit before creating a worktree", + ], + [ + "a blocked branch checkout", + { branchMutationBlockerTitle: "Checkout blocked by uncommitted changes" }, + "Checkout blocked by uncommitted changes", + ], + [ + "an empty prompt", + { promptInputEmpty: true }, + "Enter a prompt or attach a file.", + ], + ])("reports %s", (_label, change, reason) => { + expect( + resolveNewThreadSubmitDisabledReason({ ...readyState, ...change }), + ).toBe(reason); + }); + + it("returns no reason when every submission requirement is ready", () => { + expect(resolveNewThreadSubmitDisabledReason(readyState)).toBeNull(); + }); + + it("allows a selected fallback model after a transient model-list failure", () => { + expect( + resolveNewThreadSubmitDisabledReason({ + ...readyState, + modelLoadError: { providerId: "claude-code", code: "timeout" }, + }), + ).toBeNull(); + }); +}); + interface MakeThreadArgs { id: string; projectId: string; @@ -1050,49 +1149,6 @@ describe("buildRootComposeTerminalSessions", () => { }); }); -describe("resolveRootComposePanelThreadId", () => { - it("uses the most-recent thread from the selected reuse worktree", () => { - expect( - resolveRootComposePanelThreadId({ - environmentId: "env_b", - reuseThreadOptions: [ - { - environmentId: "env_a", - branchName: "main", - name: null, - threads: [{ id: "thr_a", title: "Thread A" }], - }, - { - environmentId: "env_b", - branchName: "feature", - name: "Feature worktree", - threads: [ - { id: "thr_b_recent", title: "Recent thread" }, - { id: "thr_b_old", title: "Old thread" }, - ], - }, - ], - }), - ).toBe("thr_b_recent"); - }); - - it("returns null without a selected reuse worktree", () => { - expect( - resolveRootComposePanelThreadId({ - environmentId: null, - reuseThreadOptions: [ - { - environmentId: "env_a", - branchName: "main", - name: null, - threads: [{ id: "thr_a", title: "Thread A" }], - }, - ], - }), - ).toBeNull(); - }); -}); - describe("canCreateRootComposeTerminal", () => { const connectedHostIds = new Set(["host_1"]); diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 78db6a2a44..e70396f762 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -1,10 +1,4 @@ -import { - useCallback, - useEffect, - useMemo, - useState, - type ReactNode, -} from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { findCachedProviderInfo } from "@/hooks/queries/system-queries"; @@ -16,7 +10,6 @@ import { type ServiceTier, type ThreadListEntry, } from "@bb/domain"; -import type { OpenInTargetContext } from "@bb/host-daemon-contract"; import type { NewThreadRequest } from "@get-bb/plugin-sdk"; import type { SidebarBootstrapResponse, @@ -26,7 +19,7 @@ import { NewThreadComposer, type NewThreadComposerState, } from "@/components/promptbox/NewThreadComposer"; -import { CodexCliVersionBanner } from "@/components/promptbox/banner/CodexCliVersionBanner"; +import { ProviderCliVersionBanner } from "@/components/promptbox/banner/ProviderCliVersionBanner"; import { buildProviderCliIssue, hasProviderCliAction, @@ -42,20 +35,14 @@ import { type ProjectMachineSetupCompletion, type ProjectMachineSetupDialogTarget, } from "@/components/dialogs/ProjectMachineSetupDialog"; -import type { ReuseThreadOption } from "@/components/pickers/WorktreePicker"; import { HEADER_ICON_BUTTON_CLASS } from "@/components/layout/AppPageHeader"; +import { useRightPanelToggleIconName } from "@/components/secondary-panel/panelToggleControlState"; import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint"; -import type { SecondaryPanelFileTab } from "@/components/secondary-panel/ThreadSecondaryPanel"; -import { - LazyBrowserTabDeck, - LazyFilePreview, - LazyHostFilePreviewTabContent, - LazyNewTabPage, - LazyProjectFilePreviewTabContent, - LazyThreadStorageFilePreviewTabContent, - LazyThreadTerminalPanel, - LazyWorkspaceFilePreviewTabContent, -} from "@/components/secondary-panel/lazySecondaryPanelComponents"; +import type { + SecondaryPanelPaneRenderContext, + SecondaryPanelRenderableTab, +} from "@/components/secondary-panel/ThreadSecondaryPanel"; +import { LazyBrowserTabDeck } from "@/components/secondary-panel/lazySecondaryPanelComponents"; import type { BrowserAddressFocusRequest } from "@/components/secondary-panel/BrowserTabContent"; import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; import { Icon } from "@bb/shared-ui/icon"; @@ -66,10 +53,8 @@ import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { COARSE_POINTER_COMPACT_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { PluginIcon } from "@/components/plugin/PluginIcon"; -import { - PluginPanelTabContent, - usePluginNewThreadPanelActions, -} from "@/components/plugin/PluginPanelActions"; +import type { FileOpenerOverride } from "@/lib/plugin-slot-resolvers"; +import { usePluginNewThreadPanelActions } from "@/components/plugin/PluginPanelActions"; import { usePluginSlots } from "@/lib/plugin-slots"; import { useCreateThread } from "@/hooks/mutations/thread-runtime-mutations"; import { @@ -82,8 +67,6 @@ import { } from "@/hooks/queries/thread-terminal-queries"; import { useEnvironment } from "@/hooks/queries/environment-queries"; import { useHostProviderCliStatus } from "@/hooks/queries/system-queries"; -import { useHostDaemon } from "@/hooks/useHostDaemon"; -import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; import { requestComposerFocus, subscribeComposerFocusRequests, @@ -91,17 +74,16 @@ import { import { PluginComposerHostProvider } from "@/components/plugin/plugin-composer-host"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject"; -import { getProjectScopedStorageKey } from "@/lib/project-scoped-storage"; -import type { PromptDraftAttachment } from "@/lib/prompt-draft"; +import type { PromptDraftAttachment } from "@bb/client-core"; import { buildForkThreadRequest, FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY, type ForkThreadCreateSeed, -} from "@/lib/fork-thread-request"; +} from "@bb/client-core"; import { buildThreadHandoffPromptDraft, readThreadHandoffCreateSeedFromLocationState, -} from "@/lib/thread-handoff-request"; +} from "@bb/client-core"; import { useNavigateToThreadAfterCreatePreference } from "@/lib/root-compose-create-preference"; import { getThreadRoutePath, @@ -109,7 +91,6 @@ import { getRootComposeRoutePath, isRoutePath, } from "@/lib/route-paths"; -import { resolveAbsoluteFilePath } from "@/lib/absolute-file-path"; import { getBrowserUrlHost } from "@/lib/browser-url"; import { getDesktopBrowserApi, @@ -124,18 +105,27 @@ import { useUpdateFixedPanelTabsState, } from "@/lib/fixed-panel-tabs"; import { createNewTabFixedPanelTab } from "@/lib/fixed-panel-tabs-state"; -import type { ThreadSecondaryPanel as ThreadSecondaryPanelTab } from "@/lib/thread-secondary-panel"; -import { - getFilePreviewLineRangeStart, - type HostFileTabState, - type ThreadStorageFileTabState, - type WorkspaceFileTabState, -} from "@/lib/file-preview"; +import type { + HostFileTabState, + ThreadStorageFileTabState, + WorkspaceFileTabState, +} from "@bb/client-core"; import { resolveUrlOpenTarget, useOpenLinksInAppBrowserPreference, } from "@/lib/in-app-browser-link-preference"; import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; +import { UrlOpenRoutingProvider } from "@/lib/url-open-routing"; +import { + AppNavigationHostProvider, + type AppFilePreviewIntent, + type AppFixedTabOpenIntent, +} from "@/lib/app-navigation-host"; +import { openAppFixedTabFromDestinations } from "@/lib/app-fixed-tab-navigation"; +import { + normalizeExperimentalFileOpenOptions, + toFilePreviewLineRange, +} from "@/lib/live-file-navigation"; import { useRootComposeProjectId, useSetRootComposeProjectId, @@ -155,16 +145,14 @@ import { useThreadFileTabs, type FileSearchSelection, } from "@/components/secondary-panel/useThreadFileTabs"; -import { isSecondaryFileTab } from "@/components/secondary-panel/secondaryPanelTabState"; -import { resolveRightPanelFileVisual } from "@/components/secondary-panel/rightPanelFileVisuals"; +import { isSecondaryFileTab } from "@bb/client-core"; +import { RightPanelFileTabIcon } from "@/components/secondary-panel/RightPanelFileTabIcon"; import { DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS, - terminalStatusLabel, } from "@/components/thread/terminal/useThreadTerminalController"; import { buildTerminalSyncedSecondaryFileTabs, - findActiveTerminalIdInSecondaryFileTabs, getRetainedTerminalTabId, syncTerminalTabsInFixedPanelState, } from "@/components/secondary-panel/terminalPanelTabs"; @@ -177,49 +165,25 @@ import { useThreadSecondaryPanelVisibility, } from "./thread-detail/useThreadSecondaryPanelVisibility"; import type { ThreadSecondaryPanelHostFileOpenHandler } from "./thread-detail/useThreadSecondaryPanelVisibility"; -import { - buildOpenInEditorHandler, - resolveEnvironmentOpenContext, - resolveThreadWorkspacePreviewRootPath, -} from "./thread-detail/threadWorkspaceOpenPath"; import { useAppCommandHandler, useAppCommandShortcut, } from "@/components/commands/AppCommandProvider"; import { useOptionalPaneContext } from "./thread-detail/PaneContext"; import { RootComposePanelCommandHandlers } from "./RootComposePanelCommandHandlers"; +import { + ROOT_COMPOSE_FIXED_PANEL_STATE_ID, + RootComposePanelTabContent, + type RootComposeTerminalTarget, +} from "./RootComposePanelTabContent"; -const ROOT_COMPOSE_ZEN_MODE_STORAGE_KEY = "bb.promptbox.zen-mode.root-compose"; const ROOT_COMPOSE_SIDEBAR_ACTION_ALIGNED_TOP_PADDING_CLASS = "pt-14"; -function resolveHostOpenContext(args: { - hostId: string | null; - isLocal: boolean; - serverOrigin: string; -}): OpenInTargetContext | null { - if (args.hostId === null) { - return null; - } - if (args.isLocal) { - return { kind: "local" }; - } - return { - kind: "remote-ssh", - serverOrigin: args.serverOrigin, - hostId: args.hostId, - }; -} // Fill the scroll area and center the no-projects welcome both axes. const ROOT_COMPOSE_EMPTY_WELCOME_CONTENT_CLASS = "min-h-full flex-1 items-center justify-center pb-12"; -const ROOT_COMPOSE_FIXED_PANEL_STATE_ID = "root-compose"; const EMPTY_TERMINAL_SESSIONS: readonly TerminalSession[] = []; -type SecondaryPanelChangeHandler = (panel: ThreadSecondaryPanelTab) => void; -type NullableSecondaryPanelChangeHandler = ( - panel: ThreadSecondaryPanelTab | null, -) => void; - interface LegacyProjectComposeRedirectProps { projectId: string; } @@ -235,7 +199,7 @@ export function readSectionIdFromLocationState(state: unknown): string | null { return sectionId.length > 0 ? sectionId : null; } -export type RootComposeSectionTarget = +type RootComposeSectionTarget = | { kind: "clear" } | { sectionId: string; kind: "set" }; @@ -265,10 +229,6 @@ export function shouldStartComposingFromLocationState(state: unknown): boolean { return "focusPrompt" in state && state.focusPrompt === true; } -export function requestRootComposePluginFocus(storageKey: string | null): void { - requestComposerFocus(storageKey); -} - interface BuildMobileRecentThreadsArgs { sidebarNavigation: SidebarBootstrapResponse | undefined; } @@ -278,11 +238,6 @@ interface ShouldNavigateAfterThreadCreateArgs { navigateToThreadAfterCreate: boolean; } -interface ResolveRootComposePanelThreadIdArgs { - environmentId: string | null; - reuseThreadOptions: readonly ReuseThreadOption[]; -} - interface CanCreateRootComposeTerminalArgs { connectedHostIds: ReadonlySet; environmentHostId: string | null | undefined; @@ -290,10 +245,6 @@ interface CanCreateRootComposeTerminalArgs { environmentStatus: EnvironmentStatus | undefined; } -type RootComposeTerminalTarget = - | { kind: "environment"; environmentId: string } - | { kind: "host_path"; cwd: string | null; hostId: string }; - interface BuildRootComposeTerminalSessionsArgs { environmentTerminalSessions: readonly TerminalSession[] | undefined; globalTerminalSessions: readonly TerminalSession[] | undefined; @@ -305,76 +256,13 @@ interface RootComposeRightPanelToggleProps { onToggle: () => void; } -export function resolveRootComposePanelTogglePlacement(args: { - isHosted: boolean; - isOpen: boolean; -}): { - inlinePanelToggle: "button" | "reserved"; - showPinnedToggle: boolean; -} { - if (args.isHosted) { - return { inlinePanelToggle: "button", showPinnedToggle: false }; - } - return { - inlinePanelToggle: "button", - showPinnedToggle: !args.isOpen, - }; -} - -interface RightPanelFileTabIconProps { - path: string; -} - -interface BuildRootComposeNewTabFileTabArgs { - activeTabId: string | null; - onClose: () => void; - onSelect: () => void; - tabId: string; -} - -/** The root launcher uses the same visible tab-pill model as thread panels. */ -export function buildRootComposeNewTabFileTab({ - activeTabId, - onClose, - onSelect, - tabId, -}: BuildRootComposeNewTabFileTabArgs): SecondaryPanelFileTab { - return { - id: tabId, - filename: "New tab", - isActive: tabId === activeTabId, - leadingVisual: ( - - ), - statusLabel: null, - onSelect, - onClose, - }; -} - -function RightPanelFileTabIcon({ path }: RightPanelFileTabIconProps) { - const visual = resolveRightPanelFileVisual({ path }); - return ( - - ); -} - export function RootComposeRightPanelToggle({ isOpen, onToggle, }: RootComposeRightPanelToggleProps) { - const renderAsDrawer = useIsCompactViewport(); const shortcut = useAppCommandShortcut("panel.toggle"); const rightPanelLabel = isOpen ? "Hide right panel" : "Show right panel"; - const rightPanelIconName = renderAsDrawer ? "PanelBottom" : "PanelRight"; + const rightPanelIconName = useRightPanelToggleIconName(); return ( -
- ); -} - export function DebugSettingsSection({ disabled, enabled, @@ -911,104 +792,39 @@ export function DebugSettingsSection({ }: DebugSettingsSectionProps) { return ( - - - ); -} - -interface ProviderSettingsSectionProps { - memoryEnabled: boolean; - subagentsDisabled: boolean; - workflowsDisabled: boolean; - disabled: boolean; - onMemoryEnabledChange: (enabled: boolean) => void; - onSubagentsDisabledChange: (disabled: boolean) => void; - onWorkflowsDisabledChange: (disabled: boolean) => void; - providerId: "codex" | "claude-code"; -} - -export function ProviderSettingsSection({ - memoryEnabled, - subagentsDisabled, - workflowsDisabled, - disabled, - onMemoryEnabledChange, - onSubagentsDisabledChange, - onWorkflowsDisabledChange, - providerId, -}: ProviderSettingsSectionProps) { - const isCodex = providerId === "codex"; - const label = isCodex ? "Codex memory" : "Claude Code memory"; - return ( - -
- - - - - - - {!isCodex ? ( - - - - ) : null} -
+ + +
); } -const CLAUDE_CODE_MOCK_CLI_TRAFFIC_EXPERIMENT_LABEL = "Mock CLI Traffic"; +const CHANGELOG_PREVIEW_EXPERIMENT_LABEL = "Changelog preview"; const EDIT_MESSAGES_EXPERIMENT_LABEL = "Edit messages"; -const NEW_ONBOARDING_EXPERIMENT_LABEL = "New onboarding"; +const MOBILE_APP_EXPERIMENT_LABEL = "Mobile app"; const PROVIDER_SESSION_REAPING_EXPERIMENT_LABEL = "Idle provider session release"; +const TIMELINE_WINDOWING_EXPERIMENT_LABEL = "Timeline windowing"; export function ExperimentsSettingsSection({ - claudeCodeMockCliTrafficEnabled, + changelogPreviewEnabled, disabled, editMessagesEnabled, - newOnboardingEnabled, + mobileAppEnabled, providerSessionReapingEnabled, - onClaudeCodeMockCliTrafficEnabledChange, + timelineWindowingEnabled, + onChangelogPreviewEnabledChange, onEditMessagesEnabledChange, - onNewOnboardingEnabledChange, + onMobileAppEnabledChange, onProviderSessionReapingEnabledChange, + onTimelineWindowingEnabledChange, }: ExperimentsSettingsSectionProps) { return (
@@ -1042,14 +857,14 @@ export function ExperimentsSettingsSection({ @@ -1064,6 +879,18 @@ export function ExperimentsSettingsSection({ aria-label={PROVIDER_SESSION_REAPING_EXPERIMENT_LABEL} /> + + + +
); @@ -1099,7 +926,7 @@ export function SettingsView() { const updateGeneralSettingsMutation = useUpdateGeneralSettings(); const appearance = systemConfigQuery.data?.appearance ?? defaultAppTheme; const updateAppearanceMutation = useUpdateAppearance(); - const { activePluginId, activeProviderId, activeSection, hasUnknownSection } = + const { activePluginId, activeSection, hasUnknownSection } = useSettingsNavState(); if (hasUnknownSection) { return ; @@ -1108,47 +935,16 @@ export function SettingsView() { let content: ReactNode = null; if (activePluginId !== null) { content = ; - } else if (activeProviderId !== null) { - const isCodex = activeProviderId === "codex"; + } else if (activeSection === "providers") { content = ( - - updateGeneralSettingsMutation.mutate({ - ...generalSettings, - ...(isCodex - ? { codexMemoryEnabled: enabled } - : { claudeCodeMemoryEnabled: enabled }), - }) - } - onSubagentsDisabledChange={(disabled) => - updateGeneralSettingsMutation.mutate({ - ...generalSettings, - ...(isCodex - ? { codexSubagentsDisabled: disabled } - : { claudeCodeSubagentsDisabled: disabled }), - }) - } - onWorkflowsDisabledChange={(disabled) => - updateGeneralSettingsMutation.mutate({ - ...generalSettings, - claudeCodeWorkflowsDisabled: disabled, - }) + generalSettings={generalSettings} + onGeneralSettingsChange={(next) => + updateGeneralSettingsMutation.mutate(next) } /> ); @@ -1210,19 +1006,23 @@ export function SettingsView() { } else if (activeSection === "machines") { content = ; } else if (activeSection === "updates") { - content = ; + content = ( + + ); } else if (activeSection === "experiments") { content = ( + onChangelogPreviewEnabledChange={(enabled) => updateExperimentsMutation.mutate({ ...experiments, - claudeCodeMockCliTraffic: enabled, + changelogPreview: enabled, }) } editMessagesEnabled={experiments.editMessages} @@ -1232,11 +1032,11 @@ export function SettingsView() { editMessages: enabled, }) } - newOnboardingEnabled={experiments.newOnboarding} - onNewOnboardingEnabledChange={(enabled) => + mobileAppEnabled={experiments.mobileApp} + onMobileAppEnabledChange={(enabled) => updateExperimentsMutation.mutate({ ...experiments, - newOnboarding: enabled, + mobileApp: enabled, }) } providerSessionReapingEnabled={experiments.providerSessionReaping} @@ -1246,6 +1046,13 @@ export function SettingsView() { providerSessionReaping: enabled, }) } + timelineWindowingEnabled={experiments.timelineWindowing} + onTimelineWindowingEnabledChange={(enabled) => + updateExperimentsMutation.mutate({ + ...experiments, + timelineWindowing: enabled, + }) + } /> ); } else if (activeSection === "marketplaces") { @@ -1263,7 +1070,6 @@ export function SettingsView() { openLinksInAppBrowser={openLinksInAppBrowser} rewriteLocalhostLinks={rewriteLocalhostLinks} richTextEditing={richTextEditing} - replayOnboardingAvailable={experiments.newOnboarding} steerActiveThreadOnEnter={generalSettings.steerActiveThreadOnEnter} steerActiveThreadOnEnterDisabled={ systemConfigQuery.data === undefined || @@ -1271,12 +1077,6 @@ export function SettingsView() { } onNavigateToThreadAfterCreateChange={setNavigateToThreadAfterCreate} onOpenLinksInAppBrowserChange={setOpenLinksInAppBrowser} - onReplayOnboarding={() => - updateGeneralSettingsMutation.mutate({ - ...generalSettings, - onboardingCompletedAt: null, - }) - } onRewriteLocalhostLinksChange={setRewriteLocalhostLinks} onRichTextEditingChange={setRichTextEditing} onSteerActiveThreadOnEnterChange={(enabled) => @@ -1285,6 +1085,17 @@ export function SettingsView() { steerActiveThreadOnEnter: enabled, }) } + streamerMode={generalSettings.streamerMode} + streamerModeDisabled={ + systemConfigQuery.data === undefined || + updateGeneralSettingsMutation.isPending + } + onStreamerModeChange={(enabled) => + updateGeneralSettingsMutation.mutate({ + ...generalSettings, + streamerMode: enabled, + }) + } /> diff --git a/apps/app/src/views/SkillsView.test.tsx b/apps/app/src/views/SkillsView.test.tsx index 8c8b6de849..5f774eb09a 100644 --- a/apps/app/src/views/SkillsView.test.tsx +++ b/apps/app/src/views/SkillsView.test.tsx @@ -21,16 +21,20 @@ import type { SkillSummary } from "@bb/server-contract"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { sdk } from "@/lib/sdk"; -import { buildRegistrySkillReferencePrompt } from "@/lib/skills-registry"; +import { + buildRegistrySkillReferencePrompt, + type RegistrySkill, +} from "@/lib/skills-registry"; import { SkillDetailView } from "../components/tools/SkillDetailView"; -import { RegistrySkillDetailView } from "../components/tools/SkillsBrowse"; import { + RegistrySkillDetailView, RegistrySkillsBrowsePage, +} from "../components/tools/SkillsBrowse"; +import { SkillDetailDialogView, - SkillsLibrary, SkillsOverview, - type RegistrySkill, -} from "./SkillsView"; +} from "../components/tools/SkillsCollection"; +import { SkillsLibrary } from "../components/tools/SkillsLibrary"; afterEach(() => { focusManager.setFocused(undefined); diff --git a/apps/app/src/views/SkillsView.tsx b/apps/app/src/views/SkillsView.tsx deleted file mode 100644 index 818a456af9..0000000000 --- a/apps/app/src/views/SkillsView.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { PageShell } from "@/components/ui/page-shell.js"; -import { SkillsLibrary } from "@/components/tools/SkillsLibrary"; - -export type { - RegistryPagination, - RegistrySkill, - RegistrySkillDetail, - RegistrySkillFile, - RegistrySkillsPage, -} from "@/lib/skills-registry"; -export { - fetchRegistrySkillDetail, - fetchRegistrySkillEntry, - fetchRegistrySkills, - formatInstallCount, - formatRegistrySource, - installRegistrySkill, - normalizeSkillName, - resolveInstalledRegistrySkill, -} from "@/lib/skills-registry"; -export { RegistrySkillsBrowsePage } from "@/components/tools/SkillsBrowse"; -export type { - SkillDetailDialogViewProps, - SkillsOverviewProps, -} from "@/components/tools/SkillsCollection"; -export { - ProviderLogo, - SkillDetailDialogView, - SkillsOverview, -} from "@/components/tools/SkillsCollection"; -export { SkillsLibrary } from "@/components/tools/SkillsLibrary"; - -export function SkillsView() { - return ( - - - - ); -} diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index aab52a3e9e..dc8da82146 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -19,13 +19,13 @@ import { resetPluginSlotStoreForTest, setPluginSlotRegistrations, } from "@/lib/plugin-slots"; -import { PluginDetail, ToolsView } from "./ToolsView"; +import { ToolsView } from "./ToolsView"; import { CatalogPluginDetail, CatalogPluginDetailBanner, + PluginDetail, PluginDetailBanners, PluginProvenancePill, - pluginDetailBannerKind, pluginFrontendDiagnosticRequiresFailureBanner, } from "@/components/tools/PluginDetail"; import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; @@ -551,6 +551,85 @@ describe("BB Official plugin detail routing", () => { }); }); +describe("plugin removal confirmation", () => { + it("warns that removing a local plugin deletes its settings, secrets, and schedules and names the move path", async () => { + // The wire shape of GET /api/v1/plugins (server-contract InstalledPlugin). + const localPlugin = { + id: "github", + source: "path:/Users/you/src/bb-plugin-github", + rootDir: "/Users/you/src/bb-plugin-github", + version: "0.1.0", + provenance: "direct", + isOrphanedBuiltin: false, + publisherLabel: null, + sourceDisplay: "path · /Users/you/src/bb-plugin-github", + updateState: {}, + enabled: true, + description: "Browse GitHub issues and pull requests in BB.", + name: "GitHub", + icon: "Github", + iconUrl: null, + status: "running", + statusDetail: null, + handlerStats: { count: 0, totalMs: 0, maxMs: 0, errorCount: 0 }, + services: [], + schedules: [], + cliCommand: null, + capabilities: [], + hasSettings: false, + app: { hasApp: false, bundle: null }, + logoUrl: null, + logoDarkUrl: null, + }; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "/api/v1/plugins") { + return new Response( + JSON.stringify({ enabled: true, plugins: [localPlugin] }), + { headers: { "content-type": "application/json" } }, + ); + } + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }), + ); + + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + } /> + + , + { wrapper: QueryClientWrapper }, + ); + + expect(await screen.findByRole("heading", { name: "GitHub" })).toBeTruthy(); + fireEvent.pointerDown( + screen.getByRole("button", { name: "GitHub actions" }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Remove from bb" }), + ); + + expect( + await screen.findByRole("heading", { name: "Remove plugin from bb?" }), + ).toBeTruthy(); + // The server's remove() deletes settings, secrets, and schedules for every + // source kind; only the files of a local plugin stay. Re-pointing the id at + // another directory is an install, not a remove, and keeps that state. + const description = screen.getByText(/Remove "github" from bb/); + expect(description.textContent).toContain( + "delete its settings, secrets, and schedules", + ); + expect(description.textContent).toContain("source files stay on disk"); + expect(description.textContent).toContain("install the new path instead"); + }); +}); + describe("PluginDetail banner precedence", () => { const managedPlugin: PluginListItem = { ...GITHUB_PLUGIN, @@ -580,50 +659,6 @@ describe("PluginDetail banner precedence", () => { }, }; - it("maps implementation sources into five operational states", () => { - // The current server-reported state wins over browser diagnostics and - // release metadata because it is the plugin's present-tense condition. - expect(pluginDetailBannerKind(collision, true)).toBe("degraded"); - expect(pluginDetailBannerKind(collision, false)).toBe("degraded"); - - const runningPlugin: PluginListItem = { - ...collision, - status: "running", - statusDetail: null, - handlerStats: managedPlugin.handlerStats, - }; - expect(pluginDetailBannerKind(runningPlugin, true)).toBe("failed"); - expect( - pluginDetailBannerKind( - { - ...runningPlugin, - handlerStats: { ...runningPlugin.handlerStats, errorCount: 3 }, - }, - false, - ), - ).toBeNull(); - - for (const [status, kind] of [ - ["error", "failed"], - ["incompatible", "incompatible"], - ["missing", "missing"], - ["needs-configuration", "needs-configuration"], - ] as const) { - expect(pluginDetailBannerKind({ ...runningPlugin, status }, false)).toBe( - kind, - ); - } - - // Release opportunities and history never enter the health-banner slot. - expect(pluginDetailBannerKind(runningPlugin, false)).toBeNull(); - expect( - pluginDetailBannerKind( - { ...runningPlugin, enabled: false, status: "disabled" }, - true, - ), - ).toBeNull(); - }); - it("renders only current health and keeps diagnostics out of user copy", () => { const { wrapper } = createQueryClientTestHarness(); render(, { wrapper }); diff --git a/apps/app/src/views/ToolsView.tsx b/apps/app/src/views/ToolsView.tsx index 41af35a591..65936e8af6 100644 --- a/apps/app/src/views/ToolsView.tsx +++ b/apps/app/src/views/ToolsView.tsx @@ -32,6 +32,7 @@ import { PluginDetail, PluginDetailBanners, pluginIsLocalSource, + pluginRemovalDescription, pluginRemovalLabel, } from "@/components/tools/PluginDetail"; import { @@ -56,9 +57,7 @@ import { type ToolsSectionId, } from "@/components/tools/tools-navigation"; import { cn } from "@bb/shared-ui/lib/utils"; -import { SkillsLibrary } from "./SkillsView"; - -export { PluginDetail }; +import { SkillsLibrary } from "@/components/tools/SkillsLibrary"; function ToolsBodyFallback() { return ( @@ -74,13 +73,11 @@ function ToolsBodyFallback() { ); } -export function ToolsScrollPage({ +function ToolsScrollPage({ children, - maxWidthClassName = "max-w-5xl", fillViewport = false, }: { children: ReactNode; - maxWidthClassName?: string; fillViewport?: boolean; }) { const { @@ -108,7 +105,7 @@ export function ToolsScrollPage({
{children} @@ -365,11 +362,7 @@ function PluginDetailToolView({ pluginId }: { pluginId: string }) { ? "Remove plugin from bb?" : "Uninstall plugin?" } - description={ - pluginIsLocalSource(deleteTarget) - ? `Remove "${deleteTarget.id}" from bb? Its source files will stay on disk.` - : `Uninstall "${deleteTarget.id}" and delete its managed files and settings?` - } + description={pluginRemovalDescription(deleteTarget)} confirmLabel={pluginRemovalLabel(deleteTarget)} pending={pluginDelete.isPending} onConfirm={() => pluginDelete.mutate(deleteTarget)} diff --git a/apps/app/src/views/project-settings/ProjectSourceRow.tsx b/apps/app/src/views/project-settings/ProjectSourceRow.tsx index 5c20e65c7f..c43fef0d8c 100644 --- a/apps/app/src/views/project-settings/ProjectSourceRow.tsx +++ b/apps/app/src/views/project-settings/ProjectSourceRow.tsx @@ -15,7 +15,7 @@ import { /** The machine a source lives on, for the machine-aware sources list * Null keeps the plain path-only row. */ -export interface ProjectSourceRowMachine { +interface ProjectSourceRowMachine { name: string; connected: boolean; } diff --git a/apps/app/src/views/root-compose-branch-selection.ts b/apps/app/src/views/root-compose-branch-selection.ts index c5bdd42335..8be42b1b9f 100644 --- a/apps/app/src/views/root-compose-branch-selection.ts +++ b/apps/app/src/views/root-compose-branch-selection.ts @@ -1,12 +1,12 @@ import { useCallback, useState } from "react"; import type { RootComposeSelectedBranch } from "./root-compose-thread-environment"; -export interface BranchSelectionScopeArgs { +interface BranchSelectionScopeArgs { environmentValue: string; projectId: string | undefined; } -export interface UseScopedBranchSelectionResult { +interface UseScopedBranchSelectionResult { onBranchChange: (name: string) => void; onClearBranch: () => void; onCreateBranch: (currentBranch: string | null) => void; diff --git a/apps/app/src/views/root-compose-branch-ui.ts b/apps/app/src/views/root-compose-branch-ui.ts index 8468dbf0ac..e68eb114e5 100644 --- a/apps/app/src/views/root-compose-branch-ui.ts +++ b/apps/app/src/views/root-compose-branch-ui.ts @@ -7,12 +7,12 @@ import type { RootComposeSelectedBranch } from "./root-compose-thread-environmen export type RootComposeBranchEnvironmentMode = "local" | "worktree" | "other"; -export interface BranchMutationBlocker { +interface BranchMutationBlocker { label: string; title: string; } -export interface RootComposeBranchUiState { +interface RootComposeBranchUiState { currentBranch: string | null; currentOptionLabel: string | null; mutationBlocker: BranchMutationBlocker | null; @@ -25,7 +25,7 @@ type RootComposeBranchCheckout = ProjectSourceCheckout & { defaultWorktreeBaseBranch?: string | null; }; -export interface BuildRootComposeBranchUiStateArgs { +interface BuildRootComposeBranchUiStateArgs { checkout: RootComposeBranchCheckout | undefined; isFetching: boolean; isLoading: boolean; @@ -55,19 +55,6 @@ function formatOperationName(operation: WorkspaceGitOperation): string { } } -function getCheckoutBranchName( - checkout: GitCheckoutRef | undefined, -): string | null { - if (checkout?.kind !== "branch") { - return null; - } - return checkout.branchName; -} - -function getOperationConflictState(operation: WorkspaceGitOperation): boolean { - return operation.kind !== "none" && operation.hasConflicts; -} - function formatCurrentCheckoutLabel( checkout: GitCheckoutRef | undefined, ): string { @@ -101,30 +88,6 @@ function formatCurrentCheckoutTriggerLabel( } } -function formatBranchFromOptionLabel( - defaultBranch: string | null | undefined, -): string { - return defaultBranch ?? "default"; -} - -function formatBranchFromTriggerLabel( - defaultBranch: string | null | undefined, -): string { - return `Branch from: ${defaultBranch ?? "default"}`; -} - -function formatNewBranchTriggerLabel(branchName: string): string { - return `New branch from: ${branchName}`; -} - -function formatCheckoutBranchTriggerLabel(branchName: string): string { - return `Checkout: ${branchName}`; -} - -function formatCheckoutBranchTriggerTitle(branchName: string): string { - return `Checkout branch: ${branchName}`; -} - function buildOperationBlocker( operation: WorkspaceGitOperation, ): BranchMutationBlocker | null { @@ -133,7 +96,7 @@ function buildOperationBlocker( } const operationName = formatOperationName(operation); - if (getOperationConflictState(operation)) { + if (operation.hasConflicts) { return { label: "Conflicts", title: "Checkout blocked by unresolved conflicts", @@ -205,8 +168,8 @@ function buildWorktreeBranchUiState( ): RootComposeBranchUiState { const defaultBaseBranch = args.checkout?.defaultWorktreeBaseBranch ?? args.checkout?.defaultBranch; - const defaultOptionLabel = formatBranchFromOptionLabel(defaultBaseBranch); - const defaultTriggerLabel = formatBranchFromTriggerLabel(defaultBaseBranch); + const defaultOptionLabel = defaultBaseBranch ?? "default"; + const defaultTriggerLabel = `Branch from: ${defaultBaseBranch ?? "default"}`; if (args.selectedBranch) { return { @@ -248,7 +211,9 @@ export function buildRootComposeBranchUiState( } const mutationBlocker = resolveBranchMutationBlocker(args); - const currentBranch = getCheckoutBranchName(args.checkout?.checkout); + const checkoutRef = args.checkout?.checkout; + const currentBranch = + checkoutRef?.kind === "branch" ? checkoutRef.branchName : null; const currentOptionLabel = formatCurrentCheckoutLabel( args.checkout?.checkout, ); @@ -258,7 +223,7 @@ export function buildRootComposeBranchUiState( currentOptionLabel, mutationBlocker, placeholder: "Current checkout", - triggerLabel: formatNewBranchTriggerLabel(args.selectedBranch.name), + triggerLabel: `New branch from: ${args.selectedBranch.name}`, triggerTitle: mutationBlocker?.title ?? `Create a new branch from ${args.selectedBranch.name}`, @@ -271,10 +236,10 @@ export function buildRootComposeBranchUiState( currentOptionLabel, mutationBlocker, placeholder: "Current checkout", - triggerLabel: formatCheckoutBranchTriggerLabel(args.selectedBranch.name), + triggerLabel: `Checkout: ${args.selectedBranch.name}`, triggerTitle: mutationBlocker?.title ?? - formatCheckoutBranchTriggerTitle(args.selectedBranch.name), + `Checkout branch: ${args.selectedBranch.name}`, }; } diff --git a/apps/app/src/views/root-compose-environment-selection.ts b/apps/app/src/views/root-compose-environment-selection.ts index ff3ffbd40b..d552f0370a 100644 --- a/apps/app/src/views/root-compose-environment-selection.ts +++ b/apps/app/src/views/root-compose-environment-selection.ts @@ -23,7 +23,7 @@ import { getThreadDisplayTitle } from "@/lib/thread-title"; * create-thread environment the same way. */ -export interface ResolveRootComposeEffectiveEnvironmentValueArgs { +interface ResolveRootComposeEffectiveEnvironmentValueArgs { environmentSelectionValue: string; isProjectless: boolean; /** Ids of all hosts known to the server. */ diff --git a/apps/app/src/views/root-compose-thread-environment.test.ts b/apps/app/src/views/root-compose-thread-environment.test.ts index c8ee18b55a..c78fed84bb 100644 --- a/apps/app/src/views/root-compose-thread-environment.test.ts +++ b/apps/app/src/views/root-compose-thread-environment.test.ts @@ -87,6 +87,23 @@ describe("resolveRootComposeThreadEnvironment", () => { }); }); + it("can submit the server-resolved default while branch metadata is still loading", () => { + expect( + resolveRootComposeThreadEnvironment({ + defaultBranch: undefined, + defaultWorktreeBaseBranch: undefined, + environmentValue: hostWorktreeEnvironmentValue, + projectId, + selectedBranch: null, + }), + ).toMatchObject({ + workspace: { + type: "managed-worktree", + baseBranch: { kind: "default" }, + }, + }); + }); + it("sends smart remote default base branch for managed worktrees without an explicit pick", () => { expect( resolveRootComposeThreadEnvironment({ diff --git a/apps/app/src/views/root-compose-thread-environment.ts b/apps/app/src/views/root-compose-thread-environment.ts index 399dea6f91..061515d277 100644 --- a/apps/app/src/views/root-compose-thread-environment.ts +++ b/apps/app/src/views/root-compose-thread-environment.ts @@ -7,7 +7,7 @@ export interface RootComposeSelectedBranch { isNew: boolean; } -export interface ResolveRootComposeThreadEnvironmentArgs { +interface ResolveRootComposeThreadEnvironmentArgs { defaultBranch: string | null | undefined; defaultWorktreeBaseBranch: string | null | undefined; environmentValue: string; diff --git a/apps/app/src/views/thread-detail/PaneMaximizeButton.tsx b/apps/app/src/views/thread-detail/PaneMaximizeButton.tsx index 7a06c97512..cf97f0324c 100644 --- a/apps/app/src/views/thread-detail/PaneMaximizeButton.tsx +++ b/apps/app/src/views/thread-detail/PaneMaximizeButton.tsx @@ -4,8 +4,9 @@ import { Popover, PopoverAnchor, PopoverContent } from "@bb/shared-ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider"; import { HEADER_PANE_ACTION_ICON_BUTTON_CLASS } from "@/components/layout/AppPageHeader"; -import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@/components/ui/chromeStyleTokens"; +import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { useHoverPopover } from "@/components/ui/hooks/use-hover-popover"; +import type { AppShortcutPresentation } from "@/lib/app-keybindings"; import type { SplitSide } from "@/lib/split-layout"; import { cn } from "@bb/shared-ui/lib/utils"; import { usePaneContext } from "./PaneContext"; @@ -47,17 +48,35 @@ function ArrangementGlyph({ side }: { side: SplitSide }) { ); } -export function PaneMaximizeButton({ - defaultMenuOpen = false, - defaultTooltipOpen = false, -}: { - /** Keeps the hover menu visible in its focused Ladle story. */ - defaultMenuOpen?: boolean; - /** Keeps the full-screen tooltip visible in its focused Ladle story. */ - defaultTooltipOpen?: boolean; -}) { +export function PaneMaximizeButton() { const { isMaximized, onToggleMaximize, onMoveToSide } = usePaneContext(); const shortcut = useAppCommandShortcut("pane.maximize.toggle"); + + if (onToggleMaximize === null) return null; + + return ( + + ); +} + +export function PaneArrangementButton({ + className, + isFullScreen, + onMoveToSide, + onToggleFullScreen, + shortcut, +}: { + className?: string; + isFullScreen: boolean; + onMoveToSide?: (side: SplitSide) => void; + onToggleFullScreen: () => void; + shortcut?: AppShortcutPresentation; +}) { // The pointer crosses this button on the way to the close control, so the // menu waits before it appears. const { @@ -67,11 +86,9 @@ export function PaneMaximizeButton({ handleOpenChange, } = useHoverPopover({ openDelayMs: 400, closeDelayMs: 100 }); - if (onToggleMaximize === null) return null; - - const label = isMaximized ? "Exit Full Screen" : "Full Screen"; + const label = isFullScreen ? "Exit Full Screen" : "Full Screen"; const accessibleLabel = shortcut ? `${label} (${shortcut.label})` : label; - const menuOpen = !isMaximized && (defaultMenuOpen || hoverOpen); + const menuOpen = !isFullScreen && hoverOpen; const button = ( ); - if (isMaximized) { + if (isFullScreen) { return ( - + {button} Exit Full Screen @@ -127,7 +145,7 @@ export function PaneMaximizeButton({ className={MENU_ITEM_CLASS} onClick={() => { handleOpenChange(false); - onToggleMaximize(); + onToggleFullScreen(); }} > diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx index 430918961a..c8b8b64a5d 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx @@ -25,10 +25,6 @@ import type { SplitLayout } from "@/lib/split-layout"; import { PaneContext } from "./PaneContext"; import { SplitThreadArea } from "./SplitThreadArea"; -vi.mock("@/hooks/useThreadSplitsEnabled", () => ({ - useThreadSplitsEnabled: () => true, -})); - const ARCHIVED_AT = 1_700_000_000_000; interface SeedThread { diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.parity.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.parity.test.tsx index ff97d23b8e..08ceb8fdf8 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.parity.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.parity.test.tsx @@ -6,20 +6,9 @@ import { createStore, Provider } from "jotai"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; -import { - SPLIT_LAYOUT_STORAGE_KEY, - type LayoutNode, - type PaneContent, - type SplitLayout, -} from "@/lib/split-layout"; +import type { LayoutNode, PaneContent, SplitLayout } from "@/lib/split-layout"; import { SplitThreadArea } from "./SplitThreadArea"; -const experimentState = vi.hoisted(() => ({ enabled: true })); - -vi.mock("@/hooks/useThreadSplitsEnabled", () => ({ - useThreadSplitsEnabled: () => experimentState.enabled, -})); - // The heavy thread view is stubbed to a marker so the test observes only the // wrapper DOM SplitThreadArea itself introduces. vi.mock("./ThreadDetailView", () => ({ @@ -86,34 +75,10 @@ function renderArea(layout: SplitLayout) { afterEach(() => { cleanup(); - experimentState.enabled = true; window.localStorage.clear(); }); describe("SplitThreadArea single-pane parity", () => { - it("renders the pre-split page and preserves a stored layout when the experiment is off", () => { - experimentState.enabled = false; - const layout: SplitLayout = { - root: { - type: "split", - dir: "row", - sizes: [0.5, 0.5], - children: [pane("pane-1", "t1"), pane("pane-2", "t2")], - }, - focusedPaneId: "pane-1", - }; - const { container, getAllByTestId, store, storedLayout } = - renderArea(layout); - - expect(container.querySelectorAll("[data-split-pane-id]")).toHaveLength(0); - expect(getAllByTestId("thread-view")).toHaveLength(1); - expect(getAllByTestId("thread-view")[0]?.dataset.thread).toBe("page"); - expect(store.get(splitLayoutAtom)).toStrictEqual(storedLayout); - expect( - window.localStorage.getItem(SPLIT_LAYOUT_STORAGE_KEY), - ).not.toBeNull(); - }); - it("renders the single pane with no wrapper element around the thread view", () => { const { container, getAllByTestId } = renderArea({ root: pane("pane-1", "t1"), diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx index 96dc6ce74d..050e203293 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx @@ -17,10 +17,6 @@ import { maximizedPaneIdAtom, splitLayoutAtom } from "@/lib/split-layout/atoms"; import type { SplitLayout } from "@/lib/split-layout"; import { SidebarProvider } from "@/components/ui/sidebar"; import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider"; -import { AppPageHeader } from "@/components/layout/AppPageHeader"; -import { TooltipProvider } from "@bb/shared-ui/tooltip"; -import { PaneContext, type PaneContextValue } from "./PaneContext"; -import { PaneMaximizeButton } from "./PaneMaximizeButton"; import { SplitThreadArea } from "./SplitThreadArea"; export default { @@ -237,62 +233,3 @@ export function ActiveAndIdle() { export function MaximizedWithoutRail() { return ; } - -const CONTROL_CONTEXT: PaneContextValue = { - paneId: "pane-control", - isFocused: true, - isSplitPane: true, - secondaryPanelHost: null, - reservesWindowPanelToggle: false, - onRequestClose: () => {}, - isMaximized: false, - onToggleMaximize: () => {}, - onMoveToSide: () => {}, - isBoundedPane: true, - isTopRow: true, - ownsWindowTopLeft: false, - navigateInPane: () => {}, -}; - -export function FullScreenControlStates() { - return ( - -
-
-

- Normal · hover menu -

-
- - Normal pane - } - actions={} - /> - -
-
-
-

- Full screen · exit tooltip -

-
- - - Full-screen pane - - } - actions={} - /> - -
-
-
-
- ); -} diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx index f7ff0a0be3..1fb45b2765 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx @@ -53,7 +53,6 @@ const threadStore = vi.hoisted( () => new Map(), ); -const experimentState = vi.hoisted(() => ({ enabled: true })); const viewportState = vi.hoisted(() => ({ compact: false })); const sidebarState = vi.hoisted(() => ({ showing: true })); const panelFullScreenState = vi.hoisted(() => ({ @@ -112,10 +111,6 @@ function RootComposeFixture() { return
; } -vi.mock("@/hooks/useThreadSplitsEnabled", () => ({ - useThreadSplitsEnabled: () => experimentState.enabled, -})); - vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ useIsCompactViewport: () => viewportState.compact, })); @@ -603,7 +598,6 @@ function renderSplitArea(options: { } beforeEach(() => { - experimentState.enabled = true; viewportState.compact = false; sidebarState.showing = true; panelFullScreenState.isMainCollapsed = false; @@ -641,21 +635,6 @@ describe("SplitThreadArea", () => { expect(host.dataset.flushPageInsets).toBe("true"); }); - it("hosts Browser-tab navigation when split workspaces are disabled", async () => { - experimentState.enabled = false; - - renderSplitArea({ - path: "/plugins/docs/docs", - layout: pluginSplitLayout(), - routeContent: docsContent, - }); - - expect( - (await screen.findByTestId("plugin-browser-host")).dataset - .flushPageInsets, - ).toBe("true"); - }); - it("applies spotlight pane actions to the targeted open split and preference", async () => { const store = renderSplitArea({ path: threadPath("thr-b"), diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx index 015ec6db93..8d0c53b721 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -25,7 +25,6 @@ import { import { useIsMutating } from "@tanstack/react-query"; import { BbHttpError } from "@/lib/sdk"; import { useThread } from "@/hooks/queries/thread-queries"; -import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled"; import { useSplitWorkspaceActive } from "@/hooks/useSplitWorkspaceActive"; import { dimInactiveSplitsAtom, @@ -93,16 +92,13 @@ import { resourceRouteLabelAtom } from "@/components/layout/resourceRouteLabelAt import { resolveAutomationBreadcrumbs } from "@/components/tools/tools-navigation"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; -import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@/components/ui/chromeStyleTokens"; +import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { usePluginNavPanelChrome } from "@/lib/plugin-nav-panel-chrome"; import { PluginPanelHeaderActions, PluginPanelHeaderCenter, } from "@/components/plugin/PluginPanelHeader"; -import { - getAdjacentPaneId, - getPaneIdAtReadingIndex, -} from "./splitPaneCommands"; +import { getAdjacentPaneId } from "./splitPaneCommands"; import { applyThreadPaneActionToLayout, createSinglePaneLayout, @@ -265,7 +261,6 @@ export function SplitThreadArea(props: SplitThreadAreaProps = {}) { function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { const { projectId, threadId } = useRouteState(); - const threadSplitsEnabled = useThreadSplitsEnabled(); const splitWorkspaceActive = useSplitWorkspaceActive(); const navigate = useNavigate(); const store = useStore(); @@ -291,13 +286,13 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { // layout. The reconcile is idempotent, so a URL that already matches the // focused pane is a no-op — no history spam, no render loop. useEffect(() => { - if (!threadSplitsEnabled || currentContent === null) { + if (currentContent === null) { return; } setLayout((previous) => reconcileLayoutForContent(previous, currentContent), ); - }, [currentContent, setLayout, threadSplitsEnabled]); + }, [currentContent, setLayout]); // Effective layout for render/handlers before the effect seeds the atom. const layout: SplitLayout | null = @@ -338,9 +333,6 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { useEffect( () => wsManager.onThreadPaneAction((signal) => { - if (!threadSplitsEnabled) { - return; - } const current = store.get(splitLayoutAtom); if (current === null) { return; @@ -366,7 +358,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { store.set(dimInactiveSplitsAtom, next.dimInactiveSplits); } }), - [navigate, setMaximizedPaneId, store, threadSplitsEnabled], + [navigate, setMaximizedPaneId, store], ); // A maximized pane is always the focused/address-bar owner. External opens @@ -562,7 +554,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { : null; const startX = event.clientX; const startY = event.clientY; - beginSplitDrag(startX, startY, { + beginSplitDrag({ ghostLabel: label, sourceEl, shouldEngage: (x, y) => @@ -745,7 +737,7 @@ function SplitPaneCommandHandlers({ }); useIndexedAppCommandHandlers(PANE_FOCUS_APP_COMMAND_IDS, (index) => { if (!isSplitActive) return false; - const paneId = getPaneIdAtReadingIndex(panes, index); + const paneId = panes[index]?.paneId ?? null; if (paneId !== null) focusPane(paneId); return true; }); diff --git a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx index 5c0155f677..9a18deb16b 100644 --- a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx +++ b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx @@ -32,6 +32,7 @@ import { SecondaryPanelHostLayoutContext, type SecondaryPanelHostLayout, } from "@/components/secondary-panel/SecondaryPanelHostLayoutContext"; +import { useRightPanelToggleIconName } from "@/components/secondary-panel/panelToggleControlState"; import { getPanelCollapseTransitionStyle, PANEL_COLLAPSE_TRANSITION_CLASS, @@ -189,6 +190,7 @@ export function SplitWorkspaceSecondaryPanelHost({ }; const toggleLabel = isOpen ? "Hide right panel" : "Show right panel"; + const toggleIconName = useRightPanelToggleIconName(); // An open pane panel carries the toggle in its own chrome, and a full-screen // pane hides it. The empty state has no chrome, so it keeps the button. const showsCornerToggle = !isPaneMaximized && !(isOpen && model !== null); @@ -248,7 +250,7 @@ export function SplitWorkspaceSecondaryPanelHost({ aria-expanded={isOpen} onClick={toggleWindowPanel} > - +
({ ), })); +const viewportState = vi.hoisted(() => ({ isCompactViewport: false })); + vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ - useIsCompactViewport: () => false, + useIsCompactViewport: () => viewportState.isCompactViewport, })); const THREAD_ID = "thr_header"; @@ -69,6 +71,7 @@ const PANE_CONTEXT: PaneContextValue = { afterEach(() => { cleanup(); + viewportState.isCompactViewport = false; mocks.renameThread.mockReset(); vi.restoreAllMocks(); window.localStorage.clear(); @@ -99,6 +102,40 @@ describe("ThreadDetailHeader", () => { ).toBeNull(); }); + // A compact viewport opens the right panel as a bottom drawer, so the show + // trigger has to disclose that edge rather than the wide-viewport one. + it.each([ + { expectedIcon: "PanelBottom", isCompactViewport: true }, + { expectedIcon: "PanelRight", isCompactViewport: false }, + ])( + "shows the $expectedIcon glyph on the right-panel trigger", + ({ expectedIcon, isCompactViewport }) => { + viewportState.isCompactViewport = isCompactViewport; + + render( + + + , + ); + + const showButton = screen.getByRole("button", { + name: "Show right panel", + }); + expect( + showButton.querySelector(`[data-icon="${expectedIcon}"]`), + ).not.toBeNull(); + }, + ); + it("keeps thread Full Screen in a split header while its panel is open", () => { render( { composer: { message: string; onChangeMessage: (message: string, mentions: []) => void; + onEscape?: () => void; onSubmit: () => void; submitTitle?: string; submitMode: { kind: string; reason?: string }; @@ -249,6 +251,11 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { + {composer.onEscape ? ( + + ) : null} + ), })); const { ThreadTimelinePane } = await import("./ThreadTimelinePane"); afterEach(cleanup); -it("forwards the plugin-panel opener to rendered message directives", () => { +it("forwards pane callbacks to the timeline and conversation outline", () => { render( { />, ); - expect(screen.getByTestId("timeline").textContent).toBe("available"); + expect(screen.getByTestId("plugin-panel-opener").textContent).toBe( + "available", + ); + expect(screen.getByTestId("navigation-target").textContent).toBe("none"); + fireEvent.click(screen.getByRole("button", { name: "Jump to row" })); + expect(screen.getByTestId("navigation-target").textContent).toBe( + "row-target", + ); }); diff --git a/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx b/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx index 7d323f95ce..f1194aa83d 100644 --- a/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx +++ b/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx @@ -1,11 +1,8 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import type { ThreadTimelineUnreadDividerPlacement } from "@/components/thread/timeline"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { EmbeddedThreadChat } from "@/components/thread/embedded-chat"; -import type { - HostConnectionNotice, - ThreadTimelineSurfaceProps, -} from "@/components/thread/timeline/ThreadTimelineSurface"; +import type { ThreadTimelineSurfaceProps } from "@/components/thread/timeline/ThreadTimelineSurface"; import { ThreadTableOfContents } from "@/components/thread/toc/ThreadTableOfContents"; interface ThreadTimelinePaneProps extends ThreadTimelineSurfaceProps { @@ -21,16 +18,15 @@ interface ThreadTimelinePaneProps extends ThreadTimelineSurfaceProps { unreadDividerPlacement: ThreadTimelineUnreadDividerPlacement | null; } -export type { HostConnectionNotice }; - export function ThreadTimelinePane({ footer, ...surface }: ThreadTimelinePaneProps) { + const [timelineNavigationTargetRowId, setTimelineNavigationTargetRowId] = + useState(null); return ( } - surface={surface} + surface={{ ...surface, timelineNavigationTargetRowId }} /> ); } diff --git a/apps/app/src/views/thread-detail/splitPaneCommands.test.ts b/apps/app/src/views/thread-detail/splitPaneCommands.test.ts index d22253a76c..29f4a070c5 100644 --- a/apps/app/src/views/thread-detail/splitPaneCommands.test.ts +++ b/apps/app/src/views/thread-detail/splitPaneCommands.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; import type { PaneNode } from "@/lib/split-layout"; -import { - getAdjacentPaneId, - getPaneIdAtReadingIndex, -} from "./splitPaneCommands"; +import { getAdjacentPaneId } from "./splitPaneCommands"; function pane(paneId: string): PaneNode { return { @@ -27,10 +24,4 @@ describe("split pane shortcut selection", () => { it("does not select an adjacent pane when unsplit", () => { expect(getAdjacentPaneId([PANES[0]!], "pane-1", 1)).toBeNull(); }); - - it("focuses only pane numbers that exist", () => { - expect(getPaneIdAtReadingIndex(PANES, 0)).toBe("pane-1"); - expect(getPaneIdAtReadingIndex(PANES, 7)).toBe("pane-8"); - expect(getPaneIdAtReadingIndex(PANES, 8)).toBeNull(); - }); }); diff --git a/apps/app/src/views/thread-detail/splitPaneCommands.ts b/apps/app/src/views/thread-detail/splitPaneCommands.ts index b8349b282a..ce016484dd 100644 --- a/apps/app/src/views/thread-detail/splitPaneCommands.ts +++ b/apps/app/src/views/thread-detail/splitPaneCommands.ts @@ -14,11 +14,3 @@ export function getAdjacentPaneId( const nextIndex = (startIndex + offset + panes.length) % panes.length; return panes[nextIndex]?.paneId ?? null; } - -/** Resolve a one-based shortcut slot against pane reading order. */ -export function getPaneIdAtReadingIndex( - panes: readonly PaneNode[], - index: number, -): string | null { - return panes[index]?.paneId ?? null; -} diff --git a/apps/app/src/views/thread-detail/splitThreadNavigation.ts b/apps/app/src/views/thread-detail/splitThreadNavigation.ts index a9f79c76fd..0bb7d73533 100644 --- a/apps/app/src/views/thread-detail/splitThreadNavigation.ts +++ b/apps/app/src/views/thread-detail/splitThreadNavigation.ts @@ -41,9 +41,7 @@ export function createSinglePaneLayout( }; } -export function createSinglePaneContentLayout( - content: PaneContent, -): SplitLayout { +function createSinglePaneContentLayout(content: PaneContent): SplitLayout { return { root: { type: "pane", paneId: FIRST_PANE_ID, content }, focusedPaneId: FIRST_PANE_ID, @@ -130,7 +128,7 @@ export function applyThreadOpenToLayout( : splitPane(layout, layout.focusedPaneId, decision.zone, content); } -export interface ThreadPaneActionLayoutResult { +interface ThreadPaneActionLayoutResult { layout: SplitLayout; maximizedPaneId: string | null; /** Explicit preference update requested by the action, or null when unchanged. */ diff --git a/apps/app/src/views/thread-detail/threadDetailMutationTypes.ts b/apps/app/src/views/thread-detail/threadDetailMutationTypes.ts index c4874995b9..0138a9f37d 100644 --- a/apps/app/src/views/thread-detail/threadDetailMutationTypes.ts +++ b/apps/app/src/views/thread-detail/threadDetailMutationTypes.ts @@ -11,7 +11,7 @@ export interface RequestEnvironmentActionMutationLike { ) => Promise; } -export type SendMessageMutationRequest = SendThreadMessageMutationRequest; +type SendMessageMutationRequest = SendThreadMessageMutationRequest; export interface SendMessageMutationLike { isPending: boolean; diff --git a/apps/app/src/views/thread-detail/threadParentSelectorOptions.ts b/apps/app/src/views/thread-detail/threadParentSelectorOptions.ts index 99f7f2e0e7..15a8b82088 100644 --- a/apps/app/src/views/thread-detail/threadParentSelectorOptions.ts +++ b/apps/app/src/views/thread-detail/threadParentSelectorOptions.ts @@ -1,6 +1,6 @@ import type { ThreadListEntry } from "@bb/domain"; -export interface ParentSelectorOption { +interface ParentSelectorOption { label: string; value: string; } @@ -16,7 +16,7 @@ interface CollectDescendantThreadIdsArgs { threads: readonly ThreadListEntry[]; } -export interface BuildParentSelectorOptionsArgs { +interface BuildParentSelectorOptionsArgs { currentThreadId: string | undefined; parentThreads: readonly ThreadListEntry[]; parentThreadDisplayName: string | null | undefined; diff --git a/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts b/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts index 9a92efefa5..661b8ec301 100644 --- a/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts +++ b/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts @@ -22,11 +22,6 @@ interface GetActiveFixedSecondaryTabArgs { fixedPanelTabsState: FixedPanelTabsState; } -interface GetOpenFixedSecondaryTabArgs { - activeFixedSecondaryTab: ActiveFixedSecondaryTab; - isSecondaryPanelOpen: boolean; -} - export function getActiveFixedSecondaryTab({ fixedPanelTabsState, }: GetActiveFixedSecondaryTabArgs): ActiveFixedSecondaryTab { @@ -40,13 +35,6 @@ export function getActiveFixedSecondaryTab({ return activeTab; } -export function getOpenFixedSecondaryTab({ - activeFixedSecondaryTab, - isSecondaryPanelOpen, -}: GetOpenFixedSecondaryTabArgs): ActiveFixedSecondaryTab { - return isSecondaryPanelOpen ? activeFixedSecondaryTab : null; -} - export function useSetThreadSecondaryPanelSelection( panelStateId: ThreadSecondaryPanelThreadId, syncThreadId: ThreadSecondaryPanelThreadId, diff --git a/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.test.ts b/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.test.ts index f03e35fcbb..cbde587f9b 100644 --- a/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.test.ts +++ b/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.test.ts @@ -8,7 +8,6 @@ import { describe, expect, it } from "vitest"; import { resolveWorkspaceChangedFileOpenTarget, resolveEnvironmentOpenContext, - resolveThreadWorkspacePreviewRootPath, resolveThreadWorkspaceOpenPath, } from "./threadWorkspaceOpenPath"; @@ -127,12 +126,6 @@ describe("resolveThreadWorkspaceOpenPath", () => { ).toBeNull(); }); - it("keeps the workspace preview root independent from local editor availability", () => { - expect( - resolveThreadWorkspacePreviewRootPath({ environment: makeEnvironment() }), - ).toBe("/tmp/workspace"); - }); - it("still resolves when the environment is not ready, as long as it has a path", () => { expect( resolveThreadWorkspaceOpenPath({ diff --git a/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.ts b/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.ts index 7e26bf9dd5..8cc081064b 100644 --- a/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.ts +++ b/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.ts @@ -4,7 +4,7 @@ import type { WorkspaceChangedFilesSection } from "@/components/workspace/worksp import type { EnvironmentFilePreviewSource, WorkspaceFilePreviewStatusLabel, -} from "@/lib/file-preview"; +} from "@bb/client-core"; import { buildAbsoluteFilePath } from "@/lib/absolute-file-path"; interface ResolveThreadWorkspaceOpenPathArgs { @@ -19,7 +19,7 @@ interface ResolveEnvironmentOpenContextArgs { threadEnvironmentIsLocal: boolean; } -export interface BuildOpenInEditorHandlerArgs { +interface BuildOpenInEditorHandlerArgs { rootPath: string | null; canOpenPreferredTarget: boolean; openInPreferredTarget: (request: { @@ -49,11 +49,7 @@ export function buildOpenInEditorHandler( }; } -export interface ResolveThreadWorkspacePreviewRootPathArgs { - environment: Environment | null | undefined; -} - -export type WorkspaceChangedFileOpenTarget = +type WorkspaceChangedFileOpenTarget = | { kind: "diff" } | { kind: "preview"; @@ -61,7 +57,7 @@ export type WorkspaceChangedFileOpenTarget = statusLabel: WorkspaceFilePreviewStatusLabel | null; }; -export interface ResolveWorkspaceChangedFileOpenTargetArgs { +interface ResolveWorkspaceChangedFileOpenTargetArgs { file: WorkspaceFileStatus; section: WorkspaceChangedFilesSection; } @@ -97,17 +93,6 @@ export function resolveWorkspaceChangedFileOpenTarget( return { kind: "diff" }; } -/** - * Workspace previews are served by the thread host through the server, so path - * containment should use the environment's host path even when the browser - * cannot use that path for local editor integration. - */ -export function resolveThreadWorkspacePreviewRootPath( - args: ResolveThreadWorkspacePreviewRootPathArgs, -): string | null { - return args.environment?.path ?? null; -} - export function resolveEnvironmentOpenContext( args: ResolveEnvironmentOpenContextArgs, ): OpenInTargetContext | null { diff --git a/apps/app/src/views/thread-detail/useThreadGitActions.ts b/apps/app/src/views/thread-detail/useThreadGitActions.ts index 2e15dde1a9..459aa8810c 100644 --- a/apps/app/src/views/thread-detail/useThreadGitActions.ts +++ b/apps/app/src/views/thread-detail/useThreadGitActions.ts @@ -7,52 +7,15 @@ import { } from "react"; import { appToast } from "@/components/ui/app-toast"; import { AppToastCommitDescription } from "@/components/ui/app-toast-descriptions"; -import type { - Environment, - PromptInput, - Thread, - WorkspaceStatus, -} from "@bb/domain"; +import type { Environment, Thread, WorkspaceStatus } from "@bb/domain"; import type { CommitActionResponse, - EnvironmentActionFailureDetails, SquashMergeActionResponse, } from "@bb/server-contract"; -import { environmentActionFailureDetailsSchema } from "@bb/server-contract"; import { useDialogState } from "@/hooks/useDialogState"; import type { ThreadGitActionDialogTarget } from "@/components/dialogs/ThreadGitActionDialog"; -import { - buildCommitFailureFollowUpInstruction, - buildSquashMergeCommitFailureFollowUpInstruction, - buildSquashMergeConflictFollowUpInstruction, -} from "@/lib/thread-operation-prompts"; -import { BbHttpError } from "@/lib/sdk"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; -import type { - RequestEnvironmentActionMutationLike, - SendMessageMutationLike, -} from "./threadDetailMutationTypes"; - -interface BuildAskAgentInputForGitOperationParams { - error: unknown; - mergeBaseBranch?: string; -} - -interface GitActionFailure { - askAgentInput?: PromptInput[]; - message: string; -} - -interface ToGitActionFailureParams { - action: GitActionKind; - error: unknown; - mergeBaseBranch?: string; -} - -interface AskAgentToFixGitActionParams { - input: PromptInput[]; - threadId: string; -} +import type { RequestEnvironmentActionMutationLike } from "./threadDetailMutationTypes"; interface EnqueueGitActionParams { action: GitActionKind; @@ -68,10 +31,7 @@ interface SquashMergeThreadParams { } interface RunSquashMergeThreadParams - extends SquashMergeThreadParams, - RunQueuedGitActionParams {} - -type AskAgentToFixGitAction = (params: AskAgentToFixGitActionParams) => void; + extends SquashMergeThreadParams, RunQueuedGitActionParams {} type GitActionKind = "commit" | "squash_merge"; type QueuedGitActionRunner = ( @@ -81,9 +41,6 @@ type QueuedGitActionRunner = ( interface ShowGitActionErrorToastParams { action: GitActionKind; error: unknown; - mergeBaseBranch?: string; - onAskAgentToFix: AskAgentToFixGitAction; - threadId: string; toastId: string | number; } @@ -95,12 +52,11 @@ interface ShowGitActionSuccessToastParams { interface UseThreadGitActionsParams { environment?: Environment; requestEnvironmentAction: RequestEnvironmentActionMutationLike; - sendMessage: SendMessageMutationLike; thread?: Thread; workspaceStatus?: WorkspaceStatus; } -interface ThreadHeaderGitAction { +export interface ThreadHeaderGitAction { label: string; target: ThreadGitActionDialogTarget; } @@ -109,142 +65,12 @@ type GitActionSuccessResponse = | CommitActionResponse | SquashMergeActionResponse; -function toEnvironmentActionFailureDetails( - error: unknown, -): EnvironmentActionFailureDetails | undefined { - if ( - !(error instanceof BbHttpError) || - typeof error.body !== "object" || - error.body === null - ) { - return undefined; - } - if (!("details" in error.body)) { - return undefined; - } - - const result = environmentActionFailureDetailsSchema.safeParse( - error.body.details, - ); - return result.success ? result.data : undefined; -} - -function getEnvironmentActionFailureDetailMessage( - details: EnvironmentActionFailureDetails, -): string | undefined { - switch (details.kind) { - case "commit_failed": - return details.errorMessage; - case "squash_merge_conflict": - return details.conflictFiles.length > 0 - ? `Conflicts: ${details.conflictFiles.join(", ")}` - : undefined; - case "squash_merge_commit_failed": - return details.errorMessage; - default: - return undefined; - } -} - -function buildAskAgentInputForGitOperation({ - error, - mergeBaseBranch, -}: BuildAskAgentInputForGitOperationParams): PromptInput[] | undefined { - const details = toEnvironmentActionFailureDetails(error); - if (!details) { - return undefined; - } - - switch (details.kind) { - case "commit_failed": - return [ - { - type: "text", - text: buildCommitFailureFollowUpInstruction({ - errorMessage: details.errorMessage, - }), - mentions: [], - }, - ]; - case "squash_merge_conflict": - if (!mergeBaseBranch) { - return undefined; - } - return [ - { - type: "text", - text: buildSquashMergeConflictFollowUpInstruction( - { - action: "squash_merge", - options: { - mergeBaseBranch, - }, - }, - { conflictFiles: details.conflictFiles }, - ), - mentions: [], - }, - ]; - case "squash_merge_commit_failed": - if (!mergeBaseBranch) { - return undefined; - } - return [ - { - type: "text", - text: buildSquashMergeCommitFailureFollowUpInstruction( - { - action: "squash_merge", - options: { - mergeBaseBranch, - }, - }, - { - stage: details.stage, - errorMessage: details.errorMessage, - }, - ), - mentions: [], - }, - ]; - default: - return undefined; - } -} - -function toGitActionFailure({ - action, - error, - mergeBaseBranch, -}: ToGitActionFailureParams): GitActionFailure { - const details = toEnvironmentActionFailureDetails(error); - const detailsMessage = details - ? getEnvironmentActionFailureDetailMessage(details) - : undefined; - - return { - message: - detailsMessage ?? - getMutationErrorMessage({ - error, - fallbackMessage: "Failed to start git action", - lifecycleOperation: action, - }), - askAgentInput: buildAskAgentInputForGitOperation({ - error, - mergeBaseBranch, - }), - }; -} - function getGitActionSuccessTitle(action: GitActionKind): string { switch (action) { case "commit": return "Commit created"; case "squash_merge": return "Squash merge completed"; - default: - return action; } } @@ -254,8 +80,6 @@ function getGitActionLoadingTitle(action: GitActionKind): string { return "Creating commit"; case "squash_merge": return "Squash merging"; - default: - return action; } } @@ -265,8 +89,6 @@ function getGitActionQueuedTitle(action: GitActionKind): string { return "Commit queued"; case "squash_merge": return "Squash merge queued"; - default: - return action; } } @@ -276,8 +98,6 @@ function getGitActionErrorTitle(action: GitActionKind): string { return "Commit failed"; case "squash_merge": return "Squash merge failed"; - default: - return action; } } @@ -303,38 +123,25 @@ function showGitActionSuccessToast({ function showGitActionErrorToast({ action, error, - mergeBaseBranch, - onAskAgentToFix, - threadId, toastId, }: ShowGitActionErrorToastParams): void { - const failure = toGitActionFailure({ action, error, mergeBaseBranch }); - const askAgentInput = failure.askAgentInput; const title = getGitActionErrorTitle(action); - const description = failure.message === title ? undefined : failure.message; + const message = getMutationErrorMessage({ + error, + fallbackMessage: "Failed to start git action", + lifecycleOperation: action, + }); + const description = message === title ? undefined : message; appToast.error(title, { id: toastId, ...(description ? { description } : {}), - ...(askAgentInput - ? { - action: { - label: "Ask agent to fix", - onClick: () => - onAskAgentToFix({ - input: askAgentInput, - threadId, - }), - }, - } - : {}), }); } export function useThreadGitActions({ environment, requestEnvironmentAction, - sendMessage, thread, workspaceStatus, }: UseThreadGitActionsParams) { @@ -389,35 +196,6 @@ export function useThreadGitActions({ workspaceWorkingTree?.hasUncommittedChanges, ]); - const handleAskAgentToFixGitAction = useCallback( - async ({ input, threadId }: AskAgentToFixGitActionParams) => { - if (sendMessage.isPending) { - return; - } - - const toastId = appToast.loading("Sending message"); - - try { - await sendMessage.mutateAsync({ - id: threadId, - input, - mode: "queue-if-active", - }); - appToast.success("Message sent", { id: toastId }); - } catch (error) { - appToast.error("Failed to message agent", { - id: toastId, - description: getMutationErrorMessage({ - error, - fallbackMessage: "Message was not sent", - lifecycleOperation: "send_message", - }), - }); - } - }, - [sendMessage], - ); - const enqueueGitAction = useCallback( ({ action, run }: EnqueueGitActionParams): Promise => { const isQueuedBehindGitAction = queuedGitActionCountRef.current > 0; @@ -456,8 +234,6 @@ export function useThreadGitActions({ appToast.dismiss(toastId); return; } - const threadId = thread.id; - try { const response = await requestEnvironmentAction.mutateAsync({ id: attachedEnvironmentId, @@ -474,14 +250,11 @@ export function useThreadGitActions({ showGitActionErrorToast({ action: "commit", error: nextError, - onAskAgentToFix: (params) => - void handleAskAgentToFixGitAction(params), - threadId, toastId, }); } }, - [handleAskAgentToFixGitAction, requestEnvironmentAction, thread], + [requestEnvironmentAction, thread], ); const handleCommitThread = useCallback(async () => { @@ -498,8 +271,6 @@ export function useThreadGitActions({ appToast.dismiss(toastId); return; } - const threadId = thread.id; - try { const response = await requestEnvironmentAction.mutateAsync({ id: attachedEnvironmentId, @@ -519,15 +290,11 @@ export function useThreadGitActions({ showGitActionErrorToast({ action: "squash_merge", error: nextError, - onAskAgentToFix: (params) => - void handleAskAgentToFixGitAction(params), - mergeBaseBranch, - threadId, toastId, }); } }, - [handleAskAgentToFixGitAction, requestEnvironmentAction, thread], + [requestEnvironmentAction, thread], ); const handleSquashMergeThread = useCallback( @@ -545,7 +312,6 @@ export function useThreadGitActions({ ); return { - handleAskAgentToFixGitAction, handleCommitThread, handleSquashMergeThread, threadGitActionDialog, diff --git a/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts b/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts index 7701ec03b1..52d250b089 100644 --- a/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts +++ b/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts @@ -3,20 +3,18 @@ import type { HostFileTabState, ThreadStorageFileTabState, WorkspaceFileTabState, -} from "@/lib/file-preview"; +} from "@bb/client-core"; import type { ThreadSecondaryPanel } from "@/lib/thread-secondary-panel"; -import type { FileTabViewerOverride } from "@/components/plugin/file-opener-tabs"; +import type { FileOpenerOverride } from "@/lib/plugin-slot-resolvers"; type ThreadSecondaryPanelThreadId = string | undefined; -export type ThreadSecondaryPanelOpenHandler = ( - panel: ThreadSecondaryPanel, -) => void; -export type ThreadSecondaryPanelDiffFileOpenHandler = (path: string) => void; -export type ThreadSecondaryPanelCommitDiffOpenHandler = (sha: string) => void; +type ThreadSecondaryPanelOpenHandler = (panel: ThreadSecondaryPanel) => void; +type ThreadSecondaryPanelDiffFileOpenHandler = (path: string) => void; +type ThreadSecondaryPanelCommitDiffOpenHandler = (sha: string) => void; export interface ThreadSecondaryPanelFileOpenOptions { /** Per-open viewer choice (link context menu); absent = extension default. */ - viewer?: FileTabViewerOverride; + viewer?: FileOpenerOverride; } export type ThreadSecondaryPanelWorkspaceFileOpenHandler = ( file: WorkspaceFileTabState, @@ -46,19 +44,19 @@ export interface UseThreadSecondaryPanelVisibilityArgs { togglePersistedPanel: () => void; } -export interface UseThreadSecondaryPanelDrawerVisibilityArgs { +interface UseThreadSecondaryPanelDrawerVisibilityArgs { isCompactViewport: boolean; threadId: ThreadSecondaryPanelThreadId; } -export interface ThreadSecondaryPanelDrawerVisibility { +interface ThreadSecondaryPanelDrawerVisibility { closeDrawer: () => void; isDrawerVisible: boolean; openDrawer: () => void; toggleDrawer: () => void; } -export interface ThreadSecondaryPanelVisibility { +interface ThreadSecondaryPanelVisibility { closePanel: () => void; isOpen: boolean; openCommitDiff: ThreadSecondaryPanelCommitDiffOpenHandler; diff --git a/apps/app/src/views/thread-detail/useThreadUnreadDividerState.ts b/apps/app/src/views/thread-detail/useThreadUnreadDividerState.ts index f0e83cc254..2d1d57d50d 100644 --- a/apps/app/src/views/thread-detail/useThreadUnreadDividerState.ts +++ b/apps/app/src/views/thread-detail/useThreadUnreadDividerState.ts @@ -14,7 +14,7 @@ interface ThreadUnreadDividerSnapshot { threadId: string; } -export interface ThreadUnreadDividerState { +interface ThreadUnreadDividerState { autoScroll: boolean; placement: ThreadTimelineUnreadDividerPlacement | null; } @@ -29,7 +29,7 @@ interface IsThreadUnreadArgs { latestAttentionAt: number | undefined; } -export interface UseThreadUnreadDividerStateArgs { +interface UseThreadUnreadDividerStateArgs { routeThreadId: string | undefined; thread: ThreadUnreadDividerThreadState | undefined; } diff --git a/apps/app/vite-bundle-stats.ts b/apps/app/vite-bundle-stats.ts index 9ce4be5299..82fc9504ff 100644 --- a/apps/app/vite-bundle-stats.ts +++ b/apps/app/vite-bundle-stats.ts @@ -5,7 +5,7 @@ import type { Plugin } from "vite"; const appDir = dirname(fileURLToPath(import.meta.url)); -export interface BundleBootChunk { +interface BundleBootChunk { fileName: string; bytes: number; /** npm package names whose code landed in this chunk. */ @@ -27,7 +27,7 @@ export interface BundleChunk extends BundleBootChunk { * are already on the boot path. This is the JavaScript that must arrive * between "app shell painted" and "route content painted". */ -export interface BundleRouteClosure { +interface BundleRouteClosure { /** The route's own chunk (the target of App's `lazy(() => import(...))`). */ entry: string; chunks: BundleBootChunk[]; @@ -46,7 +46,7 @@ export interface BundleStats { * name used in bundle-budget.json. The value is the route module's source * path suffix, matched against the output chunk's `facadeModuleId`. */ -export const MEASURED_ROUTE_CLOSURES: Record = { +const MEASURED_ROUTE_CLOSURES: Record = { SplitWorkspaceRoute: "/src/views/SplitWorkspaceRoute.tsx", }; diff --git a/apps/app/vite-font-preload.ts b/apps/app/vite-font-preload.ts index a62f6ade77..aef6e54f73 100644 --- a/apps/app/vite-font-preload.ts +++ b/apps/app/vite-font-preload.ts @@ -7,7 +7,7 @@ import type { HtmlTagDescriptor, Plugin } from "vite"; * italics) stay lazy: preloading them would cost bytes on every load for * glyphs most sessions never draw. */ -export const PRELOADED_FONT_BASENAME = "inter-latin-wght-normal"; +const PRELOADED_FONT_BASENAME = "inter-latin-wght-normal"; const PRELOADED_FONT_FILE_RE = new RegExp( `(^|/)${PRELOADED_FONT_BASENAME}(-[\\w-]+)?\\.woff2$`, ); diff --git a/apps/app/vite.dev.config.ts b/apps/app/vite.dev.config.ts index 2d4743c120..93a8a258c1 100644 --- a/apps/app/vite.dev.config.ts +++ b/apps/app/vite.dev.config.ts @@ -4,7 +4,7 @@ import { sharedViteConfig } from "./vite.config.js"; const viteDevConfig = loadViteDevConfig(); const devWebSocketBrowserHostPortDefine = JSON.stringify( - viteDevConfig.serverWsOrigin.port, + viteDevConfig.serverPort, ); export default defineConfig({ diff --git a/apps/cli/src/__tests__/command-output/manager.test.ts b/apps/cli/src/__tests__/command-output/manager.test.ts index 06c2871bcb..04d0a35931 100644 --- a/apps/cli/src/__tests__/command-output/manager.test.ts +++ b/apps/cli/src/__tests__/command-output/manager.test.ts @@ -11,7 +11,7 @@ describe("bb manager command output", () => { setupCommandOutputTestEnvironment(); const register: CommandRegistrar = (program) => - registerManagerCommands(program, () => "http://server"); + registerManagerCommands(program); it("bb manager exits with a parent-thread replacement message", async () => { await expect(runCommand(["manager"], register)).rejects.toThrow( diff --git a/apps/cli/src/__tests__/command-output/settings.test.ts b/apps/cli/src/__tests__/command-output/settings.test.ts index 693036e4a3..771d69e12a 100644 --- a/apps/cli/src/__tests__/command-output/settings.test.ts +++ b/apps/cli/src/__tests__/command-output/settings.test.ts @@ -34,39 +34,15 @@ describe("bb settings commands", () => { }); }); - // Keys and value shapes come from `appSettingsSchema`, so non-boolean and - // nullable preferences are settable without a per-key branch in the command. - it("sets a nullable setting and rejects an unknown key", async () => { - const put = vi.fn(async ({ json }) => json); + // Keys come from `appSettingsSchema`, so an unknown one is rejected by the + // command rather than sent to the server. + it("rejects an unknown general setting key", async () => { stubServerApi({ "v1.system.config.$get": vi.fn(async () => ({ - generalSettings: { - ...defaultAppSettings, - onboardingCompletedAt: "2026-08-06T00:00:00.000Z", - }, + generalSettings: defaultAppSettings, experiments: defaultExperiments, })), - "v1.settings.general.$put": put, - }); - - await runCommand( - ["settings", "general", "onboardingCompletedAt", "null"], - register, - ); - - expect(put).toHaveBeenCalledWith({ - json: { ...defaultAppSettings, onboardingCompletedAt: null }, - }); - - // "2026" reads as JSON, but this setting takes a string, so the raw text - // has to win: the setting's own schema decides which reading applies. - await runCommand( - ["settings", "general", "onboardingCompletedAt", "2026"], - register, - ); - - expect(put).toHaveBeenLastCalledWith({ - json: { ...defaultAppSettings, onboardingCompletedAt: "2026" }, + "v1.settings.general.$put": vi.fn(async ({ json }) => json), }); await expect( @@ -114,66 +90,51 @@ describe("bb settings commands", () => { }); }); - it("enables new onboarding before replaying the setup guide", async () => { - const updateExperiments = vi.fn(async ({ json }) => json); - const updateGeneralSettings = vi.fn(async ({ json }) => json); + it("enables the changelog preview experiment", async () => { + const put = vi.fn(async ({ json }) => json); stubServerApi({ "v1.system.config.$get": vi.fn(async () => ({ - generalSettings: { - ...defaultAppSettings, - onboardingCompletedAt: "2026-08-06T00:00:00.000Z", - }, + generalSettings: defaultAppSettings, experiments: defaultExperiments, })), - "v1.settings.experiments.$put": updateExperiments, - "v1.settings.general.$put": updateGeneralSettings, + "v1.settings.experiments.$put": put, }); - await runCommand(["settings", "replay-onboarding"], register); + await runCommand( + ["settings", "experiment", "changelogPreview", "true"], + register, + ); - expect(updateExperiments).toHaveBeenCalledWith({ - json: { ...defaultExperiments, newOnboarding: true }, - }); - expect(updateGeneralSettings).toHaveBeenCalledWith({ - json: { ...defaultAppSettings, onboardingCompletedAt: null }, + expect(put).toHaveBeenCalledWith({ + json: { ...defaultExperiments, changelogPreview: true }, }); - expect(console.log).toHaveBeenCalledWith( - "New onboarding is enabled; onboarding will show again", - ); }); - it("reports both replay side effects as JSON", async () => { + it("updates timeline windowing while preserving every experiment", async () => { + const updateExperiments = vi.fn(async ({ json }) => json); stubServerApi({ "v1.system.config.$get": vi.fn(async () => ({ generalSettings: defaultAppSettings, experiments: defaultExperiments, })), - "v1.settings.experiments.$put": vi.fn(async ({ json }) => json), - "v1.settings.general.$put": vi.fn(async ({ json }) => json), + "v1.settings.experiments.$put": updateExperiments, }); - await runCommand(["settings", "replay-onboarding", "--json"], register); - - expect(console.log).toHaveBeenCalledWith( - JSON.stringify( - { - experiments: { ...defaultExperiments, newOnboarding: true }, - generalSettings: { - ...defaultAppSettings, - onboardingCompletedAt: null, - }, - }, - null, - 2, - ), + await runCommand( + ["settings", "experiment", "timelineWindowing", "true"], + register, ); + + expect(updateExperiments).toHaveBeenCalledWith({ + json: { ...defaultExperiments, timelineWindowing: true }, + }); }); it("reads usage from a selected machine", async () => { const getUsage = vi.fn(async () => ({ codex: { status: "unauthenticated" }, - claudeCode: { status: "unauthenticated" }, - cursor: { status: "unauthenticated" }, + "claude-code": { status: "unauthenticated" }, + "acp-cursor": { status: "unauthenticated" }, })); stubServerApi({ "v1.hosts.$get": vi.fn(async () => [ diff --git a/apps/cli/src/__tests__/command-output/thread-log.test.ts b/apps/cli/src/__tests__/command-output/thread-log.test.ts index 70890e7af2..8e09775726 100644 --- a/apps/cli/src/__tests__/command-output/thread-log.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-log.test.ts @@ -237,6 +237,252 @@ describe("bb thread log command output", () => { expect(getEvents).not.toHaveBeenCalled(); }); + it("bb thread log --json caps at --limit and warns on stderr when more events exist", async () => { + // The server lists ascending by sequence, so a capped page is the OLDEST + // events. Without a warning a grep over the default page looks like a + // search over the whole thread (#1768). + const events = Array.from({ length: 4 }, (_, index) => ({ + id: `evt-${index + 1}`, + scope: { kind: "thread" }, + threadId: "thread-json-log", + type: "system/error", + data: { code: "provider_unavailable" }, + createdAt: 20 + index, + seq: index + 1, + })); + const getEvents = vi.fn(async () => events); + stubServerApi({ + "v1.threads.:id.events.$get": getEvents, + }); + + await runCommand( + ["thread", "log", "thread-json-log", "--json", "--limit", "3"], + register, + ); + + expect(getEvents).toHaveBeenCalledWith({ + param: { id: "thread-json-log" }, + query: { limit: "4" }, + }); + expect( + JSON.parse(String(vi.mocked(console.log).mock.calls[0]?.[0])), + ).toEqual(events.slice(0, 3)); + const stderr = collectLogLines(vi.mocked(console.error)).join("\n"); + expect(stderr).toContain("oldest 3 events"); + expect(stderr).toContain("--after-seq 3"); + expect(stderr).toContain("--all"); + }); + + it("bb thread log --json stays quiet when the page is not full", async () => { + const events = [ + { + id: "evt-1", + scope: { kind: "thread" }, + threadId: "thread-json-log", + type: "system/error", + data: { code: "provider_unavailable" }, + createdAt: 20, + seq: 1, + }, + ]; + stubServerApi({ + "v1.threads.:id.events.$get": vi.fn(async () => events), + }); + + await runCommand(["thread", "log", "thread-json-log", "--json"], register); + + expect( + JSON.parse(String(vi.mocked(console.log).mock.calls[0]?.[0])), + ).toEqual(events); + expect(collectLogLines(vi.mocked(console.error))).toEqual([]); + }); + + it("bb thread log --json --all pages through every event with --after-seq", async () => { + const makeEvent = (seq: number) => ({ + id: `evt-${seq}`, + scope: { kind: "thread" }, + threadId: "thread-json-log", + type: "system/error", + data: { code: "provider_unavailable" }, + createdAt: 20 + seq, + seq, + }); + const getEvents = vi.fn( + async (input: { query: { afterSeq?: string; limit?: string } }) => { + const afterSeq = Number(input.query.afterSeq ?? 0); + const limit = Number(input.query.limit); + return Array.from({ length: 1203 }, (_, index) => makeEvent(index + 1)) + .filter((event) => event.seq > afterSeq) + .slice(0, limit); + }, + ); + stubServerApi({ + "v1.threads.:id.events.$get": getEvents, + }); + + await runCommand( + ["thread", "log", "thread-json-log", "--json", "--all"], + register, + ); + + const printed = JSON.parse( + String(vi.mocked(console.log).mock.calls[0]?.[0]), + ) as Array<{ seq: number }>; + expect(printed).toHaveLength(1203); + expect(printed[0]?.seq).toBe(1); + expect(printed[1202]?.seq).toBe(1203); + expect(getEvents.mock.calls.map((call) => call[0].query.afterSeq)).toEqual([ + undefined, + "1000", + ]); + expect(collectLogLines(vi.mocked(console.error))).toEqual([]); + }); + + it("bb thread log prints an older-history notice when the timeline page is cut", async () => { + const getTimeline = vi.fn(async () => ({ + ...fixtures.makeTimelineResponse([ + fixtures.makePendingSteerTimelineRow(), + ]), + timelinePage: { + kind: "latest" as const, + segmentLimit: 20, + returnedSegmentCount: 20, + hasOlderRows: true, + olderCursor: { anchorSeq: 12, anchorId: "pending-steer-1" }, + }, + })); + stubServerApi({ + "v1.threads.:id.timeline.$get": getTimeline, + }); + + await runCommand(["thread", "log", "thread-log"], register); + + const output = String(vi.mocked(console.log).mock.calls[0]?.[0]); + expect(output).toContain("Please switch to the safer plan"); + expect(output).toContain("newest 20 user-message turns"); + expect(output).toContain("older history omitted"); + expect(output).toContain("--all"); + }); + + it("bb thread log --limit sets the timeline segment limit for human output", async () => { + const getTimeline = vi.fn(async () => + fixtures.makeTimelineResponse([fixtures.makePendingSteerTimelineRow()]), + ); + stubServerApi({ + "v1.threads.:id.timeline.$get": getTimeline, + }); + + await runCommand( + ["thread", "log", "thread-log", "--format", "verbose", "--limit", "50"], + register, + ); + + expect(getTimeline).toHaveBeenCalledWith({ + param: { id: "thread-log" }, + query: { includeNestedRows: "true", segmentLimit: "50" }, + }); + const output = String(vi.mocked(console.log).mock.calls[0]?.[0]); + expect(output).toContain("Please switch to the safer plan"); + expect(output).not.toContain("older history omitted"); + }); + + it("bb thread log --all walks older timeline pages and prints them oldest first", async () => { + const makeUserRow = (id: string, seq: number, text: string) => ({ + ...fixtures.makePendingSteerTimelineRow(), + ...fixtures.makeTimelineBase({ id, sourceSeqStart: seq }), + text, + turnRequest: { + isGrouped: false, + kind: "message" as const, + status: "accepted" as const, + }, + }); + const getTimeline = vi.fn( + async (input: { + query: { beforeAnchorSeq?: string; beforeAnchorId?: string }; + }) => { + if (input.query.beforeAnchorSeq === undefined) { + return { + ...fixtures.makeTimelineResponse([ + makeUserRow("user-3", 30, "third prompt"), + ]), + timelinePage: { + kind: "latest" as const, + segmentLimit: 100, + returnedSegmentCount: 1, + hasOlderRows: true, + olderCursor: { anchorSeq: 30, anchorId: "user-3" }, + }, + }; + } + if (input.query.beforeAnchorSeq === "30") { + return { + ...fixtures.makeTimelineResponse([ + makeUserRow("user-2", 20, "second prompt"), + ]), + timelinePage: { + kind: "older" as const, + segmentLimit: 100, + returnedSegmentCount: 1, + hasOlderRows: true, + olderCursor: { anchorSeq: 20, anchorId: "user-2" }, + }, + }; + } + return { + ...fixtures.makeTimelineResponse([ + makeUserRow("user-1", 10, "first prompt"), + ]), + timelinePage: { + kind: "older" as const, + segmentLimit: 100, + returnedSegmentCount: 1, + hasOlderRows: false, + olderCursor: null, + }, + }; + }, + ); + stubServerApi({ + "v1.threads.:id.timeline.$get": getTimeline, + }); + + await runCommand(["thread", "log", "thread-log", "--all"], register); + + expect(getTimeline.mock.calls.map((call) => call[0].query)).toEqual([ + { segmentLimit: "100" }, + { segmentLimit: "100", beforeAnchorSeq: "30", beforeAnchorId: "user-3" }, + { segmentLimit: "100", beforeAnchorSeq: "20", beforeAnchorId: "user-2" }, + ]); + const output = String(vi.mocked(console.log).mock.calls[0]?.[0]); + expect(output.indexOf("first prompt")).toBeGreaterThan(-1); + expect(output.indexOf("first prompt")).toBeLessThan( + output.indexOf("second prompt"), + ); + expect(output.indexOf("second prompt")).toBeLessThan( + output.indexOf("third prompt"), + ); + expect(output).not.toContain("older history omitted"); + }); + + it("bb thread log rejects --all combined with --limit", async () => { + stubServerApi({ + "v1.threads.:id.timeline.$get": vi.fn(async () => + fixtures.makeTimelineResponse([]), + ), + }); + + await expect( + runCommand( + ["thread", "log", "thread-log", "--all", "--limit", "5"], + register, + ), + ).rejects.toThrow("process.exit:1"); + expect(collectLogLines(vi.mocked(console.error)).join("\n")).toContain( + "--all cannot be combined with --limit", + ); + }); + it("bb thread log --self resolves from BB_THREAD_ID", async () => { vi.stubEnv("BB_THREAD_ID", "thread-log-self"); const getEvents = vi.fn(async () => []); diff --git a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts index e9930bedf0..786496f2ff 100644 --- a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts @@ -92,6 +92,51 @@ describe("bb thread spawn command output", () => { }); }); + it("bb thread spawn --plan opens the thread with the composer's /plan command mention", async () => { + const thread: domain.Thread = fixtures.makeThread({ + id: "thread-plan", + projectId: "proj-1", + providerId: "claude-code", + }); + const post = vi.fn(async () => thread); + stubServerApi({ "v1.threads.$post": post }); + + await runCommand( + [ + "thread", + "spawn", + "--project", + "proj-1", + "--prompt", + "add a README", + "--plan", + ], + register, + ); + + expect(post).toHaveBeenCalledWith({ + json: expect.objectContaining({ + input: [ + { + type: "text", + text: "/plan add a README", + mentions: [ + expect.objectContaining({ + start: 0, + end: 5, + resource: expect.objectContaining({ + kind: "command", + trigger: "/", + name: "plan", + }), + }), + ], + }, + ], + }), + }); + }); + it("bb thread spawn requires an explicit --project", async () => { vi.stubEnv("BB_PROJECT_ID", undefined); const post = vi.fn(); diff --git a/apps/cli/src/__tests__/command-output/thread-tell.test.ts b/apps/cli/src/__tests__/command-output/thread-tell.test.ts index dbca18dc1b..5d9f98eb4d 100644 --- a/apps/cli/src/__tests__/command-output/thread-tell.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-tell.test.ts @@ -128,6 +128,56 @@ describe("bb thread tell command output", () => { }); }); + // Plan mode is keyed on the structured /plan command mention the composer + // sends, never on literal text; without the mention the Claude CLI answers + // "/plan isn't available in this environment" (#2019). + it("bb thread tell --plan sends the composer's /plan command mention", async () => { + const post = vi.fn(async () => ({ ok: true })); + stubServerApi({ "v1.threads.:id.send.$post": post }); + + await runCommand( + [ + "thread", + "tell", + "thread-plan", + "add a README", + "--plan", + "--file", + "/tmp/report.pdf", + ], + register, + ); + + expect(post).toHaveBeenCalledWith({ + param: { id: "thread-plan" }, + json: { + input: [ + { + type: "text", + text: "/plan add a README", + mentions: [ + { + start: 0, + end: 5, + resource: { + kind: "command", + trigger: "/", + name: "plan", + source: "command", + origin: "builtin", + label: "plan", + argumentHint: null, + }, + }, + ], + }, + { type: "localFile", path: "/tmp/report.pdf" }, + ], + mode: "steer-if-active", + }, + }); + }); + it("bb thread tell forwards host-readable paths without reading them on the CLI machine", async () => { const post = vi.fn(async () => ({ ok: true })); stubServerApi({ "v1.threads.:id.send.$post": post }); diff --git a/apps/cli/src/__tests__/command-output/updates.test.ts b/apps/cli/src/__tests__/command-output/updates.test.ts index 7e7c3f0789..9318eb6b5a 100644 --- a/apps/cli/src/__tests__/command-output/updates.test.ts +++ b/apps/cli/src/__tests__/command-output/updates.test.ts @@ -68,12 +68,11 @@ function providerStatus(args: { ? { kind: "update" as const, label: "Update" as const, - commandKind: "exec" as const, command: "codex update", } : null, }, - claudeCode: { + "claude-code": { ...base, displayName: "Claude Code", executableName: "claude", @@ -82,7 +81,7 @@ function providerStatus(args: { needsUpdate: false, installAction: null, }, - cursor: { + "acp-cursor": { ...base, displayName: "Cursor", executableName: "agent", @@ -115,11 +114,11 @@ describe("bb updates command output", () => { const output = collectLogPayloads(vi.mocked(console.log)).join("\n"); expect(output).toContain("bb-app"); expect(output).toContain("0.0.32 -> 0.0.33"); - expect(output).toContain("update available (run: npx bb-app@latest)"); + expect(output).toContain("Update available (run: npx bb-app@latest)"); expect(output).toContain("workstation · Codex"); expect(output).toContain("0.140.0 -> 0.141.0"); expect(output).toContain("workstation · Claude Code"); - expect(output).toContain("up to date"); + expect(output).toContain("Up to date"); expect(output).toContain("laptop"); expect(output).toContain("offline"); }); @@ -208,7 +207,7 @@ describe("bb updates command output", () => { await runCommand(["updates"], register); expect(collectLogPayloads(vi.mocked(console.log)).join("\n")).toContain( - "update manually", + "Update in terminal", ); vi.mocked(console.log).mockClear(); diff --git a/apps/cli/src/__tests__/context-env.test.ts b/apps/cli/src/__tests__/context-env.test.ts index 3ed657a51c..1f792b76a5 100644 --- a/apps/cli/src/__tests__/context-env.test.ts +++ b/apps/cli/src/__tests__/context-env.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createCliRuntimeContext, - requireProjectId, requireThreadId, requireThreadIdOrSelf, resolveContextProjectId, @@ -22,9 +21,6 @@ describe("context-env", () => { }); it("requires project and thread context when missing", () => { - expect(() => requireProjectId(undefined)).toThrow( - "Missing project ID. Pass --project .", - ); expect(() => requireThreadId(undefined)).toThrow( "Missing thread ID. Pass .", ); diff --git a/apps/cli/src/__tests__/helpers/command-output-harness.ts b/apps/cli/src/__tests__/helpers/command-output-harness.ts index 95c82d043e..a7e7428611 100644 --- a/apps/cli/src/__tests__/helpers/command-output-harness.ts +++ b/apps/cli/src/__tests__/helpers/command-output-harness.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, expect, vi } from "vitest"; import { Command } from "commander"; import { createApiClient, type ApiClient } from "@bb/server-contract"; -import type { BbSdkContext } from "@bb/sdk"; const readlineState = vi.hoisted(() => ({ question: vi.fn(), @@ -29,22 +28,19 @@ vi.mock("../../client.js", async () => { status: 200, headers: { "Content-Type": "application/json" }, }); - const createCliBbSdk = vi.fn( - (baseUrl: string, options: MockCliBbSdkOptions = {}) => { - const realTransport = createHttpTransport({ baseUrl, runtime: "node" }); - return createBbSdk({ - context: options.context, - transport: { - ...realTransport, - api: serverClientState.createClient(baseUrl)?.api ?? {}, - readJson: (responsePromise: MockTransportPromise) => - realTransport.readJson(responsePromise.then(toResponse)), - readVoid: (responsePromise: MockTransportPromise) => - realTransport.readVoid(responsePromise.then(toResponse)), - }, - }); - }, - ); + const createCliBbSdk = vi.fn((baseUrl: string) => { + const realTransport = createHttpTransport({ baseUrl, runtime: "node" }); + return createBbSdk({ + transport: { + ...realTransport, + api: serverClientState.createClient(baseUrl)?.api ?? {}, + readJson: (responsePromise: MockTransportPromise) => + realTransport.readJson(responsePromise.then(toResponse)), + readVoid: (responsePromise: MockTransportPromise) => + realTransport.readVoid(responsePromise.then(toResponse)), + }, + }); + }); return { cliFetch, createCliBbSdk }; }); @@ -78,10 +74,6 @@ interface ServerClientOverride { api: object; } -interface MockCliBbSdkOptions { - context?: BbSdkContext; -} - export const createClientMock = serverClientState.createClient; export const readlineMocks = readlineState; export const resolveLocalHostIdMock = vi.mocked(resolveLocalHostId); diff --git a/apps/cli/src/__tests__/json-flag-enforcement.test.ts b/apps/cli/src/__tests__/json-flag-enforcement.test.ts index 9996250ba2..f4d3efd746 100644 --- a/apps/cli/src/__tests__/json-flag-enforcement.test.ts +++ b/apps/cli/src/__tests__/json-flag-enforcement.test.ts @@ -35,7 +35,7 @@ describe("CLI --json flag enforcement", () => { registerStatusCommand(program, getUrl); registerProjectCommands(program, getUrl); registerProviderCommands(program, getUrl); - registerManagerCommands(program, getUrl); + registerManagerCommands(program); registerMachineCommands(program, getUrl); registerThreadCommands(program, getUrl); diff --git a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts index 4ea61d1c7b..2b0217e483 100644 --- a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts +++ b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts @@ -32,7 +32,7 @@ function buildProgram(): Command { registerStatusCommand(program, getUrl); registerProjectCommands(program, getUrl); registerProviderCommands(program, getUrl); - registerManagerCommands(program, getUrl); + registerManagerCommands(program); registerThreadCommands(program, getUrl); registerEnvironmentCommands(program, getUrl); registerThemeCommands(program, getUrl); diff --git a/apps/cli/src/__tests__/spawn-helpers.test.ts b/apps/cli/src/__tests__/spawn-helpers.test.ts index 116c57cdab..376d51638d 100644 --- a/apps/cli/src/__tests__/spawn-helpers.test.ts +++ b/apps/cli/src/__tests__/spawn-helpers.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; +import { DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS } from "@bb/sdk"; import { buildSpawnEnvironment, looksLikePath, requireHostId, } from "../commands/thread/spawn.js"; import { - DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS, DEFAULT_THREAD_WAIT_TIMEOUT_SECONDS, parseThreadWaitTimeoutSeconds, parseThreadWaitPollIntervalMs, diff --git a/apps/cli/src/bb-cli-reexec.ts b/apps/cli/src/bb-cli-reexec.ts index 3ae3b9c971..1f04e595ac 100644 --- a/apps/cli/src/bb-cli-reexec.ts +++ b/apps/cli/src/bb-cli-reexec.ts @@ -11,7 +11,7 @@ import { resolve } from "node:path"; /** Set on the re-exec child so a shell-script → node hop cannot loop. */ export const BB_CLI_REEXEC_ENV = "BB_CLI_REEXEC"; -export interface MaybeReexecViaBbCliArgs { +interface MaybeReexecViaBbCliArgs { env?: NodeJS.ProcessEnv; /** Arguments after the node/script path (default: process.argv.slice(2)). */ argv?: string[]; diff --git a/apps/cli/src/client.ts b/apps/cli/src/client.ts index af46f61e48..a5a851c6a9 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -1,15 +1,11 @@ -import { createNodeBbSdk, type BbSdk, type BbSdkContext } from "@bb/sdk/node"; +import { createNodeBbSdk, type BbSdk } from "@bb/sdk/node"; import type { Dispatcher } from "undici"; -export interface CreateCliBbSdkOptions { - context?: BbSdkContext; -} - /** * Node's fetch accepts the non-standard undici `dispatcher` option so one call * can use its own connection pool and timeouts (see plugin-cli-proxy.ts). */ -export type CliRequestInit = RequestInit & { dispatcher?: Dispatcher }; +type CliRequestInit = RequestInit & { dispatcher?: Dispatcher }; export function cliFetch( input: RequestInfo | URL, @@ -18,13 +14,6 @@ export function cliFetch( return fetch(input, init); } -export function createCliBbSdk( - baseUrl: string, - options: CreateCliBbSdkOptions = {}, -): BbSdk { - return createNodeBbSdk({ - baseUrl, - context: options.context, - fetch: cliFetch, - }); +export function createCliBbSdk(baseUrl: string): BbSdk { + return createNodeBbSdk({ baseUrl, fetch: cliFetch }); } diff --git a/apps/cli/src/commands/helpers.ts b/apps/cli/src/commands/helpers.ts index 1106690b3f..2a391b1cc3 100644 --- a/apps/cli/src/commands/helpers.ts +++ b/apps/cli/src/commands/helpers.ts @@ -13,7 +13,6 @@ import type { ResolvedId } from "../context-env.js"; export { type ResolvedId, - type ThreadSelfTargetOptions, requireThreadId, requireThreadIdOrSelf, } from "../context-env.js"; diff --git a/apps/cli/src/commands/machine.ts b/apps/cli/src/commands/machine.ts index c75c940925..7cf8e8b438 100644 --- a/apps/cli/src/commands/machine.ts +++ b/apps/cli/src/commands/machine.ts @@ -18,15 +18,11 @@ interface MachineProviderInstallOptions extends MachineListCommandOptions { action?: "install" | "update"; } -function parseProviderCliKey(value: string): "claudeCode" | "codex" | "cursor" { - switch (value) { - case "claudeCode": - case "codex": - case "cursor": - return value; - default: - throw new Error("provider must be claudeCode, codex, or cursor."); - } +function parseProviderCliKey(value: string): string { + const providerId = value.trim(); + if (providerId.length === 0) + throw new Error("provider ID must not be empty."); + return providerId; } function describeMachines(hosts: readonly Host[]): string { @@ -64,7 +60,7 @@ export function resolveMachineTargetOption(args: { return args.machine ?? args.host; } -export type MachineEnvironmentRouting = +type MachineEnvironmentRouting = | { environmentId: string; hostId?: never } | { environmentId?: never; hostId: string } | { environmentId?: never; hostId?: never }; @@ -230,7 +226,7 @@ export function registerMachineCommands( .description("Inspect and install provider CLIs on a machine"); providerCli .command("status ") - .description("Show provider CLI health") + .description("Show registered provider CLI installation/update status") .option("--json", "Print machine-readable JSON output") .action( action(async (target: string, opts: MachineListCommandOptions) => { @@ -243,7 +239,7 @@ export function registerMachineCommands( ); providerCli .command("install ") - .description("Install or update a provider CLI") + .description("Install or update a registered provider CLI by provider ID") .option("--action ", "Action: install or update", "install") .option("--json", "Print machine-readable JSON output") .action( diff --git a/apps/cli/src/commands/manager.ts b/apps/cli/src/commands/manager.ts index 4ef8b8246b..fc639fe949 100644 --- a/apps/cli/src/commands/manager.ts +++ b/apps/cli/src/commands/manager.ts @@ -30,10 +30,7 @@ function registerRemovedManagerSubcommand( })); } -export function registerManagerCommands( - program: Command, - _getUrl: () => string, -): void { +export function registerManagerCommands(program: Command): void { const manager = program .command("manager") .description("Compatibility notice for removed manager commands") diff --git a/apps/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts index 9eb35bc0d5..08e0ebe535 100644 --- a/apps/cli/src/commands/plugin.ts +++ b/apps/cli/src/commands/plugin.ts @@ -44,7 +44,7 @@ import { resolveBbCliVersion } from "../version.js"; import { outputJson, type JsonOutputOptions } from "./helpers.js"; import { renderBorderlessTable } from "../table.js"; -export interface NewPluginTarget { +interface NewPluginTarget { packageName: string; directoryName: string; } @@ -1082,6 +1082,21 @@ export function registerPluginCommands( const pkg = pluginPackageSummarySchema.parse(raw); if (pkg.name !== undefined) { summary = `Installing ${pkg.name}@${pkg.version ?? "?"} from ${path}`; + // The same id installed from another local directory is + // moved, not refused; name the install being replaced so + // the confirmation is about the move, not a fresh install. + const pluginId = derivePluginId(pkg.name); + const { plugins } = await createCliBbSdk( + getUrl(), + ).plugins.list(); + const installed = plugins.find((p) => p.id === pluginId); + if ( + installed !== undefined && + installed.source.startsWith("path:") && + installed.rootDir !== path + ) { + summary = `${summary}\nThis moves "${pluginId}" from ${installed.rootDir}; its settings, secrets, and schedules are kept.`; + } } } catch { // fall through to the bare path summary @@ -1225,7 +1240,7 @@ export function registerPluginCommands( if (!shouldAttempt) { if (result.outcome === "pinned") { console.log( - `${result.id}: skipped — pinned${detail ? ` (${detail})` : ""}; remove and reinstall with a tracking npm range, git branch, or git semver range to receive updates.`, + `${result.id}: skipped — pinned${detail ? ` (${detail})` : ""}; remove and reinstall with a tracking npm range, git branch, or git semver range to receive updates (remove deletes the plugin's settings, secrets, and schedules). A local path plugin updates with \`bb plugin reload\`; move it with \`bb plugin install path:\`.`, ); } else if (result.outcome === "incompatible") { console.log( @@ -1638,7 +1653,8 @@ export function registerPluginCommands( if (!result.ok) process.exit(1); return; } - if (!result.ok) exitWithError(result); + // A failed reload still carries the inventory: print the targeted + // entries (status and detail) before the error and the exit code. const reloaded = id === undefined ? (result.plugins ?? []) @@ -1646,6 +1662,7 @@ export function registerPluginCommands( for (const entry of reloaded) { printPlugin(entry); } + if (!result.ok) exitWithError(result); }), ); @@ -1847,7 +1864,7 @@ export function registerPluginCommands( plugin .command("remove ") .description( - "Remove an installed plugin (git:/npm: managed files are deleted; local path sources are left alone)", + "Remove an installed plugin and delete its settings, secrets, and schedules (git:/npm: managed files are deleted; local path sources stay on disk). To move a local plugin to another directory, install the new path instead", ) .option("--json", "Output JSON") .action( diff --git a/apps/cli/src/commands/provider.ts b/apps/cli/src/commands/provider.ts index 3c9a02e300..2cbc227167 100644 --- a/apps/cli/src/commands/provider.ts +++ b/apps/cli/src/commands/provider.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; import type { AvailableModel } from "@bb/domain"; -import type { ProviderHostRoutingArgs } from "@bb/sdk"; import type { SystemProviderInfo } from "@bb/server-contract"; import { action } from "../action.js"; import { createCliBbSdk } from "../client.js"; @@ -29,13 +28,6 @@ interface IncludeSelectedOnlyModelArgs { selectedModel?: string; } -async function resolveProviderRouting( - opts: ProviderListCommandOptions, - serverUrl: string, -): Promise { - return resolveMachineEnvironmentRouting(opts, serverUrl); -} - function addProviderRoutingOptions(command: Command): Command { return command .option("--machine ", "Machine whose providers should be used") @@ -62,7 +54,7 @@ export function registerProviderCommands( const serverUrl = getUrl(); const sdk = createCliBbSdk(serverUrl); const providers = await sdk.providers.list( - await resolveProviderRouting(opts, serverUrl), + await resolveMachineEnvironmentRouting(opts, serverUrl), ); if (outputJson(opts, providers)) return; if (providers.length === 0) { @@ -89,7 +81,7 @@ export function registerProviderCommands( const serverUrl = getUrl(); const sdk = createCliBbSdk(serverUrl); const executionOptions = await sdk.providers.models({ - ...(await resolveProviderRouting(opts, serverUrl)), + ...(await resolveMachineEnvironmentRouting(opts, serverUrl)), ...(providerId ? { providerId } : {}), }); const models = includeSelectedOnlyModel({ diff --git a/apps/cli/src/commands/settings.ts b/apps/cli/src/commands/settings.ts index 45ba952250..81ca779c24 100644 --- a/apps/cli/src/commands/settings.ts +++ b/apps/cli/src/commands/settings.ts @@ -153,30 +153,6 @@ export function registerSettingsCommands( }), ); - settings - .command("replay-onboarding") - .description("Show the first-run setup guide again on the next app load") - .option("--json", "Print machine-readable JSON output") - .action( - action(async (opts: JsonOptions) => { - const sdk = createCliBbSdk(getUrl()); - const config = await sdk.system.config(); - let experiments = config.experiments; - if (!config.experiments.newOnboarding) { - experiments = await sdk.system.updateExperiments({ - ...config.experiments, - newOnboarding: true, - }); - } - const generalSettings = await sdk.system.updateGeneralSettings({ - ...config.generalSettings, - onboardingCompletedAt: null, - }); - if (outputJson(opts, { experiments, generalSettings })) return; - console.log("New onboarding is enabled; onboarding will show again"); - }), - ); - settings .command("experiment ") .description("Set an experiment value") diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts index 5d8937c9bb..25bfeaa55d 100644 --- a/apps/cli/src/commands/thread/actions.ts +++ b/apps/cli/src/commands/thread/actions.ts @@ -24,6 +24,7 @@ import { parsePermissionMode, parseServiceTier, PERMISSION_MODE_HELP, + PLAN_HELP, buildPromptInputs, collectOption, } from "./helpers.js"; @@ -69,6 +70,7 @@ interface ThreadTellCommandOptions { reasoningLevel?: string; serviceTier?: string; mode?: string; + plan?: boolean; file?: string[]; image?: string[]; } @@ -97,6 +99,7 @@ interface PostThreadMessageArgs { reasoningLevel?: ReasoningLevel; serviceTier?: ServiceTier; senderThreadId?: string; + plan?: boolean; files?: readonly string[]; images?: readonly string[]; } @@ -423,6 +426,7 @@ export function registerActionsCommands( ) .option("--permission-mode ", PERMISSION_MODE_HELP) .option("--mode ", "Message mode: steer (default), queue, or auto") + .option("--plan", PLAN_HELP) .option( "--file ", "Pass a host-readable absolute or uploaded attachment file path (repeatable)", @@ -448,6 +452,7 @@ export function registerActionsCommands( reasoningLevel: parseReasoningLevel(opts.reasoningLevel), serviceTier: parseServiceTier(opts.serviceTier), senderThreadId: resolveSenderThreadId(id), + plan: opts.plan, files: opts.file, images: opts.image, }); @@ -530,6 +535,7 @@ async function postThreadMessage( threadId: args.threadId, input: buildPromptInputs({ message: args.message, + plan: args.plan, files: args.files, images: args.images, }), diff --git a/apps/cli/src/commands/thread/fork.ts b/apps/cli/src/commands/thread/fork.ts index 8e766ae0dd..82b60dfec0 100644 --- a/apps/cli/src/commands/thread/fork.ts +++ b/apps/cli/src/commands/thread/fork.ts @@ -71,7 +71,10 @@ export function registerForkCommand( .description("Fork a thread at its tip or a source event sequence") .option("--prompt ", "Optional first prompt; omit for an idle fork") .option("--title ", "Thread title") - .option("--source-seq-end <seq>", "Last included source event sequence") + .option( + "--source-seq-end <seq>", + "Fork after the source turn containing this event sequence", + ) .option("--workspace <mode>", "Workspace: isolated (default) or reuse") .option("--permission-mode <mode>", PERMISSION_MODE_HELP) .option("--visibility <visibility>", "Thread visibility: visible or hidden") diff --git a/apps/cli/src/commands/thread/helpers.ts b/apps/cli/src/commands/thread/helpers.ts index 89ab3190fd..cd0423514f 100644 --- a/apps/cli/src/commands/thread/helpers.ts +++ b/apps/cli/src/commands/thread/helpers.ts @@ -1,4 +1,5 @@ import { + createBuiltinPlanCommandTextInput, permissionModeInputSchema, type PermissionMode, type PromptInput, @@ -6,24 +7,22 @@ import { type ServiceTier, } from "@bb/domain"; import { - DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS as SDK_DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS, + DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS, DEFAULT_THREAD_WAIT_TIMEOUT_MS, - type ThreadWaitTarget, } from "@bb/sdk"; import { joinValues } from "../helpers.js"; export const THREAD_WAIT_EXIT_CODE_TIMEOUT = 2; export const THREAD_WAIT_EXIT_CODE_INVALID_REQUEST = 3; export const THREAD_WAIT_EXIT_CODE_UNREACHABLE = 4; -export const DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS = - SDK_DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS; export const DEFAULT_THREAD_WAIT_TIMEOUT_SECONDS = DEFAULT_THREAD_WAIT_TIMEOUT_MS / 1000; -export type { ThreadWaitTarget }; const SERVICE_TIERS: ServiceTier[] = ["fast", "default"]; export const PERMISSION_MODE_HELP = "Permission mode: accept-edits, auto, or full"; +export const PLAN_HELP = + "Send the message as the provider's /plan action so the agent proposes a plan for approval before executing"; export function collectOption(value: string, previous: string[]): string[] { return [...previous, value]; @@ -33,9 +32,13 @@ export function buildPromptInputs(args: { message: string; files?: readonly string[]; images?: readonly string[]; + /** Open the provider's plan action (`/plan`) instead of executing. */ + plan?: boolean; }): PromptInput[] { return [ - { type: "text", text: args.message, mentions: [] }, + args.plan + ? createBuiltinPlanCommandTextInput(args.message) + : { type: "text", text: args.message, mentions: [] }, ...(args.files ?? []).map( (path): PromptInput => ({ type: "localFile", path }), ), diff --git a/apps/cli/src/commands/thread/interactions.ts b/apps/cli/src/commands/thread/interactions.ts index 9676bfbdc8..aa558b2944 100644 --- a/apps/cli/src/commands/thread/interactions.ts +++ b/apps/cli/src/commands/thread/interactions.ts @@ -134,6 +134,9 @@ function formatInteractionKind(interaction: PendingInteraction): string { return "permission"; case "plan": return "plan"; + // Declarative base only: no producer emits this subject until WS5. + case "tool_use": + return "tool-use"; default: return assertNever(interaction.payload.subject); } @@ -251,6 +254,14 @@ function printApprovalInteraction( console.log(` ${line}`); } break; + case "tool_use": + // Declarative base only: no producer emits this subject until WS5. + for (const line of formatPendingInteractionSubjectDetailLines( + interaction, + )) { + console.log(` ${line}`); + } + break; default: assertNever(interaction.payload.subject); } diff --git a/apps/cli/src/commands/thread/pending-todos.ts b/apps/cli/src/commands/thread/pending-todos.ts index eec87dfbdf..31504f0b10 100644 --- a/apps/cli/src/commands/thread/pending-todos.ts +++ b/apps/cli/src/commands/thread/pending-todos.ts @@ -5,7 +5,7 @@ import type { } from "@bb/domain"; import type { BbSdk } from "@bb/sdk"; -export interface FetchThreadPendingTodosArgs { +interface FetchThreadPendingTodosArgs { sdk: Pick<BbSdk, "threads">; threadId: string; } @@ -45,7 +45,6 @@ const STATUS_RANK: Record<ThreadTimelinePendingTodoItemStatus, number> = { }; interface TodoCounts { - active: number; completed: number; total: number; } @@ -53,13 +52,11 @@ interface TodoCounts { function countTodos( items: readonly ThreadTimelinePendingTodoItem[], ): TodoCounts { - let active = 0; let completed = 0; for (const item of items) { if (item.status === "completed") completed += 1; - else active += 1; } - return { active, completed, total: items.length }; + return { completed, total: items.length }; } /** diff --git a/apps/cli/src/commands/thread/show.ts b/apps/cli/src/commands/thread/show.ts index 903b269721..8f05f7f09b 100644 --- a/apps/cli/src/commands/thread/show.ts +++ b/apps/cli/src/commands/thread/show.ts @@ -7,6 +7,7 @@ import { resolveEnvironmentMergeBaseBranch, type Environment, type Thread, + type ThreadEventRow, type ThreadGitDiffResponse, type ThreadPullRequest, type ThreadTimelinePendingTodos, @@ -48,8 +49,15 @@ interface ThreadLogCommandOptions { format?: string; limit?: string; afterSeq?: string; + all?: boolean; } +const THREAD_LOG_DEFAULT_EVENT_LIMIT = 100; +/** Page size for `--json --all`; bounds each response, not the total. */ +const THREAD_LOG_ALL_EVENTS_PAGE_SIZE = 1000; +/** Server-side `segmentLimit` maximum (THREAD_TIMELINE_SEGMENT_LIMIT_MAX). */ +const THREAD_LOG_TIMELINE_SEGMENT_LIMIT_MAX = 100; + interface ThreadOutputCommandOptions { json?: boolean; self?: boolean; @@ -440,44 +448,94 @@ export function registerShowCommand( ) .option( "--limit <count>", - "Maximum number of events to return; json format only (default 100)", + `Maximum entries to print: events for json (oldest first, default ${THREAD_LOG_DEFAULT_EVENT_LIMIT}); user-message turns for minimal/verbose (newest first, default 20, max ${THREAD_LOG_TIMELINE_SEGMENT_LIMIT_MAX})`, ) .option( "--after-seq <seq>", "Return events after this sequence number; json format only", ) + .option( + "--all", + "Print the whole thread by paging through every entry (cannot be combined with --limit)", + ) .action( action(async (id: string | undefined, opts: ThreadLogCommandOptions) => { const threadId = requireThreadIdOrSelf(id, opts); const sdk = createCliBbSdk(getUrl()); const format = resolveThreadTimelineTextFormat(opts); - if (format !== "json" && (opts.limit || opts.afterSeq)) { - throw new Error( - "--limit and --after-seq are only supported with --format json", - ); + if (opts.all && opts.limit !== undefined) { + throw new Error("--all cannot be combined with --limit"); + } + if (format !== "json" && opts.afterSeq !== undefined) { + throw new Error("--after-seq is only supported with --format json"); } if (format === "json") { - const events = await sdk.threads.events.list({ - threadId, - limit: String(opts.limit ?? 100), - ...(opts.afterSeq ? { afterSeq: opts.afterSeq } : {}), - }); - console.log(JSON.stringify(events, null, 2)); + const events = opts.all + ? await listAllThreadLogEvents(sdk, threadId, opts.afterSeq) + : await listThreadLogEventsPage(sdk, { + threadId, + limit: parseThreadLogLimit( + opts.limit, + THREAD_LOG_DEFAULT_EVENT_LIMIT, + ), + afterSeq: opts.afterSeq, + }); + console.log(JSON.stringify(events.rows, null, 2)); + if (events.hasMore) { + const lastSeq = events.rows[events.rows.length - 1]?.seq; + console.error( + `Showing the oldest ${events.rows.length} events${ + opts.afterSeq === undefined ? "" : ` after seq ${opts.afterSeq}` + }; more exist. Use --after-seq ${lastSeq} for the next page or --all for the whole thread.`, + ); + } return; } - const timeline: ThreadTimelineResponse = await sdk.threads.timeline({ + const segmentLimit = opts.all + ? THREAD_LOG_TIMELINE_SEGMENT_LIMIT_MAX + : parseThreadLogLimit(opts.limit, null); + if ( + segmentLimit !== null && + segmentLimit > THREAD_LOG_TIMELINE_SEGMENT_LIMIT_MAX + ) { + throw new Error( + `--limit must be at most ${THREAD_LOG_TIMELINE_SEGMENT_LIMIT_MAX} for minimal/verbose formats; use --all for the whole thread.`, + ); + } + const timelineQuery = { threadId, - ...(format === "verbose" ? { includeNestedRows: "true" } : {}), - }); + ...(format === "verbose" + ? { includeNestedRows: "true" as const } + : {}), + ...(segmentLimit === null + ? {} + : { segmentLimit: String(segmentLimit) }), + }; + const timeline: ThreadTimelineResponse = + await sdk.threads.timeline(timelineQuery); + let rows = timeline.rows; + let page = timeline.timelinePage; + while (opts.all && page.hasOlderRows && page.olderCursor !== null) { + const older: ThreadTimelineResponse = await sdk.threads.timeline({ + ...timelineQuery, + beforeAnchorSeq: String(page.olderCursor.anchorSeq), + beforeAnchorId: page.olderCursor.anchorId, + }); + rows = [...older.rows, ...rows]; + page = older.timelinePage; + } const color = process.stdout.isTTY === true && !process.env.NO_COLOR; - const text = formatThreadTimelineText(timeline.rows, { + const text = formatThreadTimelineText(rows, { verbose: format === "verbose", color, }); - console.log(text); + const notice = page.hasOlderRows + ? `(Showing the newest ${page.returnedSegmentCount} user-message turns; older history omitted. Use --limit <n> (max ${THREAD_LOG_TIMELINE_SEGMENT_LIMIT_MAX}) or --all to see more.)` + : null; + console.log(notice === null ? text : `${text}\n\n${notice}`); }), ); @@ -573,6 +631,63 @@ function printEnvironmentPullRequest( console.log(` Merge: ${pr.mergeability.state}`); } +function parseThreadLogLimit<TDefault extends number | null>( + value: string | undefined, + defaultLimit: TDefault, +): number | TDefault { + if (value === undefined) return defaultLimit; + if (!/^\d+$/u.test(value) || Number(value) < 1) { + throw new Error("--limit must be a positive integer."); + } + return Number(value); +} + +interface ThreadLogEventsPage { + rows: ThreadEventRow[]; + /** True when at least one more event follows the last row. */ + hasMore: boolean; +} + +/** + * `/events` lists ascending by sequence and applies LIMIT, so a capped page is + * the oldest events. Over-read by one row to know whether the page was cut + * instead of guessing from a full page. + */ +async function listThreadLogEventsPage( + sdk: BbSdk, + args: { threadId: string; limit: number; afterSeq: string | undefined }, +): Promise<ThreadLogEventsPage> { + const rows = await sdk.threads.events.list({ + threadId: args.threadId, + limit: String(args.limit + 1), + ...(args.afterSeq === undefined ? {} : { afterSeq: args.afterSeq }), + }); + const hasMore = rows.length > args.limit; + return { rows: hasMore ? rows.slice(0, args.limit) : rows, hasMore }; +} + +async function listAllThreadLogEvents( + sdk: BbSdk, + threadId: string, + afterSeq: string | undefined, +): Promise<ThreadLogEventsPage> { + const rows: ThreadEventRow[] = []; + let cursor = afterSeq; + for (;;) { + const page = await sdk.threads.events.list({ + threadId, + limit: String(THREAD_LOG_ALL_EVENTS_PAGE_SIZE), + ...(cursor === undefined ? {} : { afterSeq: cursor }), + }); + rows.push(...page); + const last = page[page.length - 1]; + if (last === undefined || page.length < THREAD_LOG_ALL_EVENTS_PAGE_SIZE) { + return { rows, hasMore: false }; + } + cursor = String(last.seq); + } +} + function resolveThreadTimelineTextFormat( opts: ThreadLogCommandOptions, ): ThreadTimelineTextFormat { diff --git a/apps/cli/src/commands/thread/spawn.ts b/apps/cli/src/commands/thread/spawn.ts index 55faca2b2b..47b68f98ca 100644 --- a/apps/cli/src/commands/thread/spawn.ts +++ b/apps/cli/src/commands/thread/spawn.ts @@ -26,6 +26,7 @@ import { buildPromptInputs, collectOption, PERMISSION_MODE_HELP, + PLAN_HELP, parseServiceTier, } from "./helpers.js"; @@ -43,6 +44,7 @@ interface ThreadSpawnCommandOptions { title?: string; serviceTier?: string; permissionMode?: string; + plan?: boolean; parentSelf?: boolean; machine?: string; host?: string; @@ -178,7 +180,7 @@ export function registerSpawnCommand( ) .option( "--base-branch <branch>", - "Base branch for new managed worktrees. Omit to let bb choose the project's default worktree base.", + "Base branch for new managed worktrees. Omit to let bb choose the project's default worktree base; naming the default branch fetches and prefers origin the same way.", ) .option( "--machine <id-or-name>", @@ -202,6 +204,7 @@ export function registerSpawnCommand( .option("--title <title>", "Thread title") .option("--service-tier <tier>", "Service tier: fast or default") .option("--permission-mode <mode>", PERMISSION_MODE_HELP) + .option("--plan", PLAN_HELP) .option( "--file <path>", "Pass a host-readable absolute or uploaded attachment file path (repeatable)", @@ -221,7 +224,10 @@ export function registerSpawnCommand( ) .option("--origin-kind <kind>", "Thread origin: fork") .option("--source-thread <id>", "Source thread for a fork") - .option("--source-seq-end <seq>", "Last source event sequence") + .option( + "--source-seq-end <seq>", + "Fork after the source turn containing this event sequence", + ) .action( action(async (opts: ThreadSpawnCommandOptions) => { const projectId = resolveExplicitIdFlag({ @@ -300,6 +306,7 @@ export function registerSpawnCommand( ...(opts.model ? { model: opts.model } : {}), input: buildPromptInputs({ message: opts.prompt, + plan: opts.plan, files: opts.file, images: opts.image, }), diff --git a/apps/cli/src/commands/thread/wait.ts b/apps/cli/src/commands/thread/wait.ts index da76cd989b..78b5773407 100644 --- a/apps/cli/src/commands/thread/wait.ts +++ b/apps/cli/src/commands/thread/wait.ts @@ -1,18 +1,21 @@ import { Command } from "commander"; import { threadStatusSchema, threadStatusValues } from "@bb/domain"; -import { ThreadWaitTimeoutError, ThreadWaitUnreachableError } from "@bb/sdk"; +import { + DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS, + type ThreadWaitTarget, + ThreadWaitTimeoutError, + ThreadWaitUnreachableError, +} from "@bb/sdk"; import { action, CliExitError } from "../../action.js"; import { createCliBbSdk } from "../../client.js"; import { outputJson, requireThreadId } from "../helpers.js"; import { - DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS, DEFAULT_THREAD_WAIT_TIMEOUT_SECONDS, parseThreadWaitPollIntervalMs, parseThreadWaitTimeoutSeconds, THREAD_WAIT_EXIT_CODE_INVALID_REQUEST, THREAD_WAIT_EXIT_CODE_TIMEOUT, THREAD_WAIT_EXIT_CODE_UNREACHABLE, - type ThreadWaitTarget, } from "./helpers.js"; interface ThreadWaitCommandOptions { diff --git a/apps/cli/src/commands/updates.ts b/apps/cli/src/commands/updates.ts index 3377bd78a4..4238d014dd 100644 --- a/apps/cli/src/commands/updates.ts +++ b/apps/cli/src/commands/updates.ts @@ -1,5 +1,9 @@ import { Command } from "commander"; import type { Host } from "@bb/domain"; +import { + UPDATE_STATE_PRESENTATION, + type UpdateState, +} from "@bb/domain/update-state"; import type { HostProviderCliStatusResponse } from "@bb/server-contract"; import { action } from "../action.js"; import { createCliBbSdk } from "../client.js"; @@ -7,10 +11,8 @@ import { renderBorderlessTable } from "../table.js"; import { outputJson } from "./helpers.js"; import { resolveMachineId } from "./machine.js"; -const MANAGED_PROVIDERS = ["codex", "claudeCode"] as const; - -type ProviderCliKey = (typeof MANAGED_PROVIDERS)[number]; -type ProviderCliStatus = HostProviderCliStatusResponse[ProviderCliKey]; +type ProviderCliKey = string; +type ProviderCliStatus = HostProviderCliStatusResponse[string]; type ProviderCliStatusResponse = HostProviderCliStatusResponse; interface UpdatesCommandOptions { @@ -30,15 +32,27 @@ interface MachineUpdatesEntry { statusError: string | null; } -function providerStateLabel(status: ProviderCliStatus): string { - if (!status.installed) return "not installed"; - if (status.versionUnsupported) return "update needed"; - if (status.needsUpdate) { +/** + * The same state ladder Settings → Updates draws, printed as words. + * + * Both surfaces read `UPDATE_STATE_PRESENTATION` so a CLI that reads "Update + * in terminal" reads the same way in the app. This used to + * be a second, hand-maintained list of phrases here, and the two had already + * drifted — the app said "Update needed" where the CLI said "update needed" + * for one case and "update manually" for another. + */ +function providerState(status: ProviderCliStatus): UpdateState { + if (!status.installed) return "not-installed"; + if (status.needsUpdate || status.versionUnsupported) { return status.installAction === null - ? "update manually" - : "update available"; + ? "update-manually" + : "update-available"; } - return "up to date"; + return "up-to-date"; +} + +function providerStateLabel(status: ProviderCliStatus): string { + return UPDATE_STATE_PRESENTATION[providerState(status)].label; } function providerVersionLabel(status: ProviderCliStatus): string { @@ -91,8 +105,7 @@ function actionableTargets( const targets: ProviderUpdateTarget[] = []; for (const entry of entries) { if (entry.providerStatus === null) continue; - for (const provider of MANAGED_PROVIDERS) { - const status = entry.providerStatus[provider]; + for (const [provider, status] of Object.entries(entry.providerStatus)) { if (isActionableProviderStatus(status)) { targets.push({ host: entry.host, provider, status }); } @@ -115,8 +128,7 @@ function printUpdatesTable(args: { rows.push([entry.host.name, "-", entry.statusError ?? "status failed"]); continue; } - for (const provider of MANAGED_PROVIDERS) { - const status = entry.providerStatus[provider]; + for (const status of Object.values(entry.providerStatus)) { rows.push([ `${entry.host.name} · ${status.displayName}`, providerVersionLabel(status), @@ -186,8 +198,8 @@ export function registerUpdatesCommands( const appState = version.isDevelopment ? "development mode" : version.updateAvailable - ? `update available (run: ${version.upgradeCommand})` - : "up to date"; + ? `${UPDATE_STATE_PRESENTATION["update-available"].label} (run: ${version.upgradeCommand})` + : UPDATE_STATE_PRESENTATION["up-to-date"].label; const appVersionLabel = version.latestVersion !== null && version.latestVersion !== version.currentVersion @@ -222,15 +234,12 @@ export function registerUpdatesCommands( const hasManualUpdates = entries.some( (entry) => entry.providerStatus !== null && - MANAGED_PROVIDERS.some((provider) => { - const status = entry.providerStatus?.[provider]; - return ( - status !== undefined && + Object.values(entry.providerStatus).some( + (status) => status.installed && status.needsUpdate && - status.installAction === null - ); - }), + status.installAction === null, + ), ); console.log( hasManualUpdates @@ -271,8 +280,7 @@ export function registerUpdatesCommands( hostName: target.host.name, provider: target.provider, success, - message: - errorEvent?.type === "error" ? errorEvent.message : null, + message: errorEvent?.type === "error" ? errorEvent.message : null, }); if (!opts.json) { console.log( diff --git a/apps/cli/src/context-env.ts b/apps/cli/src/context-env.ts index 860d1e4908..e41d577df1 100644 --- a/apps/cli/src/context-env.ts +++ b/apps/cli/src/context-env.ts @@ -1,4 +1,5 @@ import { loadCliConfig, type CliConfig } from "@bb/config/cli"; +import { toOptionalString } from "@bb/config/strings"; const VALID_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; @@ -6,11 +7,11 @@ export interface CliRuntimeContext { cliConfig: CliConfig; } -export interface CreateCliRuntimeContextArgs { +interface CreateCliRuntimeContextArgs { cliConfig?: CliConfig; } -export interface ResolveExplicitIdFlagArgs { +interface ResolveExplicitIdFlagArgs { flagName: string; value?: string; } @@ -32,26 +33,18 @@ function validateId(value: string, source: string): string { return value; } -function trimToUndefined(value?: string): string | undefined { - if (value === undefined) return undefined; - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; -} - -export function resolveServerUrl( - context: CliRuntimeContext = createCliRuntimeContext(), -): string { +export function resolveServerUrl(context: CliRuntimeContext): string { return context.cliConfig.BB_SERVER_URL; } export function resolveContextProjectId(): string | undefined { - const fromEnv = trimToUndefined(process.env.BB_PROJECT_ID); + const fromEnv = toOptionalString(process.env.BB_PROJECT_ID); if (fromEnv) return validateId(fromEnv, "BB_PROJECT_ID"); return undefined; } export function resolveContextThreadId(): string | undefined { - const fromEnv = trimToUndefined(process.env.BB_THREAD_ID); + const fromEnv = toOptionalString(process.env.BB_THREAD_ID); if (fromEnv) return validateId(fromEnv, "BB_THREAD_ID"); return undefined; } @@ -59,20 +52,11 @@ export function resolveContextThreadId(): string | undefined { export function resolveExplicitIdFlag( args: ResolveExplicitIdFlagArgs, ): string | undefined { - const fromFlag = trimToUndefined(args.value); + const fromFlag = toOptionalString(args.value); if (fromFlag) return validateId(fromFlag, args.flagName); return undefined; } -export function requireProjectId(flagValue?: string): string { - const projectId = resolveExplicitIdFlag({ - flagName: "--project flag", - value: flagValue, - }); - if (projectId) return projectId; - throw new Error("Missing project ID. Pass --project <id>."); -} - export function requireThreadId(positionalId?: string): string { const threadId = resolveExplicitIdFlag({ flagName: "<threadId> argument", @@ -88,7 +72,7 @@ export interface ResolvedId { source: "arg" | "env"; } -export interface ThreadSelfTargetOptions { +interface ThreadSelfTargetOptions { self?: boolean; } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 42816b0cc7..06f8d61ede 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -88,7 +88,7 @@ registerStatusCommand(program, getUrl, getContext); registerSettingsCommands(program, getUrl); registerProjectCommands(program, getUrl); registerProviderCommands(program, getUrl); -registerManagerCommands(program, getUrl); +registerManagerCommands(program); registerMachineCommands(program, getUrl); registerUpdatesCommands(program, getUrl); registerTerminalCommands(program, getUrl); diff --git a/apps/cli/src/plugin-cli-proxy.ts b/apps/cli/src/plugin-cli-proxy.ts index c528d50a87..ba58036097 100644 --- a/apps/cli/src/plugin-cli-proxy.ts +++ b/apps/cli/src/plugin-cli-proxy.ts @@ -56,7 +56,7 @@ const RETRYABLE_CODES = new Set([ * something very different from ECONNREFUSED (nothing listening). `attempts` * records how many probes were spent so the message can say so. */ -export type PluginCliContributionsResult = +type PluginCliContributionsResult = | { outcome: "ok"; contributions: PluginCliContributionEntry[] } | { outcome: "unreachable"; @@ -67,7 +67,7 @@ export type PluginCliContributionsResult = | { outcome: "invalid" }; /** What a failed probe tells us about the server, independent of wording. */ -export interface UnreachableDiagnosis { +interface UnreachableDiagnosis { blockedCode: "EPERM" | "EACCES" | undefined; timedOut: boolean; refused: boolean; @@ -81,9 +81,7 @@ export interface UnreachableDiagnosis { * AggregateError — and report every signal it carries. Kept separate from the * wording so the retry decision and the message cannot drift apart. */ -export function diagnoseUnreachableServer( - cause: unknown, -): UnreachableDiagnosis { +function diagnoseUnreachableServer(cause: unknown): UnreachableDiagnosis { let blockedCode: "EPERM" | "EACCES" | undefined; let timedOut = false; let retryableCode = false; @@ -201,7 +199,7 @@ export function describeUnreachableServer( }`; } -export interface FetchPluginCliContributionsOptions { +interface FetchPluginCliContributionsOptions { /** Injected so tests exercise the retry schedule without real delays. */ sleep?: (ms: number) => Promise<void>; } diff --git a/apps/connect/package.json b/apps/connect/package.json index 01d6aaafb6..3fc507ac6d 100644 --- a/apps/connect/package.json +++ b/apps/connect/package.json @@ -7,9 +7,7 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "test": "vitest run --config vitest.config.ts", - "typecheck": "tsc --noEmit", - "spike:origin": "tsx scripts/spike-origin.mts", - "spike:client": "tsx scripts/spike-client.mts" + "typecheck": "tsc --noEmit" }, "dependencies": { "@bb/connect-db": "workspace:*", @@ -21,14 +19,11 @@ "@cloudflare/workers-types": "^4.20260610.0", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.0.0", - "@types/ws": "^8.18.1", "better-sqlite3": "12.10.0", "esbuild": "^0.28.1", "miniflare": "^4.20260701.0", - "tsx": "^4.23.1", "typescript": "npm:@typescript/typescript6@^6.0.2", "typescript-7": "npm:typescript@^7.0.2", - "wrangler": "^4.100.0", - "ws": "^8.18.0" + "wrangler": "^4.100.0" } } diff --git a/apps/connect/scripts/spike-client.mts b/apps/connect/scripts/spike-client.mts deleted file mode 100644 index e5d9d7e662..0000000000 --- a/apps/connect/scripts/spike-client.mts +++ /dev/null @@ -1,254 +0,0 @@ -// bb connect spike tunnel client. -// -// Connects out to the TunnelDO and proxies relayed streams to a local origin -// (a bb server, or scripts/spike-origin.mts for protocol testing). The real -// client lands in apps/host-daemon in M3; this script exists to validate the -// wire protocol end to end. -// -// BB_CONNECT_TUNNEL_URL ws endpoint (default ws://127.0.0.1:8787/__tunnel) -// BB_CONNECT_SECRET spike shared secret (default local-dev-secret) -// BB_CONNECT_ORIGIN local origin to proxy to (default http://127.0.0.1:9999) - -import WebSocket from "ws"; -import { - HEARTBEAT_REQUEST, - HEARTBEAT_RESPONSE, - chunkBody, - decodeFrame, - encodeFrame, - type Frame, - type HeaderPair, - type OpenHttpFrame, - type OpenWsFrame, -} from "@bb/tunnel-contract"; - -const TUNNEL_URL = process.env.BB_CONNECT_TUNNEL_URL ?? "ws://127.0.0.1:8787/__tunnel"; -const SECRET = process.env.BB_CONNECT_SECRET ?? "local-dev-secret"; -const ORIGIN = (process.env.BB_CONNECT_ORIGIN ?? "http://127.0.0.1:9999").replace(/\/$/, ""); - -const HEARTBEAT_INTERVAL_MS = 20_000; -const HEARTBEAT_DEADLINE_MS = 60_000; - -const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "accept-encoding"]); - -function log(message: string): void { - console.log(`[spike-client ${new Date().toISOString()}] ${message}`); -} - -interface HttpStream { - meta: OpenHttpFrame; - chunks: Buffer[]; - abort: AbortController; -} - -interface WsStream { - socket: WebSocket; - /** ws-data frames that arrived before the origin socket opened. */ - buffered: Frame[]; - open: boolean; -} - -class TunnelSession { - private readonly httpStreams = new Map<number, HttpStream>(); - private readonly wsStreams = new Map<number, WsStream>(); - private lastHeartbeatAck = Date.now(); - - constructor(private readonly tunnel: WebSocket) {} - - start(): void { - const heartbeat = setInterval(() => { - if (Date.now() - this.lastHeartbeatAck > HEARTBEAT_DEADLINE_MS) { - log("heartbeat deadline missed; terminating socket to force reconnect"); - this.tunnel.terminate(); - return; - } - this.tunnel.send(HEARTBEAT_REQUEST); - }, HEARTBEAT_INTERVAL_MS); - - this.tunnel.on("message", (data: Buffer, isBinary: boolean) => { - if (!isBinary) { - if (data.toString() === HEARTBEAT_RESPONSE) this.lastHeartbeatAck = Date.now(); - return; - } - try { - this.onFrame(decodeFrame(data)); - } catch (error) { - log(`bad frame: ${String(error)}`); - } - }); - - this.tunnel.on("close", () => { - clearInterval(heartbeat); - for (const stream of this.httpStreams.values()) stream.abort.abort(); - for (const stream of this.wsStreams.values()) stream.socket.close(1001, "tunnel closed"); - this.httpStreams.clear(); - this.wsStreams.clear(); - }); - } - - private send(frame: Frame): void { - if (this.tunnel.readyState === WebSocket.OPEN) this.tunnel.send(encodeFrame(frame)); - } - - private onFrame(frame: Frame): void { - switch (frame.type) { - case "open-http": { - const stream: HttpStream = { meta: frame, chunks: [], abort: new AbortController() }; - this.httpStreams.set(frame.streamId, stream); - // Spike simplification: request bodies are buffered, not streamed. - if (!frame.hasBody) void this.executeHttp(frame.streamId, stream); - return; - } - case "body-chunk": - this.httpStreams.get(frame.streamId)?.chunks.push(Buffer.from(frame.data)); - return; - case "body-end": { - const stream = this.httpStreams.get(frame.streamId); - if (stream) void this.executeHttp(frame.streamId, stream); - return; - } - case "open-ws": - this.openOriginWebSocket(frame); - return; - case "ws-data": { - const stream = this.wsStreams.get(frame.streamId); - if (!stream) return; - if (!stream.open) { - stream.buffered.push(frame); - return; - } - stream.socket.send(frame.isBinary ? frame.data : Buffer.from(frame.data).toString()); - return; - } - case "close-stream": { - const http = this.httpStreams.get(frame.streamId); - if (http) { - http.abort.abort(); - this.httpStreams.delete(frame.streamId); - return; - } - const ws = this.wsStreams.get(frame.streamId); - if (ws) { - ws.socket.close(frame.code, frame.reason); - this.wsStreams.delete(frame.streamId); - } - return; - } - case "resp-head": - case "ws-open-ack": - return; // client-originated frames; never received - } - } - - private async executeHttp(streamId: number, stream: HttpStream): Promise<void> { - const { meta } = stream; - const headers: Record<string, string> = {}; - for (const [name, value] of meta.headers) { - if (!SKIP_REQUEST_HEADERS.has(name.toLowerCase())) headers[name] = value; - } - try { - const body = meta.hasBody ? Buffer.concat(stream.chunks) : undefined; - const response = await fetch(`${ORIGIN}${meta.path}`, { - method: meta.method, - headers, - body, - redirect: "manual", - signal: stream.abort.signal, - }); - const responseHeaders: HeaderPair[] = []; - response.headers.forEach((value, name) => { - if (name.toLowerCase() !== "content-encoding") responseHeaders.push([name, value]); - }); - this.send({ type: "resp-head", streamId, status: response.status, headers: responseHeaders }); - if (response.body) { - const reader = response.body.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - for (const chunk of chunkBody(streamId, value)) this.send(chunk); - } - } - this.send({ type: "body-end", streamId }); - log(`${meta.method} ${meta.path} → ${response.status}`); - } catch (error) { - if (!stream.abort.signal.aborted) { - log(`${meta.method} ${meta.path} failed: ${String(error)}`); - this.send({ type: "close-stream", streamId, code: 1011, reason: String(error) }); - } - } finally { - this.httpStreams.delete(streamId); - } - } - - private openOriginWebSocket(frame: OpenWsFrame): void { - const wsOrigin = ORIGIN.replace(/^http/, "ws"); - const headers: Record<string, string> = {}; - for (const [name, value] of frame.headers) { - if (!SKIP_REQUEST_HEADERS.has(name.toLowerCase())) headers[name] = value; - } - const socket = new WebSocket(`${wsOrigin}${frame.path}`, frame.protocols, { headers }); - const stream: WsStream = { socket, buffered: [], open: false }; - this.wsStreams.set(frame.streamId, stream); - - socket.on("open", () => { - stream.open = true; - this.send({ - type: "ws-open-ack", - streamId: frame.streamId, - protocol: socket.protocol || null, - }); - for (const buffered of stream.buffered) this.onFrame(buffered); - stream.buffered = []; - log(`ws open ${frame.path}`); - }); - socket.on("message", (data: Buffer, isBinary: boolean) => { - this.send({ - type: "ws-data", - streamId: frame.streamId, - isBinary, - data: isBinary ? new Uint8Array(data) : new Uint8Array(Buffer.from(data.toString())), - }); - }); - socket.on("close", (code: number, reason: Buffer) => { - if (this.wsStreams.delete(frame.streamId)) { - this.send({ - type: "close-stream", - streamId: frame.streamId, - code: code === 1000 || (code >= 3000 && code <= 4999) ? code : 1000, - reason: reason.toString(), - }); - } - }); - socket.on("error", (error: Error) => { - log(`ws ${frame.path} error: ${error.message}`); - }); - } -} - -let attempt = 0; - -function connect(): void { - log(`connecting to ${TUNNEL_URL} (origin ${ORIGIN})`); - const tunnel = new WebSocket(TUNNEL_URL, { - headers: { authorization: `Bearer ${SECRET}` }, - }); - const connectedAt = { value: 0 }; - - tunnel.on("open", () => { - connectedAt.value = Date.now(); - log("tunnel connected"); - new TunnelSession(tunnel).start(); - }); - tunnel.on("error", (error: Error) => { - log(`tunnel error: ${error.message}`); - }); - tunnel.on("close", (code: number, reason: Buffer) => { - const stableFor = connectedAt.value ? Date.now() - connectedAt.value : 0; - attempt = stableFor > 10_000 ? 0 : attempt + 1; - const delay = Math.min(1000 * 2 ** attempt, 30_000); - log(`tunnel closed (${code} ${reason.toString()}); reconnecting in ${delay}ms`); - setTimeout(connect, delay); - }); -} - -connect(); diff --git a/apps/connect/scripts/spike-origin.mts b/apps/connect/scripts/spike-origin.mts deleted file mode 100644 index e5d3430cbc..0000000000 --- a/apps/connect/scripts/spike-origin.mts +++ /dev/null @@ -1,90 +0,0 @@ -// Test origin for the bb connect M0 spike: deterministic HTTP + WS behaviors -// to validate the tunnel protocol without needing a bb server. -// Listens on 127.0.0.1:9999. - -import { createServer } from "node:http"; -import { createHash } from "node:crypto"; -import { WebSocketServer } from "ws"; - -const PORT = 9999; - -const server = createServer((req, res) => { - const url = new URL(req.url ?? "/", "http://localhost"); - - if (url.pathname === "/") { - res.writeHead(200, { "content-type": "text/plain" }); - res.end("hello from spike origin\n"); - return; - } - - if (url.pathname === "/echo-headers") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify(req.headers, null, 2)); - return; - } - - if (url.pathname === "/echo" && req.method === "POST") { - res.writeHead(200, { "content-type": req.headers["content-type"] ?? "application/octet-stream" }); - req.pipe(res); - return; - } - - if (url.pathname === "/big") { - // Deterministic payload; response includes its own sha256 in a header so - // the far side can verify integrity. - const mb = Math.min(Number(url.searchParams.get("mb") ?? "10"), 100); - const chunk = Buffer.alloc(1024 * 1024); - for (let i = 0; i < chunk.length; i++) chunk[i] = i % 251; - const hash = createHash("sha256"); - for (let i = 0; i < mb; i++) hash.update(chunk); - res.writeHead(200, { - "content-type": "application/octet-stream", - "content-length": String(mb * 1024 * 1024), - "x-spike-sha256": hash.digest("hex"), - }); - let sent = 0; - const push = (): void => { - while (sent < mb) { - sent++; - if (!res.write(chunk)) { - res.once("drain", push); - return; - } - } - res.end(); - }; - push(); - return; - } - - if (url.pathname === "/slow") { - res.writeHead(200, { "content-type": "text/plain" }); - let i = 0; - const timer = setInterval(() => { - res.write(`chunk ${i}\n`); - if (++i >= 5) { - clearInterval(timer); - res.end("done\n"); - } - }, 200); - return; - } - - res.writeHead(404, { "content-type": "text/plain" }); - res.end("not found\n"); -}); - -const wss = new WebSocketServer({ server, path: "/ws-echo" }); -wss.on("connection", (socket) => { - socket.on("message", (data: Buffer, isBinary: boolean) => { - if (!isBinary && data.toString() === "ping") { - socket.send("pong"); - return; - } - socket.send(data, { binary: isBinary }); - }); -}); - -server.listen(PORT, "127.0.0.1", () => { - console.log(`[spike-origin] listening on http://127.0.0.1:${PORT}`); -}); diff --git a/apps/connect/scripts/tunnel-client.mts b/apps/connect/scripts/tunnel-client.mts deleted file mode 100644 index bc855c1cc6..0000000000 --- a/apps/connect/scripts/tunnel-client.mts +++ /dev/null @@ -1,289 +0,0 @@ -// bb connect tunnel client (M3). Redeems a connect code for a durable -// credential, holds an outbound WebSocket to the per-handle gate, and proxies -// relayed HTTP/WS streams to a local origin (your bb server). -// -// This standalone script proves the full authenticated tunnel e2e on staging; -// the same logic moves into apps/host-daemon for productization. -// -// --code <CODE> one-time connect code from the dashboard (first pair) -// --server <url> https://<handle>.<domain> (from the dashboard) -// --app-url <url> https://<domain> (redemption endpoint; default derives from --server) -// --origin <url> local server to expose (default http://127.0.0.1:9999) -// --store <path> credential store (default ~/.bb/cloud.json) - -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname } from "node:path"; -import WebSocket from "ws"; -import { - HEARTBEAT_REQUEST, - HEARTBEAT_RESPONSE, - chunkBody, - decodeFrame, - encodeFrame, - type Frame, - type HeaderPair, - type OpenHttpFrame, - type OpenWsFrame, -} from "@bb/tunnel-contract"; - -function arg(name: string, fallback?: string): string | undefined { - const i = process.argv.indexOf(`--${name}`); - return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : fallback; -} - -const SERVER_URL = arg("server"); -if (!SERVER_URL) throw new Error("--server https://<handle>.<domain> is required"); -const APP_URL = arg("app-url") ?? new URL(SERVER_URL).origin.replace(/\/\/[^.]+\./, "//"); -const ORIGIN = (arg("origin") ?? "http://127.0.0.1:9999").replace(/\/$/, ""); -const STORE = arg("store") ?? `${homedir()}/.bb/cloud.json`; -const CODE = arg("code"); - -const TUNNEL_URL = SERVER_URL.replace(/^http/, "ws").replace(/\/$/, "") + "/__tunnel"; -const HEARTBEAT_INTERVAL_MS = 20_000; -const HEARTBEAT_DEADLINE_MS = 60_000; -const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "accept-encoding"]); - -function log(m: string): void { - console.log(`[tunnel-client ${new Date().toISOString()}] ${m}`); -} - -interface Stored { - [serverUrl: string]: { credential: string; handle: string }; -} - -function loadStore(): Stored { - try { - return JSON.parse(readFileSync(STORE, "utf8")) as Stored; - } catch { - return {}; - } -} - -function saveCredential(credential: string, handle: string): void { - const store = loadStore(); - store[SERVER_URL!] = { credential, handle }; - mkdirSync(dirname(STORE), { recursive: true }); - writeFileSync(STORE, JSON.stringify(store, null, 2), { mode: 0o600 }); -} - -async function redeem(code: string): Promise<{ credential: string; handle: string }> { - const res = await fetch(`${APP_URL}/api/connect/redeem`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ code }), - }); - if (!res.ok) { - throw new Error(`redeem failed: ${res.status} ${JSON.stringify(await res.json().catch(() => ({})))}`); - } - const data = (await res.json()) as { credential: string; handle: string }; - saveCredential(data.credential, data.handle); - log(`paired as ${data.handle}; credential stored in ${STORE}`); - return data; -} - -async function resolveCredential(): Promise<string> { - if (CODE) return (await redeem(CODE)).credential; - const stored = loadStore()[SERVER_URL!]; - if (stored) return stored.credential; - throw new Error("no stored credential for this server; pass --code to pair"); -} - -interface HttpStream { - meta: OpenHttpFrame; - chunks: Buffer[]; - abort: AbortController; -} -interface WsStream { - socket: WebSocket; - buffered: Frame[]; - open: boolean; -} - -class TunnelSession { - private readonly httpStreams = new Map<number, HttpStream>(); - private readonly wsStreams = new Map<number, WsStream>(); - private lastAck = Date.now(); - - constructor(private readonly tunnel: WebSocket) {} - - start(): void { - const hb = setInterval(() => { - if (Date.now() - this.lastAck > HEARTBEAT_DEADLINE_MS) { - log("heartbeat deadline missed; terminating to reconnect"); - this.tunnel.terminate(); - return; - } - this.tunnel.send(HEARTBEAT_REQUEST); - }, HEARTBEAT_INTERVAL_MS); - - this.tunnel.on("message", (data: Buffer, isBinary: boolean) => { - if (!isBinary) { - if (data.toString() === HEARTBEAT_RESPONSE) this.lastAck = Date.now(); - return; - } - try { - this.onFrame(decodeFrame(data)); - } catch (e) { - log(`bad frame: ${String(e)}`); - } - }); - this.tunnel.on("close", () => { - clearInterval(hb); - for (const s of this.httpStreams.values()) s.abort.abort(); - for (const s of this.wsStreams.values()) s.socket.close(1001, "tunnel closed"); - this.httpStreams.clear(); - this.wsStreams.clear(); - }); - } - - private send(frame: Frame): void { - if (this.tunnel.readyState === WebSocket.OPEN) this.tunnel.send(encodeFrame(frame)); - } - - private onFrame(frame: Frame): void { - switch (frame.type) { - case "open-http": { - const stream: HttpStream = { meta: frame, chunks: [], abort: new AbortController() }; - this.httpStreams.set(frame.streamId, stream); - if (!frame.hasBody) void this.executeHttp(frame.streamId, stream); - return; - } - case "body-chunk": - this.httpStreams.get(frame.streamId)?.chunks.push(Buffer.from(frame.data)); - return; - case "body-end": { - const s = this.httpStreams.get(frame.streamId); - if (s) void this.executeHttp(frame.streamId, s); - return; - } - case "open-ws": - this.openOriginWs(frame); - return; - case "ws-data": { - const s = this.wsStreams.get(frame.streamId); - if (!s) return; - if (!s.open) { - s.buffered.push(frame); - return; - } - s.socket.send(frame.isBinary ? frame.data : Buffer.from(frame.data).toString()); - return; - } - case "close-stream": { - const h = this.httpStreams.get(frame.streamId); - if (h) { - h.abort.abort(); - this.httpStreams.delete(frame.streamId); - return; - } - const w = this.wsStreams.get(frame.streamId); - if (w) { - w.socket.close(frame.code, frame.reason); - this.wsStreams.delete(frame.streamId); - } - return; - } - case "resp-head": - case "ws-open-ack": - return; - } - } - - private async executeHttp(streamId: number, stream: HttpStream): Promise<void> { - const { meta } = stream; - const headers: Record<string, string> = {}; - for (const [n, v] of meta.headers) if (!SKIP_REQUEST_HEADERS.has(n.toLowerCase())) headers[n] = v; - try { - const body = meta.hasBody ? Buffer.concat(stream.chunks) : undefined; - const res = await fetch(`${ORIGIN}${meta.path}`, { - method: meta.method, - headers, - body, - redirect: "manual", - signal: stream.abort.signal, - }); - const respHeaders: HeaderPair[] = []; - res.headers.forEach((v, n) => { - if (n.toLowerCase() !== "content-encoding") respHeaders.push([n, v]); - }); - this.send({ type: "resp-head", streamId, status: res.status, headers: respHeaders }); - if (res.body) { - const reader = res.body.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - for (const c of chunkBody(streamId, value)) this.send(c); - } - } - this.send({ type: "body-end", streamId }); - } catch (e) { - if (!stream.abort.signal.aborted) { - this.send({ type: "close-stream", streamId, code: 1011, reason: String(e) }); - } - } finally { - this.httpStreams.delete(streamId); - } - } - - private openOriginWs(frame: OpenWsFrame): void { - const wsOrigin = ORIGIN.replace(/^http/, "ws"); - const headers: Record<string, string> = {}; - for (const [n, v] of frame.headers) if (!SKIP_REQUEST_HEADERS.has(n.toLowerCase())) headers[n] = v; - const socket = new WebSocket(`${wsOrigin}${frame.path}`, frame.protocols, { headers }); - const stream: WsStream = { socket, buffered: [], open: false }; - this.wsStreams.set(frame.streamId, stream); - socket.on("open", () => { - stream.open = true; - this.send({ type: "ws-open-ack", streamId: frame.streamId, protocol: socket.protocol || null }); - for (const b of stream.buffered) this.onFrame(b); - stream.buffered = []; - }); - socket.on("message", (data: Buffer, isBinary: boolean) => { - this.send({ - type: "ws-data", - streamId: frame.streamId, - isBinary, - data: isBinary ? new Uint8Array(data) : new Uint8Array(Buffer.from(data.toString())), - }); - }); - socket.on("close", (code: number, reason: Buffer) => { - if (this.wsStreams.delete(frame.streamId)) { - this.send({ - type: "close-stream", - streamId: frame.streamId, - code: code === 1000 || (code >= 3000 && code <= 4999) ? code : 1000, - reason: reason.toString(), - }); - } - }); - socket.on("error", (e: Error) => log(`origin ws ${frame.path} error: ${e.message}`)); - } -} - -let attempt = 0; - -async function connect(credential: string): Promise<void> { - log(`connecting to ${TUNNEL_URL} (origin ${ORIGIN})`); - const tunnel = new WebSocket(TUNNEL_URL, { headers: { authorization: `Bearer ${credential}` } }); - let connectedAt = 0; - tunnel.on("open", () => { - connectedAt = Date.now(); - log("tunnel connected"); - new TunnelSession(tunnel).start(); - }); - tunnel.on("unexpected-response", (_req, res) => { - log(`tunnel rejected: HTTP ${res.statusCode}`); - }); - tunnel.on("error", (e: Error) => log(`tunnel error: ${e.message}`)); - tunnel.on("close", (code: number, reason: Buffer) => { - const stable = connectedAt ? Date.now() - connectedAt : 0; - attempt = stable > 10_000 ? 0 : attempt + 1; - const delay = Math.min(1000 * 2 ** attempt, 30_000); - log(`tunnel closed (${code} ${reason.toString()}); reconnecting in ${delay}ms`); - setTimeout(() => void connect(credential), delay); - }); -} - -const credential = await resolveCredential(); -void connect(credential); diff --git a/apps/connect/src/cloud-dev.ts b/apps/connect/src/cloud-dev.ts index 324407cdc4..86ad698471 100644 --- a/apps/connect/src/cloud-dev.ts +++ b/apps/connect/src/cloud-dev.ts @@ -1,11 +1,11 @@ export const CLOUD_DEV_HOST_HEADER = "x-bb-cloud-dev-host"; export const SECURE_SESSION_COOKIE = "__Secure-better-auth.session_token"; -export const LOCAL_SESSION_COOKIE = "better-auth.session_token"; +const LOCAL_SESSION_COOKIE = "better-auth.session_token"; export const SECURE_DESKTOP_SESSION_COOKIE = "__Secure-bb-connect.desktop_session"; -export const LOCAL_DESKTOP_SESSION_COOKIE = "bb-connect.desktop_session"; +const LOCAL_DESKTOP_SESSION_COOKIE = "bb-connect.desktop_session"; -export interface ConnectRuntime { +interface ConnectRuntime { accountAppUrl: string; baseDomain: string; localCloud: boolean; diff --git a/apps/connect/src/machine-label.ts b/apps/connect/src/machine-label.ts index da3c1a9bbb..a6799e13a7 100644 --- a/apps/connect/src/machine-label.ts +++ b/apps/connect/src/machine-label.ts @@ -65,7 +65,7 @@ function affectedRows(result: unknown): number { throw new Error("machine label update did not report affected rows"); } -export interface MachineLabelAssignmentHooks { +interface MachineLabelAssignmentHooks { /** Test/control barrier before the one atomic source+claim update. */ beforeAttach?: (candidate: string) => Promise<void>; } diff --git a/apps/connect/src/servers.ts b/apps/connect/src/servers.ts index 0e0b4c3b32..27502c61a7 100644 --- a/apps/connect/src/servers.ts +++ b/apps/connect/src/servers.ts @@ -15,7 +15,7 @@ import { resolveConnectRuntime } from "./cloud-dev.js"; import { MACHINE_CREDENTIAL_HEADER } from "./protocol-headers.js"; import type { Env } from "./tunnel-do.js"; -export const DESKTOP_SESSION_TTL_MS = 60 * 60 * 1000; +const DESKTOP_SESSION_TTL_MS = 60 * 60 * 1000; function bytesToBase64Url(bytes: Uint8Array): string { return btoa(String.fromCharCode(...bytes)) @@ -208,7 +208,7 @@ export async function resolveAccountUserId( return verifySessionCookie(cookie, secret, db); } -export interface AccountServerListing { +interface AccountServerListing { /** Routing label (`server.subdomain`) — `<handle>.getbb.app`. */ handle: string; /** Human-readable row name; falls back to handle when empty. */ diff --git a/apps/connect/src/session.ts b/apps/connect/src/session.ts index be7731a948..3011884f5d 100644 --- a/apps/connect/src/session.ts +++ b/apps/connect/src/session.ts @@ -37,7 +37,7 @@ function cacheGet<T>( return undefined; } -export interface ResolvedServer { +interface ResolvedServer { kind: "server"; /** * The account that owns this server (`server.userId`). Session and machine @@ -55,7 +55,7 @@ export interface ResolvedServer { }; } -export interface ResolvedMachine { +interface ResolvedMachine { kind: "machine"; routingKey: string; userId: string; @@ -69,7 +69,7 @@ export interface ResolvedMachine { }; } -export type ResolvedLabel = ResolvedServer | ResolvedMachine; +type ResolvedLabel = ResolvedServer | ResolvedMachine; /** * Preserve the existing server-label resolution path and precedence: first diff --git a/apps/connect/src/tunnel-do.ts b/apps/connect/src/tunnel-do.ts index 7eca8777af..895c329864 100644 --- a/apps/connect/src/tunnel-do.ts +++ b/apps/connect/src/tunnel-do.ts @@ -20,6 +20,12 @@ export interface Env { BETTER_AUTH_SECRET: string; ACCOUNT_APP_URL?: string; CLOUD_DEV?: string; + /** + * Android signing-cert SHA-256 fingerprints for `/.well-known/assetlinks.json` + * (comma-separated). Unset → the file serves an empty list (iOS universal + * links are unaffected). + */ + ASSETLINKS_SHA256_FINGERPRINTS?: string; } const TUNNEL_TAG = "tunnel"; diff --git a/apps/connect/src/worker.test.ts b/apps/connect/src/worker.test.ts index e6a9bdd078..ca536b6ce0 100644 --- a/apps/connect/src/worker.test.ts +++ b/apps/connect/src/worker.test.ts @@ -685,6 +685,118 @@ describe("machine gate auth", () => { ); }); +describe("bb mobile app-link association files", () => { + beforeEach(() => { + vi.clearAllMocks(); + // No cookie, no machine header: these must never reach the session gate. + mockParseCookie.mockReturnValue(null); + mockResolveLabel.mockResolvedValue(resolvedServer()); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + "/.well-known/apple-app-site-association", + "/.well-known/assetlinks.json", + ])( + "serves %s on a bare label without a session and without proxying", + async (path) => { + const { env, ctx, captured } = makeEnv(() => new Response("origin")); + const response = await worker.fetch( + visitorRequest("sawyer.getbb.app", path), + env as never, + ctx, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(captured).toHaveLength(0); + expect(mockResolveLabel).not.toHaveBeenCalled(); + expect(mockVerifySession).not.toHaveBeenCalled(); + }, + ); + + it("serves the AASA on bare labels that do not resolve yet (Apple fetches anonymously before a claim)", async () => { + mockResolveLabel.mockResolvedValue(null); + const { env, ctx, captured } = makeEnv(() => new Response("origin")); + const unknown = await worker.fetch( + visitorRequest( + "nobody-here.getbb.app", + "/.well-known/apple-app-site-association", + ), + env as never, + ctx, + ); + expect(unknown.status).toBe(200); + const body = (await unknown.json()) as { + applinks: { details: { appIDs: string[] }[] }; + }; + expect(body.applinks.details[0]?.appIDs).toEqual([ + "9QCU24SXK5.app.getbb.mobile", + ]); + expect(captured).toHaveLength(0); + }); + + it.each([ + "/.well-known/apple-app-site-association", + "/.well-known/assetlinks.json", + ])( + "does not claim %s on share hosts — they front arbitrary local apps, so the file falls through to the session gate", + async (path) => { + const { env, ctx, captured } = makeEnv(() => new Response("origin")); + const share = await worker.fetch( + visitorRequest("sawyer--8000.getbb.app", path), + env as never, + ctx, + ); + // Anonymous (Apple CDN / Android) fetch → 401 sign-in page, i.e. no + // association for `<label>--<port>` hosts; never proxied without a session. + expect(share.status).toBe(401); + expect(share.headers.get("content-type")).not.toBe("application/json"); + expect(captured).toHaveLength(0); + }, + ); + + it("reads Android fingerprints from the env and serves an empty list otherwise", async () => { + const { env, ctx } = makeEnv(() => new Response("origin")); + const empty = await worker.fetch( + visitorRequest("sawyer.getbb.app", "/.well-known/assetlinks.json"), + env as never, + ctx, + ); + const emptyBody = (await empty.json()) as { + target: { sha256_cert_fingerprints: string[] }; + }[]; + expect(emptyBody[0]?.target.sha256_cert_fingerprints).toEqual([]); + + const withEnv = await worker.fetch( + visitorRequest("sawyer.getbb.app", "/.well-known/assetlinks.json"), + { ...env, ASSETLINKS_SHA256_FINGERPRINTS: "aa:bb,cc:dd" } as never, + ctx, + ); + const withEnvBody = (await withEnv.json()) as { + target: { package_name: string; sha256_cert_fingerprints: string[] }; + }[]; + expect(withEnvBody[0]?.target.package_name).toBe("app.getbb.mobile"); + expect(withEnvBody[0]?.target.sha256_cert_fingerprints).toEqual([ + "AA:BB", + "CC:DD", + ]); + }); + + it("leaves other .well-known paths to the session gate", async () => { + const { env, ctx, captured } = makeEnv(() => new Response("origin")); + const response = await worker.fetch( + visitorRequest("sawyer.getbb.app", "/.well-known/openid-configuration"), + env as never, + ctx, + ); + expect(response.status).toBe(401); + expect(captured).toHaveLength(0); + }); +}); + describe("gate worker share hosts", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/connect/src/worker.ts b/apps/connect/src/worker.ts index 9935622663..194e12fc3b 100644 --- a/apps/connect/src/worker.ts +++ b/apps/connect/src/worker.ts @@ -1,5 +1,10 @@ import { drizzle } from "drizzle-orm/d1"; -import { RESERVED_HANDLES, parseVisitorHost, schema } from "@bb/connect-db"; +import { + RESERVED_HANDLES, + handleAppLinkAssociationRequest, + parseVisitorHost, + schema, +} from "@bb/connect-db"; import { TUNNEL_OFFLINE_HEADER, TunnelDO, type Env } from "./tunnel-do.js"; import { parseCookie, @@ -200,7 +205,7 @@ export function offlinePage( } /** A machine label has no bb app of its own; only nested port shares proxy. */ -export function machinePage( +function machinePage( label: string, accountHandle: string, runtime: ReturnType<typeof resolveConnectRuntime>, @@ -290,10 +295,24 @@ export default { if (url.pathname === "/api/connect/machine-label") { return handleAssignMachineLabel(request, env); } - const host = resolveConnectRequestHost(request.headers, runtime); const parsed = parseVisitorHost(host, env.BASE_DOMAIN); if (!parsed) return text("bb connect: unknown host\n", 404); + // bb mobile universal / app links: Apple's CDN and Android fetch the + // association files anonymously from `https://<label>.getbb.app`, so + // bare labels answer here — before label resolution (Apple may fetch + // before the label is claimed) and the session gate, never proxied to + // the tunnel, never redirected. Share hosts (`<label>--<port>`) front + // arbitrary local apps, not a bb server, so they must not claim + // `/threads/*` & co for the app: they fall through to the normal gate + // like any other path (401 for Apple's anonymous fetch → no association). + if (parsed.target === null) { + const appLinks = handleAppLinkAssociationRequest( + { method: request.method, url: url.toString() }, + env, + ); + if (appLinks) return appLinks; + } // The base label is now ANY server's subdomain (the account handle names the // primary bb; additional bbs claim their own labels), not just a profile // handle. `target` (a port) rides along for share hosts, nested per-bb. diff --git a/apps/connect/test/encoding-fixture.ts b/apps/connect/test/encoding-fixture.ts index 63008bd4ee..736161ad14 100644 --- a/apps/connect/test/encoding-fixture.ts +++ b/apps/connect/test/encoding-fixture.ts @@ -9,7 +9,7 @@ import { cacheKey, serveWithCache } from "../src/cache.js"; export { TunnelDO } from "../src/tunnel-do.js"; -export interface FixtureEnv { +interface FixtureEnv { TUNNEL_DO: DurableObjectNamespace; DB: D1Database; BASE_DOMAIN: string; diff --git a/apps/connect/wrangler.jsonc b/apps/connect/wrangler.jsonc index 6227e7338d..3b92eb1176 100644 --- a/apps/connect/wrangler.jsonc +++ b/apps/connect/wrangler.jsonc @@ -10,6 +10,9 @@ // Secret: wrangler secret put BETTER_AUTH_SECRET [--env staging] // (must equal bb-web's BETTER_AUTH_SECRET — the gate verifies the // session cookie's HMAC with it) +// Optional var: ASSETLINKS_SHA256_FINGERPRINTS — comma-separated Android +// signing-cert fingerprints for /.well-known/assetlinks.json (bb +// mobile app links). Unset until the Android app is signed. { "$schema": "node_modules/wrangler/config-schema.json", "name": "bb-connect", diff --git a/apps/demo-server/package.json b/apps/demo-server/package.json new file mode 100644 index 0000000000..022f2a3c14 --- /dev/null +++ b/apps/demo-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "@bb/demo-server", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "test": "vitest run --config vitest.config.ts" + }, + "dependencies": { + "@bb/domain": "workspace:*", + "@bb/server-contract": "workspace:*", + "zod": "^4.3.6" + }, + "devDependencies": { + "@bb/tsconfig": "workspace:*", + "@cloudflare/workers-types": "^4.20260610.0", + "@types/node": "^22.0.0", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitest": "^4.1.1", + "wrangler": "^4.100.0" + } +} diff --git a/apps/demo-server/src/demo-state.ts b/apps/demo-server/src/demo-state.ts new file mode 100644 index 0000000000..2721f2d734 --- /dev/null +++ b/apps/demo-server/src/demo-state.ts @@ -0,0 +1,49 @@ +// One Durable Object per client: its DemoWorld plus the WebSockets that +// receive the world's change notices. HTTP and the socket share the object, +// so a sent message and the notice that announces it cannot disagree. + +import { DemoWorld } from "./demo-world.js"; + +export class DemoStateDO { + private readonly world = new DemoWorld(); + private readonly sockets = new Set<WebSocket>(); + + constructor() { + this.world.onChanged((message) => { + const raw = JSON.stringify(message); + for (const socket of this.sockets) { + try { + socket.send(raw); + } catch { + this.sockets.delete(socket); + } + } + }); + } + + async fetch(request: Request): Promise<Response> { + const url = new URL(request.url); + if (url.pathname.replace(/\/+$/u, "") === "/ws") { + return this.handleWebSocket(request); + } + return this.world.handle(request); + } + + private handleWebSocket(request: Request): Response { + if (request.headers.get("upgrade") !== "websocket") { + return new Response("Expected a WebSocket upgrade", { status: 426 }); + } + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + server.accept(); + this.sockets.add(server); + server.addEventListener("close", () => this.sockets.delete(server)); + server.addEventListener("error", () => this.sockets.delete(server)); + server.addEventListener("message", (event) => { + if (typeof event.data !== "string") return; + const reply = this.world.socketReply(event.data); + if (reply !== null) server.send(reply); + }); + return new Response(null, { status: 101, webSocket: client }); + } +} diff --git a/apps/demo-server/src/demo-world.test.ts b/apps/demo-server/src/demo-world.test.ts new file mode 100644 index 0000000000..4a5cf91335 --- /dev/null +++ b/apps/demo-server/src/demo-world.test.ts @@ -0,0 +1,350 @@ +import { + hostSchema, + pongMessageSchema, + resolvedThreadExecutionOptionsSchema, + threadChangedMessageSchema, + type ThreadChangedMessage, +} from "@bb/domain"; +import { + sendQueuedMessageResponseSchema, + sidebarBootstrapResponseSchema, + systemConfigResponseSchema, + systemVersionResponseSchema, + threadChildSummaryResponseSchema, + threadListResponseSchema, + threadPendingInteractionsResponseSchema, + threadQueuedMessageListResponseSchema, + threadResponseSchema, + threadTabsResponseSchema, + threadTimelineResponseSchema, + type SendMessageRequest, + type ThreadTimelineResponse, +} from "@bb/server-contract"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + DemoWorld, + MAX_MESSAGE_CHARS, + MAX_TURNS_PER_THREAD, + REPLY_DELAY_MS, +} from "./demo-world.js"; +import { DEMO_THREADS } from "./fixtures/timelines.js"; + +const ORIGIN = "https://demo.example.test"; +const THREAD_ID = DEMO_THREADS[0].id; + +/** A world on a manual clock, so the scripted reply lands when the test says so. */ +function createWorld() { + let now = 1_800_000_000_000; + const timers: { fn: () => void; at: number }[] = []; + const notices: ThreadChangedMessage[] = []; + const world = new DemoWorld({ + now: () => now, + schedule: (fn, ms) => { + timers.push({ fn, at: now + ms }); + }, + }); + world.onChanged((message) => notices.push(message)); + const advance = (ms: number) => { + now += ms; + for (const timer of timers.splice(0)) { + if (timer.at <= now) timer.fn(); + else timers.push(timer); + } + }; + const get = (path: string) => world.handle(new Request(`${ORIGIN}${path}`)); + const send = (method: string, path: string, body: unknown) => + world.handle( + new Request(`${ORIGIN}${path}`, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + return { world, get, send, advance, notices, clock: () => now }; +} + +async function parsed<T>( + pending: Promise<Response>, + schema: z.ZodType<T>, +): Promise<T> { + const response = await pending; + expect(response.status).toBe(200); + return schema.parse(await response.json()); +} + +function sendBody(text: string): SendMessageRequest { + return { input: [{ type: "text", text, mentions: [] }], mode: "auto" }; +} + +function texts(timeline: ThreadTimelineResponse): string[] { + return timeline.rows.flatMap((row) => + row.kind === "conversation" + ? [`${row.role}: ${row.text.split("\n")[0]}`] + : [], + ); +} + +describe("demo world routes", () => { + it("answers every launch-path route with a body the contract accepts", async () => { + const { get } = createWorld(); + const config = await parsed( + get("/api/v1/system/config"), + systemConfigResponseSchema, + ); + expect(config.serverUrl).toBe(ORIGIN); + expect(await (await get("/health")).json()).toEqual({ ok: true }); + await parsed(get("/api/v1/system/version"), systemVersionResponseSchema); + await parsed(get("/api/v1/hosts"), z.array(hostSchema)); + const bootstrap = await parsed( + get("/api/v1/sidebar-bootstrap"), + sidebarBootstrapResponseSchema, + ); + expect(bootstrap.projects[0].threads.map((thread) => thread.title)).toEqual( + DEMO_THREADS.map((seed) => seed.title), + ); + await parsed(get("/api/v1/threads"), threadListResponseSchema); + await parsed(get(`/api/v1/threads/${THREAD_ID}`), threadResponseSchema); + await parsed( + get(`/api/v1/threads/${THREAD_ID}/interactions`), + threadPendingInteractionsResponseSchema, + ); + await parsed( + get(`/api/v1/threads/${THREAD_ID}/queued-messages`), + threadQueuedMessageListResponseSchema, + ); + await parsed( + get(`/api/v1/threads/${THREAD_ID}/tabs`), + threadTabsResponseSchema, + ); + await parsed( + get(`/api/v1/threads/${THREAD_ID}/default-execution-options`), + resolvedThreadExecutionOptionsSchema, + ); + await parsed( + get(`/api/v1/threads/${THREAD_ID}/child-summary`), + threadChildSummaryResponseSchema, + ); + }); + + it("serves every seeded timeline in contract shape, oldest first", async () => { + const { get, clock } = createWorld(); + for (const seed of DEMO_THREADS) { + const timeline = await parsed( + get(`/api/v1/threads/${seed.id}/timeline?limit=20`), + threadTimelineResponseSchema, + ); + expect(timeline.rows.length).toBeGreaterThan(1); + expect(timeline.maxSeq).toBe(timeline.rows.length); + const seqs = timeline.rows.map((row) => row.sourceSeqStart); + expect(seqs).toEqual([...seqs].sort((a, b) => a - b)); + for (const row of timeline.rows) + expect(row.startedAt).toBeLessThan(clock()); + } + }); + + it("answers unknown routes with 501, unknown threads with 404, bad bodies with 400", async () => { + const { get, send } = createWorld(); + const unknown = await get("/api/v1/projects/proj_demo00000001/branches"); + expect(unknown.status).toBe(501); + expect(await unknown.json()).toMatchObject({ + error: { code: "not_implemented" }, + }); + expect((await get("/api/v1/threads/thr_nope/timeline")).status).toBe(404); + expect( + (await send("POST", `/api/v1/threads/${THREAD_ID}/send`, { text: "x" })) + .status, + ).toBe(400); + }); + + it("echoes a tabs write with the next revision", async () => { + const { send } = createWorld(); + const tabs = await parsed( + send("PUT", `/api/v1/threads/${THREAD_ID}/tabs`, { + expectedRevision: 3, + tabs: [], + }), + threadTabsResponseSchema, + ); + expect(tabs.revision).toBe(4); + }); +}); + +describe("sending a message", () => { + it("shows the user row at once, then the scripted reply after the delay", async () => { + const { get, send, advance, notices, clock } = createWorld(); + const sentAt = clock(); + expect( + ( + await send( + "POST", + `/api/v1/threads/${THREAD_ID}/send`, + sendBody("Yes, go ahead."), + ) + ).status, + ).toBe(200); + + let thread = await parsed( + get(`/api/v1/threads/${THREAD_ID}`), + threadResponseSchema, + ); + expect(thread.status).toBe("active"); + expect(thread.runtime.displayStatus).toBe("active"); + let timeline = await parsed( + get(`/api/v1/threads/${THREAD_ID}/timeline`), + threadTimelineResponseSchema, + ); + expect(texts(timeline).at(-1)).toBe("user: Yes, go ahead."); + // The app stamps its optimistic row with the device clock; the server's + // row must not sort before it. + expect(timeline.rows.at(-1)?.startedAt).toBe(sentAt); + expect(notices.map((notice) => notice.changes)).toEqual([ + ["events-appended", "status-changed"], + ]); + for (const notice of notices) threadChangedMessageSchema.parse(notice); + + advance(REPLY_DELAY_MS - 1); + timeline = await parsed( + get(`/api/v1/threads/${THREAD_ID}/timeline`), + threadTimelineResponseSchema, + ); + expect(texts(timeline).at(-1)).toBe("user: Yes, go ahead."); + + advance(1); + expect(notices).toHaveLength(2); + thread = await parsed( + get(`/api/v1/threads/${THREAD_ID}`), + threadResponseSchema, + ); + expect(thread.status).toBe("idle"); + timeline = await parsed( + get(`/api/v1/threads/${THREAD_ID}/timeline`), + threadTimelineResponseSchema, + ); + expect(texts(timeline).slice(-2)).toEqual([ + "user: Yes, go ahead.", + "assistant: That change is straightforward.", + ]); + expect(timeline.rows.at(-2)?.kind).toBe("work"); + expect(timeline.maxSeq).toBe(timeline.rows.length); + // The sidebar reflects the new activity too. + const bootstrap = await parsed( + get("/api/v1/sidebar-bootstrap"), + sidebarBootstrapResponseSchema, + ); + expect(bootstrap.projects[0].threads[0].updatedAt).toBe(sentAt); + }); + + it("lands the pending reply when the thread is stopped", async () => { + const { get, send } = createWorld(); + await send("POST", `/api/v1/threads/${THREAD_ID}/send`, sendBody("Go.")); + expect( + (await send("POST", `/api/v1/threads/${THREAD_ID}/stop`, {})).status, + ).toBe(200); + const thread = await parsed( + get(`/api/v1/threads/${THREAD_ID}`), + threadResponseSchema, + ); + expect(thread.status).toBe("idle"); + const timeline = await parsed( + get(`/api/v1/threads/${THREAD_ID}/timeline`), + threadTimelineResponseSchema, + ); + expect(texts(timeline).at(-1)).toMatch(/^assistant:/u); + }); + + it("queues, then sends a queued message as a turn", async () => { + const { get, send, notices } = createWorld(); + const queued = await parsed( + send("POST", `/api/v1/threads/${THREAD_ID}/queued-messages`, { + input: [{ type: "text", text: "Later.", mentions: [] }], + }), + threadQueuedMessageListResponseSchema.element, + ); + expect( + await parsed( + get(`/api/v1/threads/${THREAD_ID}/queued-messages`), + threadQueuedMessageListResponseSchema, + ), + ).toEqual([queued]); + await parsed( + send( + "POST", + `/api/v1/threads/${THREAD_ID}/queued-messages/${queued.id}/send`, + { + mode: "steer", + }, + ), + sendQueuedMessageResponseSchema, + ); + expect( + await parsed( + get(`/api/v1/threads/${THREAD_ID}/queued-messages`), + threadQueuedMessageListResponseSchema, + ), + ).toEqual([]); + const timeline = await parsed( + get(`/api/v1/threads/${THREAD_ID}/timeline`), + threadTimelineResponseSchema, + ); + expect(texts(timeline).at(-1)).toBe("user: Later."); + expect(notices.map((notice) => notice.changes[0])).toEqual([ + "queue-changed", + "queue-changed", + "events-appended", + ]); + }); + + it("caps message length and the number of turns it keeps", async () => { + const { get, send, advance } = createWorld(); + await send( + "POST", + `/api/v1/threads/${THREAD_ID}/send`, + sendBody("x".repeat(MAX_MESSAGE_CHARS + 10)), + ); + let timeline = await parsed( + get(`/api/v1/threads/${THREAD_ID}/timeline`), + threadTimelineResponseSchema, + ); + const last = timeline.rows.at(-1); + expect(last?.kind === "conversation" && last.text.length).toBe( + MAX_MESSAGE_CHARS, + ); + + for (let index = 0; index < MAX_TURNS_PER_THREAD + 5; index += 1) { + await send( + "POST", + `/api/v1/threads/${THREAD_ID}/send`, + sendBody(`turn ${index}`), + ); + advance(REPLY_DELAY_MS); + } + timeline = await parsed( + get(`/api/v1/threads/${THREAD_ID}/timeline`), + threadTimelineResponseSchema, + ); + const seeded = DEMO_THREADS[0].rows(THREAD_ID, 0).length; + expect(timeline.rows.length).toBe(seeded + MAX_TURNS_PER_THREAD * 3); + expect(texts(timeline).at(-2)).toBe( + `user: turn ${MAX_TURNS_PER_THREAD + 4}`, + ); + }); +}); + +describe("socket frames", () => { + it("answers ping with pong and ignores subscriptions", () => { + const { world } = createWorld(); + const pong = world.socketReply(JSON.stringify({ type: "ping" })); + expect(pong).not.toBeNull(); + pongMessageSchema.parse(JSON.parse(pong ?? "")); + expect( + world.socketReply( + JSON.stringify({ + type: "subscribe", + target: { kind: "thread", threadId: THREAD_ID }, + }), + ), + ).toBeNull(); + expect(world.socketReply("not json")).toBeNull(); + }); +}); diff --git a/apps/demo-server/src/demo-world.ts b/apps/demo-server/src/demo-world.ts new file mode 100644 index 0000000000..9ea087f4e4 --- /dev/null +++ b/apps/demo-server/src/demo-world.ts @@ -0,0 +1,493 @@ +// The demo world: every route the mobile app touches, answered from typed +// fixtures plus the messages this client has sent. +// +// This module is pure so `demo-world.test.ts` can drive it with plain +// Request/Response objects and a fake clock. `demo-state.ts` wraps one world +// per client in a Durable Object and fans its change notices out to that +// client's WebSockets. + +import { + defaultAppSettings, + defaultAppTheme, + defaultExperiments, + defaultFeatureFlags, + type PromptInput, + type ThreadChangedMessage, + type ThreadQueuedMessage, +} from "@bb/domain"; +import { + createQueuedMessageRequestSchema, + pingMessageSchema, + sendMessageRequestSchema, + sendQueuedMessageRequestSchema, + systemConfigResponseSchema, + updateThreadTabsRequestSchema, + type SendQueuedMessageResponse, + type SystemConfigResponse, + type ThreadChildSummaryResponse, + type ThreadPendingInteractionsResponse, + type ThreadQueuedMessageListResponse, + type ThreadTabsResponse, + type ThreadTimelineResponse, +} from "@bb/server-contract"; +import { z } from "zod"; +import configFixture from "./fixtures/system-config.json" with { type: "json" }; +import { PROVIDERS, SYSTEM_EXECUTION_OPTIONS } from "./fixtures/providers.js"; +import { + commandRow, + conversationRow, + DEMO_REPLY, + DEMO_REPLY_COMMAND, + DEMO_THREADS, + type DemoThreadSeed, +} from "./fixtures/timelines.js"; +import { + EMPTY_TABS, + hosts, + PLUGIN_CONTRIBUTIONS, + queuedMessage, + seedStartedAt, + seedUpdatedAt, + sidebarBootstrap, + SYSTEM_VERSION, + THREAD_DEFAULT_EXECUTION_OPTIONS, + threadListEntry, + threadResponse, + type DemoThreadView, +} from "./fixtures/world.js"; + +// The keybinding tables are captured JSON (their defaults live in the server, +// which a worker cannot import), so they enter through the contract's own +// parser: a stale fixture fails module load (and the test) loudly instead of +// reaching the app as a shape it cannot render. Fields with a domain default +// take the default, so they cannot drift at all. +const SYSTEM_CONFIG = systemConfigResponseSchema.parse({ + ...configFixture, + generalSettings: defaultAppSettings, + // The mobile experiment gates the app's own settings surfaces. + experiments: { ...defaultExperiments, mobileApp: true }, + appearance: defaultAppTheme, + featureFlags: defaultFeatureFlags, + // Replaced per request with the origin the app reached us on. + serverUrl: "https://demo.invalid", +}); + +/** How long a thread shows "Working…" before the scripted reply lands. */ +export const REPLY_DELAY_MS = 1_800; + +/** Sent text beyond this is cut. The demo echoes input back to the sender only, but it should not echo megabytes. */ +export const MAX_MESSAGE_CHARS = 4_000; + +/** Sent turns kept per thread. Older ones fall off so a session cannot grow without bound. */ +export const MAX_TURNS_PER_THREAD = 20; + +const THREAD_PATH = + /^\/threads\/(thr_[a-z0-9]+)(?:\/([a-z-]+(?:\/[a-z0-9_-]+)*))?$/u; + +interface SentTurn { + text: string; + sentAt: number; +} + +interface ThreadState { + seed: DemoThreadSeed; + turns: SentTurn[]; +} + +interface DemoWorldOptions { + now?: () => number; + /** Runs `fn` after `ms`; swapped for a manual scheduler in tests. */ + schedule?: (fn: () => void, ms: number) => void; +} + +const JSON_HEADERS = { "content-type": "application/json" }; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS }); +} + +/** + * Everything outside the demo path. A reviewer who wanders into an + * unimplemented corner should read "not part of the demo", not see an empty + * screen that looks like a bug. + */ +function notImplemented(method: string, path: string): Response { + return json( + { + error: { + code: "not_implemented", + message: `The bb demo server does not implement ${method} ${path}. This server exists for App Store review and product demos; it serves fixed data and runs nothing.`, + }, + }, + 501, + ); +} + +function badRequest(error: z.ZodError): Response { + return json( + { error: { code: "bad_request", message: z.prettifyError(error) } }, + 400, + ); +} + +function notFound(threadId: string): Response { + return json( + { error: { code: "not_found", message: `No thread ${threadId}` } }, + 404, + ); +} + +async function readJson(request: Request): Promise<unknown> { + try { + return await request.json(); + } catch { + return null; + } +} + +/** The text of a prompt, as the composer sends it: text parts joined, attachments dropped. */ +function promptText(input: readonly PromptInput[]): string { + return input + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n") + .slice(0, MAX_MESSAGE_CHARS); +} + +export class DemoWorld { + private readonly now: () => number; + private readonly schedule: (fn: () => void, ms: number) => void; + private readonly threads = new Map<string, ThreadState>( + DEMO_THREADS.map((seed) => [seed.id, { seed, turns: [] }]), + ); + private readonly queued = new Map<string, ThreadQueuedMessage[]>(); + private readonly listeners = new Set< + (message: ThreadChangedMessage) => void + >(); + private nextQueuedId = 1; + + constructor(options: DemoWorldOptions = {}) { + this.now = options.now ?? (() => Date.now()); + this.schedule = options.schedule ?? ((fn, ms) => setTimeout(fn, ms)); + } + + /** Receives every realtime notice the world would push to its client. */ + onChanged(listener: (message: ThreadChangedMessage) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** Answers a WebSocket frame from the app: pong for ping, nothing otherwise. */ + socketReply(raw: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + // Subscriptions are ignored on purpose: this world belongs to one client, + // so it already sees everything. + return pingMessageSchema.safeParse(parsed).success + ? JSON.stringify({ type: "pong" }) + : null; + } + + async handle(request: Request): Promise<Response> { + const url = new URL(request.url); + const path = url.pathname.replace(/\/+$/u, "") || "/"; + if (path === "/health") return json({ ok: true }); + if (!path.startsWith("/api/v1/")) + return notImplemented(request.method, path); + + const api = path.slice("/api/v1".length); + const response = await this.handleApi(request, api, url.origin); + return response ?? notImplemented(request.method, path); + } + + private async handleApi( + request: Request, + api: string, + origin: string, + ): Promise<Response | null> { + const now = this.now(); + if (request.method === "GET") { + switch (api) { + case "/system/config": + return json(this.systemConfig(origin)); + case "/system/version": + return json(SYSTEM_VERSION); + case "/system/execution-options": + return json(SYSTEM_EXECUTION_OPTIONS); + case "/system/providers": + return json(PROVIDERS); + case "/hosts": + return json(hosts(now)); + case "/plugins/contributions": + return json(PLUGIN_CONTRIBUTIONS); + case "/sidebar-bootstrap": + return json(sidebarBootstrap(this.views(now), now)); + case "/threads": + return json( + this.views(now).map((view) => threadListEntry(view, now)), + ); + default: + break; + } + } + + const match = THREAD_PATH.exec(api); + if (!match) return null; + const [, threadId, sub = ""] = match; + const state = this.threads.get(threadId); + if (!state) return notFound(threadId); + + if (request.method === "GET") return this.handleThreadGet(state, sub, now); + if (request.method === "POST") + return this.handleThreadPost(request, state, sub, now); + if (request.method === "PUT" && sub === "tabs") + return this.handleUpdateTabs(request); + return null; + } + + private handleThreadGet( + state: ThreadState, + sub: string, + now: number, + ): Response | null { + const view = this.view(state, now); + switch (sub) { + case "": + return json(threadResponse(view, now)); + case "timeline": + return json(this.timeline(state, now)); + case "interactions": + return json([] satisfies ThreadPendingInteractionsResponse); + case "queued-messages": + return json( + this.queuedFor( + state.seed.id, + ) satisfies ThreadQueuedMessageListResponse, + ); + case "tabs": + return json(EMPTY_TABS); + case "default-execution-options": + return json(THREAD_DEFAULT_EXECUTION_OPTIONS); + case "child-summary": + return json({ + nonDeletedChildCount: 0, + } satisfies ThreadChildSummaryResponse); + default: + return null; + } + } + + private async handleThreadPost( + request: Request, + state: ThreadState, + sub: string, + now: number, + ): Promise<Response | null> { + const threadId = state.seed.id; + if (sub === "send") { + const body = sendMessageRequestSchema.safeParse(await readJson(request)); + if (!body.success) return badRequest(body.error); + this.appendTurn(state, promptText(body.data.input), now); + return json({ ok: true }); + } + if (sub === "read") { + return json(threadResponse(this.view(state, now), now)); + } + if (sub === "stop") { + // Stop lands the pending reply now rather than dropping it: the demo + // has nothing to interrupt, and an empty turn would look broken. + const pending = state.turns.find((turn) => this.replyPending(turn, now)); + if (pending) { + pending.sentAt = now - REPLY_DELAY_MS; + this.emit(threadId, ["events-appended", "status-changed"]); + } + return json({ ok: true }); + } + if (sub === "queued-messages") { + const body = createQueuedMessageRequestSchema.safeParse( + await readJson(request), + ); + if (!body.success) return badRequest(body.error); + const message = queuedMessage({ + id: `qm_demo${this.nextQueuedId++}`, + content: body.data.input, + now, + }); + this.queued.set(threadId, [...this.queuedFor(threadId), message]); + this.emit(threadId, ["queue-changed"]); + return json(message); + } + const sendQueued = /^queued-messages\/([a-z0-9_]+)\/send$/u.exec(sub); + if (sendQueued) { + const body = sendQueuedMessageRequestSchema.safeParse( + await readJson(request), + ); + if (!body.success) return badRequest(body.error); + const queuedMessageId = sendQueued[1]; + const message = this.queuedFor(threadId).find( + (entry) => entry.id === queuedMessageId, + ); + if (!message) { + return json({ message: "Queued message not found" }, 404); + } + this.queued.set( + threadId, + this.queuedFor(threadId).filter( + (entry) => entry.id !== queuedMessageId, + ), + ); + this.emit(threadId, ["queue-changed"]); + this.appendTurn(state, promptText(message.content), now); + return json({ + ok: true, + queuedMessage: message, + } satisfies SendQueuedMessageResponse); + } + return null; + } + + /** + * The thread screen persists its panel tabs on open. A 501 here is not + * harmless: the app retries and stacks "Couldn't sync tabs" toasts over the + * timeline, which is what a reviewer would see and report as broken. The + * demo accepts the write and echoes it back without storing it. + */ + private async handleUpdateTabs(request: Request): Promise<Response> { + const body = updateThreadTabsRequestSchema.safeParse( + await readJson(request), + ); + if (!body.success) return badRequest(body.error); + const response: ThreadTabsResponse = { + revision: body.data.expectedRevision + 1, + tabs: body.data.tabs, + }; + return json(response); + } + + /** + * The probe that adds a server compares `serverUrl` with the URL the user + * typed and flags a mismatch, so the config reports whatever origin the + * request came in on. + */ + private systemConfig(origin: string): SystemConfigResponse { + return { ...SYSTEM_CONFIG, serverUrl: origin }; + } + + private views(now: number): DemoThreadView[] { + return [...this.threads.values()].map((state) => this.view(state, now)); + } + + private view(state: ThreadState, now: number): DemoThreadView { + const last = state.turns.at(-1); + return { + seed: state.seed, + busy: last !== undefined && this.replyPending(last, now), + updatedAt: last?.sentAt ?? seedUpdatedAt(state.seed, now), + }; + } + + private replyPending(turn: SentTurn, now: number): boolean { + return now < turn.sentAt + REPLY_DELAY_MS; + } + + private queuedFor(threadId: string): ThreadQueuedMessage[] { + return this.queued.get(threadId) ?? []; + } + + private timeline(state: ThreadState, now: number): ThreadTimelineResponse { + const threadId = state.seed.id; + const rows = state.seed.rows(threadId, seedStartedAt(state.seed, now)); + let seq = rows.length; + for (const [index, turn] of state.turns.entries()) { + const turnId = `${threadId}-sent-${index + 1}`; + rows.push( + conversationRow({ + threadId, + turnId, + seq: ++seq, + at: turn.sentAt, + role: "user", + text: turn.text, + }), + ); + if (this.replyPending(turn, now)) continue; + rows.push( + commandRow({ + threadId, + turnId, + seq: ++seq, + at: turn.sentAt + 400, + ...DEMO_REPLY_COMMAND, + }), + conversationRow({ + threadId, + turnId, + seq: ++seq, + at: turn.sentAt + REPLY_DELAY_MS, + role: "assistant", + text: DEMO_REPLY, + }), + ); + } + return { + rows, + maxSeq: seq, + activePromptMode: null, + activeThinking: null, + activeWorkflows: [], + activeBackgroundCommands: [], + pendingTodos: null, + goal: null, + modelFallback: null, + contextWindowUsage: { + estimated: false, + modelContextWindow: 258_400, + usedTokens: 12_400, + }, + timelinePage: { + kind: "latest", + segmentLimit: 20, + returnedSegmentCount: 1, + hasOlderRows: false, + olderCursor: null, + }, + }; + } + + /** + * Records a sent message. The user row appears at once and the thread turns + * busy; the scripted reply lands after REPLY_DELAY_MS. Both moments are + * announced over the socket so the app refetches, exactly as it would for a + * real turn. The reply is a function of time, not of the timer firing, so a + * lost timer only delays the second notice. + */ + private appendTurn(state: ThreadState, text: string, now: number): void { + state.turns.push({ text, sentAt: now }); + if (state.turns.length > MAX_TURNS_PER_THREAD) { + state.turns.splice(0, state.turns.length - MAX_TURNS_PER_THREAD); + } + const threadId = state.seed.id; + this.emit(threadId, ["events-appended", "status-changed"]); + this.schedule(() => { + this.emit(threadId, ["events-appended", "status-changed"]); + }, REPLY_DELAY_MS); + } + + private emit( + threadId: string, + changes: ThreadChangedMessage["changes"], + ): void { + const message: ThreadChangedMessage = { + type: "changed", + entity: "thread", + id: threadId, + changes, + }; + for (const listener of this.listeners) listener(message); + } +} diff --git a/apps/demo-server/src/fixtures/ids.ts b/apps/demo-server/src/fixtures/ids.ts new file mode 100644 index 0000000000..2464145193 --- /dev/null +++ b/apps/demo-server/src/fixtures/ids.ts @@ -0,0 +1,6 @@ +// Stable identifiers for the demo data. Fixed values, never generated, so the +// same thread ids work in a deep link and in the review notes. + +export const DEMO_PROJECT_ID = "proj_demo00000001"; +export const DEMO_PERSONAL_PROJECT_ID = "proj_personal"; +export const DEMO_HOST_ID = "host_demo0000001"; diff --git a/apps/demo-server/src/fixtures/providers.ts b/apps/demo-server/src/fixtures/providers.ts new file mode 100644 index 0000000000..6247c16159 --- /dev/null +++ b/apps/demo-server/src/fixtures/providers.ts @@ -0,0 +1,132 @@ +// Providers and models the demo offers in its pickers. Typed against +// @bb/domain, the same types the real server fills at its boundary. +// +// The three built-in providers and a short Codex model list are enough for +// the composer and the settings screens to render; nothing here runs. + +import type { AvailableModel, ProviderInfo } from "@bb/domain"; +import type { SystemExecutionOptionsResponse } from "@bb/server-contract"; + +function provider( + info: Pick< + ProviderInfo, + "id" | "displayName" | "capabilities" | "composerActions" + >, +): ProviderInfo { + return { + ...info, + available: true, + logoUrl: `/api/v1/system/providers/${info.id}/logo`, + experimental_providerHealth: false, + experimental_providerUsage: false, + experimental_providerInstallation: false, + }; +} + +const SKILLS_ACTION = { kind: "skills", trigger: "/" } as const; +const PLAN_ACTION = { + kind: "plan", + command: { trigger: "/", name: "plan", trailingText: " " }, +} as const; + +export const PROVIDERS: readonly ProviderInfo[] = [ + provider({ + id: "codex", + displayName: "Codex", + capabilities: { + supportsThreadArchive: true, + supportsThreadRename: true, + supportsServiceTier: true, + supportsNativeUserQuestion: false, + permissionModes: ["accept-edits", "auto", "full"], + supportsFork: true, + supportsSessionRewind: true, + }, + composerActions: [ + SKILLS_ACTION, + PLAN_ACTION, + { + kind: "goal", + command: { trigger: "/", name: "goal", trailingText: " " }, + }, + ], + }), + provider({ + id: "claude-code", + displayName: "Claude Code", + capabilities: { + supportsThreadArchive: false, + supportsThreadRename: false, + supportsServiceTier: false, + supportsNativeUserQuestion: true, + permissionModes: ["accept-edits", "auto", "full"], + supportsFork: true, + supportsSessionRewind: true, + }, + composerActions: [SKILLS_ACTION, PLAN_ACTION], + }), + provider({ + id: "pi", + displayName: "Pi", + capabilities: { + supportsThreadArchive: false, + supportsThreadRename: false, + supportsServiceTier: false, + supportsNativeUserQuestion: false, + permissionModes: ["full"], + supportsFork: true, + supportsSessionRewind: true, + }, + composerActions: [SKILLS_ACTION], + }), +]; + +const REASONING_EFFORTS = [ + { + reasoningEffort: "low", + description: "Fast responses with lighter reasoning", + }, + { + reasoningEffort: "medium", + description: "Balances speed and reasoning depth for everyday tasks", + }, + { + reasoningEffort: "high", + description: "Greater reasoning depth for complex problems", + }, + { + reasoningEffort: "xhigh", + description: "Extra high reasoning depth for complex problems", + }, +] as const; + +export const DEFAULT_MODEL = "gpt-5.6-sol"; + +const MODELS: readonly AvailableModel[] = [ + { + id: DEFAULT_MODEL, + model: DEFAULT_MODEL, + displayName: "GPT-5.6-Sol", + description: "Latest frontier agentic coding model.", + supportedReasoningEfforts: [...REASONING_EFFORTS], + defaultReasoningEffort: "high", + isDefault: true, + }, + { + id: "gpt-5.6-luna", + model: "gpt-5.6-luna", + displayName: "GPT-5.6-Luna", + description: "Fast and affordable agentic coding model.", + supportedReasoningEfforts: [...REASONING_EFFORTS], + defaultReasoningEffort: "medium", + isDefault: false, + }, +]; + +export const SYSTEM_EXECUTION_OPTIONS: SystemExecutionOptionsResponse = { + providers: [...PROVIDERS], + permissionCeiling: "full", + models: [...MODELS], + selectedOnlyModels: [], + modelLoadError: null, +}; diff --git a/apps/demo-server/src/fixtures/system-config.json b/apps/demo-server/src/fixtures/system-config.json new file mode 100644 index 0000000000..b1c45e809c --- /dev/null +++ b/apps/demo-server/src/fixtures/system-config.json @@ -0,0 +1,3187 @@ +{ + "keybindings": [ + { + "command": "thread.new", + "desktopOnly": false, + "shortcut": { + "key": "o", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.new", + "desktopOnly": true, + "shortcut": { + "key": "n", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.search", + "desktopOnly": false, + "shortcut": { + "key": "k", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "settings.open", + "desktopOnly": false, + "shortcut": { + "key": ",", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "sidebar.toggle", + "desktopOnly": false, + "shortcut": { + "key": "\\", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.previous", + "desktopOnly": false, + "shortcut": { + "key": "[", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.previous", + "desktopOnly": true, + "shortcut": { + "key": "[", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.next", + "desktopOnly": false, + "shortcut": { + "key": "]", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.next", + "desktopOnly": true, + "shortcut": { + "key": "]", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.1", + "desktopOnly": true, + "shortcut": { + "key": "1", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.2", + "desktopOnly": true, + "shortcut": { + "key": "2", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.3", + "desktopOnly": true, + "shortcut": { + "key": "3", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.4", + "desktopOnly": true, + "shortcut": { + "key": "4", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.5", + "desktopOnly": true, + "shortcut": { + "key": "5", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.6", + "desktopOnly": true, + "shortcut": { + "key": "6", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.7", + "desktopOnly": true, + "shortcut": { + "key": "7", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.8", + "desktopOnly": true, + "shortcut": { + "key": "8", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.9", + "desktopOnly": false, + "shortcut": { + "key": "9", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.9", + "desktopOnly": false, + "shortcut": { + "key": "9", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.9", + "desktopOnly": true, + "shortcut": { + "key": "9", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.1", + "desktopOnly": true, + "shortcut": { + "key": "1", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.2", + "desktopOnly": true, + "shortcut": { + "key": "2", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.3", + "desktopOnly": true, + "shortcut": { + "key": "3", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.4", + "desktopOnly": true, + "shortcut": { + "key": "4", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.5", + "desktopOnly": true, + "shortcut": { + "key": "5", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.6", + "desktopOnly": true, + "shortcut": { + "key": "6", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.7", + "desktopOnly": true, + "shortcut": { + "key": "7", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.8", + "desktopOnly": true, + "shortcut": { + "key": "8", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.maximize.toggle", + "desktopOnly": false, + "shortcut": { + "key": "e", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.close", + "desktopOnly": false, + "shortcut": { + "key": "x", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "panel.newTab", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "panel.close", + "desktopOnly": false, + "shortcut": { + "key": "w", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "panel.toggle", + "desktopOnly": false, + "shortcut": { + "key": "j", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "file.quickOpen", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "diff.toggle", + "desktopOnly": false, + "shortcut": { + "key": "d", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen", "editableFocus", "terminalFocus", "browserFocus"] + } + }, + { + "command": "terminal.open", + "desktopOnly": false, + "shortcut": { + "key": "Enter", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "terminal.open", + "desktopOnly": true, + "shortcut": { + "key": "t", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "composer.focus", + "desktopOnly": false, + "shortcut": { + "key": "c", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.toggle", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.toggle", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleModel", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleModelBackward", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleProvider", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleProviderBackward", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleReasoning", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleReasoningBackward", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleModel", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleModelBackward", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleProvider", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleProviderBackward", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleReasoning", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleReasoningBackward", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "browser.focusLocation", + "desktopOnly": true, + "shortcut": { + "key": "l", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "browserFocus"], + "none": ["modalOpen"] + } + }, + { + "command": "browser.reload", + "desktopOnly": true, + "shortcut": { + "key": "r", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "browserFocus"], + "none": ["modalOpen"] + } + }, + { + "command": "browser.find", + "desktopOnly": true, + "shortcut": { + "key": "f", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "browserFocus"], + "none": ["modalOpen"] + } + }, + { + "command": "workspace.openPreferred", + "desktopOnly": false, + "shortcut": { + "key": "o", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "question.select.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.9", + "desktopOnly": false, + "shortcut": { + "key": "9", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "window.new", + "desktopOnly": true, + "shortcut": { + "key": "n", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + } + ], + "defaultKeybindings": [ + { + "command": "thread.new", + "desktopOnly": false, + "shortcut": { + "key": "o", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.new", + "desktopOnly": true, + "shortcut": { + "key": "n", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.search", + "desktopOnly": false, + "shortcut": { + "key": "k", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.rename", + "desktopOnly": false, + "shortcut": null, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.archive", + "desktopOnly": false, + "shortcut": null, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "settings.open", + "desktopOnly": false, + "shortcut": { + "key": ",", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "sidebar.toggle", + "desktopOnly": false, + "shortcut": { + "key": "\\", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.previous", + "desktopOnly": false, + "shortcut": { + "key": "[", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.previous", + "desktopOnly": true, + "shortcut": { + "key": "[", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.next", + "desktopOnly": false, + "shortcut": { + "key": "]", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.next", + "desktopOnly": true, + "shortcut": { + "key": "]", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.1", + "desktopOnly": true, + "shortcut": { + "key": "1", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.2", + "desktopOnly": true, + "shortcut": { + "key": "2", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.3", + "desktopOnly": true, + "shortcut": { + "key": "3", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.4", + "desktopOnly": true, + "shortcut": { + "key": "4", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.5", + "desktopOnly": true, + "shortcut": { + "key": "5", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.6", + "desktopOnly": true, + "shortcut": { + "key": "6", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.7", + "desktopOnly": true, + "shortcut": { + "key": "7", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.8", + "desktopOnly": true, + "shortcut": { + "key": "8", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.9", + "desktopOnly": false, + "shortcut": { + "key": "9", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "thread.jump.9", + "desktopOnly": false, + "shortcut": { + "key": "9", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "thread.jump.9", + "desktopOnly": true, + "shortcut": { + "key": "9", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.previous", + "desktopOnly": false, + "shortcut": null, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.next", + "desktopOnly": false, + "shortcut": null, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.1", + "desktopOnly": true, + "shortcut": { + "key": "1", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.2", + "desktopOnly": true, + "shortcut": { + "key": "2", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.3", + "desktopOnly": true, + "shortcut": { + "key": "3", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.4", + "desktopOnly": true, + "shortcut": { + "key": "4", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.5", + "desktopOnly": true, + "shortcut": { + "key": "5", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.6", + "desktopOnly": true, + "shortcut": { + "key": "6", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.7", + "desktopOnly": true, + "shortcut": { + "key": "7", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": false, + "meta": false, + "control": true, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface", "macPlatform"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.focus.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive", "webSurface"], + "none": ["modalOpen", "macPlatform"] + } + }, + { + "command": "pane.focus.8", + "desktopOnly": true, + "shortcut": { + "key": "8", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.maximize.toggle", + "desktopOnly": false, + "shortcut": { + "key": "e", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "pane.close", + "desktopOnly": false, + "shortcut": { + "key": "x", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "splitActive"], + "none": ["modalOpen"] + } + }, + { + "command": "panel.newTab", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "panel.close", + "desktopOnly": false, + "shortcut": { + "key": "w", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "panel.toggle", + "desktopOnly": false, + "shortcut": { + "key": "j", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "file.quickOpen", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "diff.toggle", + "desktopOnly": false, + "shortcut": { + "key": "d", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen", "editableFocus", "terminalFocus", "browserFocus"] + } + }, + { + "command": "terminal.open", + "desktopOnly": false, + "shortcut": { + "key": "Enter", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "terminal.open", + "desktopOnly": true, + "shortcut": { + "key": "t", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "composer.focus", + "desktopOnly": false, + "shortcut": { + "key": "c", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.toggle", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.toggle", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleModel", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleModelBackward", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleProvider", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleProviderBackward", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleReasoning", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleReasoningBackward", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "promptAvailable"], + "none": ["modalOpen", "terminalFocus", "browserFocus"] + } + }, + { + "command": "modelPicker.cycleModel", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleModelBackward", + "desktopOnly": false, + "shortcut": { + "key": "m", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleProvider", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleProviderBackward", + "desktopOnly": false, + "shortcut": { + "key": "p", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleReasoning", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": false + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "modelPicker.cycleReasoningBackward", + "desktopOnly": false, + "shortcut": { + "key": "t", + "mod": false, + "meta": false, + "control": false, + "alt": true, + "shift": true + }, + "when": { + "all": ["mainSurface", "modelPickerOpen"], + "none": [] + } + }, + { + "command": "browser.focusLocation", + "desktopOnly": true, + "shortcut": { + "key": "l", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "browserFocus"], + "none": ["modalOpen"] + } + }, + { + "command": "browser.reload", + "desktopOnly": true, + "shortcut": { + "key": "r", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "browserFocus"], + "none": ["modalOpen"] + } + }, + { + "command": "browser.find", + "desktopOnly": true, + "shortcut": { + "key": "f", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "browserFocus"], + "none": ["modalOpen"] + } + }, + { + "command": "workspace.openPreferred", + "desktopOnly": false, + "shortcut": { + "key": "o", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + }, + { + "command": "question.select.1", + "desktopOnly": false, + "shortcut": { + "key": "1", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.2", + "desktopOnly": false, + "shortcut": { + "key": "2", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.3", + "desktopOnly": false, + "shortcut": { + "key": "3", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.4", + "desktopOnly": false, + "shortcut": { + "key": "4", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.5", + "desktopOnly": false, + "shortcut": { + "key": "5", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.6", + "desktopOnly": false, + "shortcut": { + "key": "6", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.7", + "desktopOnly": false, + "shortcut": { + "key": "7", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.8", + "desktopOnly": false, + "shortcut": { + "key": "8", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "question.select.9", + "desktopOnly": false, + "shortcut": { + "key": "9", + "mod": false, + "meta": false, + "control": false, + "alt": false, + "shift": false + }, + "when": { + "all": ["mainSurface", "questionOpen"], + "none": ["modalOpen", "editableFocus"] + } + }, + { + "command": "window.new", + "desktopOnly": true, + "shortcut": { + "key": "n", + "mod": true, + "meta": false, + "control": false, + "alt": false, + "shift": true + }, + "when": { + "all": ["mainSurface"], + "none": ["modalOpen"] + } + } + ], + "keybindingOverrides": [], + "customThemes": [], + "pluginThemes": [], + "hostDaemonPort": null, + "localHelperPorts": [], + "primaryHostId": "host_demo0000001", + "primaryHostPlatform": "linux", + "voiceTranscriptionEnabled": true, + "dataDir": "/home/demo/.bb" +} diff --git a/apps/demo-server/src/fixtures/timelines.ts b/apps/demo-server/src/fixtures/timelines.ts new file mode 100644 index 0000000000..8ed02fca6a --- /dev/null +++ b/apps/demo-server/src/fixtures/timelines.ts @@ -0,0 +1,263 @@ +// Synthetic timelines for the demo threads. +// +// Every row is typed against @bb/server-contract, so a contract change that +// would crash the thread screen fails `typecheck` here instead of reaching a +// reviewer. `demo-world.test.ts` additionally parses the served responses +// with the contract's zod schemas. +// +// The content is invented. Nothing here comes from a real thread, because +// this server is public and a real capture would publish whatever the +// captured machine was working on. + +import type { + TimelineCommandWorkRow, + TimelineConversationRow, + TimelineRow, +} from "@bb/server-contract"; + +export interface DemoThreadSeed { + id: string; + title: string; + /** How long before "now" the thread was last touched. */ + minutesAgo: number; + /** The seeded conversation, oldest first. */ + rows: (threadId: string, startedAt: number) => TimelineRow[]; +} + +const CWD = "/home/demo/demo-app"; + +function baseRow(threadId: string, turnId: string, seq: number, at: number) { + return { + threadId, + turnId, + sourceSeqStart: seq, + sourceSeqEnd: seq, + startedAt: at, + createdAt: at, + }; +} + +/** + * A conversation row. The two roles are not symmetric: a user row carries + * `mentions`, an attachments object, and a `turnRequest`; an assistant row + * carries `attachments: null` and `turnRequest: null`. The contract type + * enforces the difference. + */ +export function conversationRow(args: { + threadId: string; + turnId: string; + seq: number; + at: number; + role: "user" | "assistant"; + text: string; +}): TimelineConversationRow { + const { threadId, turnId, seq, at, role, text } = args; + const common = { + ...baseRow(threadId, turnId, seq, at), + id: `${threadId}:conversation:${seq}`, + kind: "conversation" as const, + text, + }; + if (role === "assistant") { + return { ...common, role, attachments: null, turnRequest: null }; + } + return { + ...common, + role, + mentions: [], + attachments: { + webImages: 0, + localImages: 0, + localFiles: 0, + imageUrls: [], + localImagePaths: [], + localFilePaths: [], + }, + initiator: "user", + senderThreadId: null, + systemMessageKind: "unlabeled", + systemMessageSubject: null, + turnRequest: { isGrouped: false, kind: "message", status: "accepted" }, + }; +} + +export function commandRow(args: { + threadId: string; + turnId: string; + seq: number; + at: number; + command: string; + output: string; +}): TimelineCommandWorkRow { + const { threadId, turnId, seq, at, command, output } = args; + return { + ...baseRow(threadId, turnId, seq, at), + id: `${threadId}:command:${seq}`, + kind: "work", + workKind: "command", + status: "completed", + callId: `${threadId}-call-${seq}`, + command, + cwd: CWD, + source: null, + output, + exitCode: 0, + completedAt: at + 1_200, + approvalStatus: null, + activityIntents: [], + }; +} + +const ASSISTANT_INTRO = [ + "I looked at how the theme is applied today.", + "", + "The palette is set once at startup from `settings.theme`, so a toggle needs", + "two things: a stored preference, and a listener that re-applies the palette", + "without a reload.", + "", + "## Plan", + "", + "1. Persist the choice next to the other user preferences.", + "2. Re-apply the palette when the value changes.", + "3. Follow the system setting when the user has not chosen.", + "", + "```ts", + "export function useTheme() {", + ' const [mode, setMode] = usePreference("theme", "system");', + " useEffect(() => applyPalette(resolve(mode)), [mode]);", + " return { mode, setMode };", + "}", + "```", + "", + "Want me to make the change?", +].join("\n"); + +export const DEMO_THREADS: readonly DemoThreadSeed[] = [ + { + id: "thr_demo00000001", + title: "Add a dark mode toggle", + minutesAgo: 12, + rows: (threadId, start) => { + const turnId = `${threadId}-turn-1`; + return [ + conversationRow({ + threadId, + turnId, + seq: 1, + at: start, + role: "user", + text: "Add a dark mode toggle to the settings screen.", + }), + commandRow({ + threadId, + turnId, + seq: 2, + at: start + 2_000, + command: "rg -n 'theme' src --type ts", + output: [ + 'src/settings/appearance.ts:14:export const THEME_KEY = "theme";', + "src/settings/appearance.ts:22: applyPalette(resolveTheme(stored));", + "src/app/boot.ts:41: applyPalette(readTheme());", + ].join("\n"), + }), + conversationRow({ + threadId, + turnId, + seq: 3, + at: start + 6_000, + role: "assistant", + text: ASSISTANT_INTRO, + }), + ]; + }, + }, + { + id: "thr_demo00000002", + title: "Fix the flaky checkout test", + minutesAgo: 90, + rows: (threadId, start) => { + const turnId = `${threadId}-turn-1`; + return [ + conversationRow({ + threadId, + turnId, + seq: 1, + at: start, + role: "user", + text: "The checkout test fails about one run in five. Find out why.", + }), + commandRow({ + threadId, + turnId, + seq: 2, + at: start + 3_000, + command: "pnpm test checkout --repeat 20", + output: [ + "✓ checkout > applies a discount code (18 runs)", + "✗ checkout > applies a discount code (2 runs)", + " expected 1 request, received 2", + ].join("\n"), + }), + conversationRow({ + threadId, + turnId, + seq: 3, + at: start + 9_000, + role: "assistant", + text: [ + "The test does not wait for the first request to settle, so a retry", + "sometimes lands inside the assertion window.", + "", + "The fix is to await the pending request instead of a fixed delay.", + ].join("\n"), + }), + ]; + }, + }, + { + id: "thr_demo00000003", + title: "Speed up the search index", + minutesAgo: 240, + rows: (threadId, start) => { + const turnId = `${threadId}-turn-1`; + return [ + conversationRow({ + threadId, + turnId, + seq: 1, + at: start, + role: "user", + text: "Search takes about two seconds on the large fixture. Where does the time go?", + }), + conversationRow({ + threadId, + turnId, + seq: 2, + at: start + 5_000, + role: "assistant", + text: [ + "Almost all of it is in `buildIndex`, which re-reads every document on", + "each query. Caching the index and invalidating it on write brings a", + "warm query under 50ms.", + ].join("\n"), + }), + ]; + }, + }, +]; + +/** The scripted reply every sent message produces, so the app shows a real turn. */ +export const DEMO_REPLY = [ + "That change is straightforward.", + "", + "I would put the toggle next to the other appearance settings and store the", + "choice with the existing preferences, so it survives a restart.", + "", + "This is the bb demo server, so I am replaying a scripted answer rather than", + "running a real agent.", +].join("\n"); + +export const DEMO_REPLY_COMMAND = { + command: "rg -n 'appearance' src --type ts", + output: 'src/settings/appearance.ts:14:export const THEME_KEY = "theme";', +}; diff --git a/apps/demo-server/src/fixtures/world.ts b/apps/demo-server/src/fixtures/world.ts new file mode 100644 index 0000000000..7942b0b3d3 --- /dev/null +++ b/apps/demo-server/src/fixtures/world.ts @@ -0,0 +1,208 @@ +// The static part of the demo world: project, host, and the small +// per-thread responses. Typed against @bb/server-contract so a contract +// change fails `typecheck` instead of reaching a reviewer as a crash. +// +// Times are relative to the request clock. A frozen timestamp would read as +// "12 minutes ago" on the day it was written and "3 weeks ago" when the +// reviewer opens the app, and it would also sort the app's own optimistic +// rows (stamped with the device clock) above rows the server appends. + +import type { + Host, + ResolvedThreadExecutionOptions, + ThreadListEntry, + ThreadQueuedMessage, +} from "@bb/domain"; +import type { + ProjectWithThreadsResponse, + SidebarBootstrapResponse, + SystemVersionResponse, + ThreadResponse, + ThreadTabsResponse, +} from "@bb/server-contract"; +import { + DEMO_HOST_ID, + DEMO_PERSONAL_PROJECT_ID, + DEMO_PROJECT_ID, +} from "./ids.js"; +import { DEFAULT_MODEL } from "./providers.js"; +import type { DemoThreadSeed } from "./timelines.js"; + +const DAY_MS = 24 * 60 * 60_000; +const MINUTE_MS = 60_000; + +/** A thread as the list and detail routes see it at one instant. */ +export interface DemoThreadView { + seed: DemoThreadSeed; + /** True while a scripted reply is still pending. */ + busy: boolean; + /** Last activity: the seed's age, or the latest sent message. */ + updatedAt: number; +} + +export function seedUpdatedAt(seed: DemoThreadSeed, now: number): number { + return now - seed.minutesAgo * MINUTE_MS; +} + +/** When the seeded conversation of a thread started, 30 minutes before its last activity. */ +export function seedStartedAt(seed: DemoThreadSeed, now: number): number { + return seedUpdatedAt(seed, now) - 30 * MINUTE_MS; +} + +export function threadListEntry( + view: DemoThreadView, + now: number, +): ThreadListEntry { + const { seed, busy, updatedAt } = view; + return { + id: seed.id, + projectId: DEMO_PROJECT_ID, + environmentId: null, + providerId: "codex", + title: seed.title, + titleFallback: seed.title, + sectionId: null, + status: busy ? "active" : "idle", + parentThreadId: null, + sourceThreadId: null, + originKind: null, + originPluginId: null, + visibility: "visible", + archivedAt: null, + pinnedAt: null, + deletedAt: null, + lastReadAt: now, + latestAttentionAt: updatedAt, + createdAt: seedStartedAt(seed, now), + updatedAt, + runtime: { + displayStatus: busy ? "active" : "idle", + hostReconnectGraceExpiresAt: null, + }, + activity: { + activeWorkflowCount: 0, + activeBackgroundAgentCount: 0, + activeBackgroundCommandCount: 0, + activePlanModeCount: 0, + activeGoalCount: 0, + }, + pinSortKey: null, + hasPendingInteraction: false, + environmentHostId: DEMO_HOST_ID, + environmentName: null, + environmentBranchName: "main", + environmentWorkspaceDisplayKind: "other", + }; +} + +export function threadResponse( + view: DemoThreadView, + now: number, +): ThreadResponse { + const { + activity: _activity, + pinSortKey: _pinSortKey, + hasPendingInteraction: _hasPendingInteraction, + environmentHostId: _environmentHostId, + environmentName: _environmentName, + environmentBranchName: _environmentBranchName, + environmentWorkspaceDisplayKind: _environmentWorkspaceDisplayKind, + ...thread + } = threadListEntry(view, now); + return { ...thread, activeBackgroundAgentCount: 0, canSpawnChild: true }; +} + +const PROJECT_DEFAULT_EXECUTION_OPTIONS = { + providerId: "codex", + model: DEFAULT_MODEL, + reasoningLevel: "high", + permissionMode: "accept-edits", + serviceTier: "default", +} as const; + +export const THREAD_DEFAULT_EXECUTION_OPTIONS: ResolvedThreadExecutionOptions = + { + model: PROJECT_DEFAULT_EXECUTION_OPTIONS.model, + permissionMode: PROJECT_DEFAULT_EXECUTION_OPTIONS.permissionMode, + reasoningLevel: PROJECT_DEFAULT_EXECUTION_OPTIONS.reasoningLevel, + serviceTier: PROJECT_DEFAULT_EXECUTION_OPTIONS.serviceTier, + source: "client/turn/requested", + }; + +export function sidebarBootstrap( + views: readonly DemoThreadView[], + now: number, +): SidebarBootstrapResponse { + const createdAt = now - 7 * DAY_MS; + const project: ProjectWithThreadsResponse = { + id: DEMO_PROJECT_ID, + kind: "standard", + name: "demo-app", + gitRemoteUrl: null, + createdAt, + updatedAt: Math.max(...views.map((view) => view.updatedAt)), + sources: [], + threads: views.map((view) => threadListEntry(view, now)), + defaultExecutionOptions: PROJECT_DEFAULT_EXECUTION_OPTIONS, + }; + const personalProject: ProjectWithThreadsResponse = { + id: DEMO_PERSONAL_PROJECT_ID, + kind: "standard", + name: "Personal", + gitRemoteUrl: null, + createdAt, + updatedAt: createdAt, + sources: [], + threads: [], + defaultExecutionOptions: PROJECT_DEFAULT_EXECUTION_OPTIONS, + }; + return { sections: [], projects: [project], personalProject }; +} + +export function hosts(now: number): Host[] { + return [ + { + id: DEMO_HOST_ID, + name: "demo", + type: "persistent", + status: "connected", + maxPermissionMode: "full", + lastSeenAt: now, + lastRejectedProtocolVersion: null, + createdAt: now - 7 * DAY_MS, + updatedAt: now, + }, + ]; +} + +export const EMPTY_TABS: ThreadTabsResponse = { revision: 0, tabs: [] }; + +export const SYSTEM_VERSION: SystemVersionResponse = { + currentVersion: "0.39.0", + latestVersion: "0.39.0", + source: "npm", + updateAvailable: false, + isDevelopment: false, + upgradeCommand: "npx bb-app@latest", +}; + +/** `GET /plugins/contributions` has no contract type; this mirrors the server route. */ +export const PLUGIN_CONTRIBUTIONS = { cliCommands: [], mentionProviders: [] }; + +export function queuedMessage(args: { + id: string; + content: ThreadQueuedMessage["content"]; + now: number; +}): ThreadQueuedMessage { + return { + id: args.id, + content: args.content, + model: THREAD_DEFAULT_EXECUTION_OPTIONS.model, + reasoningLevel: THREAD_DEFAULT_EXECUTION_OPTIONS.reasoningLevel, + permissionMode: THREAD_DEFAULT_EXECUTION_OPTIONS.permissionMode, + serviceTier: THREAD_DEFAULT_EXECUTION_OPTIONS.serviceTier, + groupWithNext: false, + createdAt: args.now, + updatedAt: args.now, + }; +} diff --git a/apps/demo-server/src/worker.ts b/apps/demo-server/src/worker.ts new file mode 100644 index 0000000000..ae1e08f5e0 --- /dev/null +++ b/apps/demo-server/src/worker.ts @@ -0,0 +1,51 @@ +// bb demo server — a mock bb server for App Store review. +// +// WHY THIS EXISTS +// +// A bb server's public API is unauthenticated and permits command execution +// and file reads (see the warning in apps/server/src/start-server.ts). So the +// obvious way to give an App Review reviewer something to connect to — put a +// real bb server on the internet and paste the URL into the review notes — +// publishes a shell. The connect path is authenticated, but its pairing codes +// are single-use and expire in ten minutes (CONNECT_CODE_TTL_MS), which no +// reviewer can work with. +// +// This worker answers the subset of the bb server API that the mobile app +// touches on its launch path, from fixed fixtures. It runs no commands, reads +// no files, and holds no credentials, so it is safe to expose. A reviewer adds +// it as a Direct URL server and sees a working app. +// +// WHAT IT IS NOT +// +// It is not a bb server and must never be presented as one to users. It exists +// for review and for demos. Every route that is not part of the demo path +// answers 501 with a clear message, so an unimplemented corner reads as "not +// available in the demo" rather than as a broken app. +// +// ISOLATION +// +// Each client address gets its own Durable Object, so the messages a reviewer +// sends are visible only to that reviewer. The server is public: a shared +// world would let anyone put text in front of Apple's reviewer, and would +// let one visitor read what another typed. State is in-memory only and is +// dropped when the object goes idle. + +import { DemoStateDO } from "./demo-state.js"; + +export interface Env { + DEMO_STATE: DurableObjectNamespace; +} + +export { DemoStateDO }; + +/** The Durable Object name for a request: its client address, or one shared fallback when none is known (local dev). */ +function demoStateName(request: Request): string { + return request.headers.get("cf-connecting-ip") ?? "local"; +} + +export default { + async fetch(request: Request, env: Env): Promise<Response> { + const id = env.DEMO_STATE.idFromName(demoStateName(request)); + return env.DEMO_STATE.get(id).fetch(request); + }, +}; diff --git a/apps/demo-server/tsconfig.json b/apps/demo-server/tsconfig.json new file mode 100644 index 0000000000..59c47eb1e8 --- /dev/null +++ b/apps/demo-server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": [ + "@bb/tsconfig/base.json", + "@bb/tsconfig/typecheck-overrides.json" + ], + "compilerOptions": { + "rootDir": ".", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["@cloudflare/workers-types"], + "lib": ["ES2022"], + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/apps/demo-server/vitest.config.ts b/apps/demo-server/vitest.config.ts new file mode 100644 index 0000000000..c1433e6ef3 --- /dev/null +++ b/apps/demo-server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/apps/demo-server/wrangler.jsonc b/apps/demo-server/wrangler.jsonc new file mode 100644 index 0000000000..8377eda8ae --- /dev/null +++ b/apps/demo-server/wrangler.jsonc @@ -0,0 +1,38 @@ +// bb demo server — the mock bb server an App Store reviewer connects to. +// +// Deploy: .github/workflows/deploy-demo-server.yml on every push to main that +// touches apps/demo-server, or by hand: +// pnpm --filter @bb/demo-server exec wrangler deploy +// +// No route is declared, so the worker serves from +// https://bb-demo-server.<account-subdomain>.workers.dev. That is deliberate: +// connect owns the `*.getbb.app/*` zone route, so `demo.getbb.app` (the +// serverUrl the fixtures advertise) resolves to the tunnel gate and is refused +// as an unknown handle. Moving there needs a custom-domain route on this +// worker, which takes precedence over the wildcard, plus a DNS record; the CI +// token is zone read-only and cannot create either, so that is a dashboard +// step. workers.dev is HTTPS and is all a reviewer needs. +// +// Per-client isolation keys on the `cf-connecting-ip` header (src/worker.ts). +// workers.dev sets it; anything that proxies this worker would collapse every +// visitor into one shared world, so do not put it behind another proxy. +// +// No D1, no secrets, no bindings beyond the Durable Object: the demo holds +// fixed fixtures and in-memory per-client state only (one object per client +// address, see src/worker.ts). Nothing here can reach a real bb server, a +// machine, or a user's data. +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "bb-demo-server", + "main": "./src/worker.ts", + // Pinned so a CI token that can see more than one account never stops to + // ask which one; same account as bb-connect and bb-web. + "account_id": "7bb84c630057dafa53e2aacbe6bd094f", + "compatibility_date": "2026-06-11", + "compatibility_flags": ["nodejs_compat"], + "observability": { "enabled": true }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["DemoStateDO"] }], + "durable_objects": { + "bindings": [{ "name": "DEMO_STATE", "class_name": "DemoStateDO" }], + }, +} diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 58cc183b6e..2dac62382a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -111,7 +111,7 @@ Linux gets both update paths, but they are not equivalent: install and reports that a newer release exists. - Self-installing auto-update runs only inside an AppImage whose directory the app can write to. electron-updater detects the AppImage through the `APPIMAGE` - environment variable, and its install step unlinks the running file *before* + environment variable, and its install step unlinks the running file _before_ moving the replacement in — so a read-only directory would delete the app and leave nothing behind. Both the startup check and the install handler verify write and search access on the parent directory first. @@ -155,10 +155,10 @@ so a single publisher is what keeps one platform from deleting the other's binaries. Each platform has its own update feed file inside the same release tag: -| Platform | Artifacts | electron-updater metadata | Version feed | -| -------- | ----------------------- | ------------------------- | ---------------------------- | -| macOS | `.dmg`, `.zip` (arm64) | `latest-mac.yml` | `desktop-version.json` | -| Linux | `.AppImage` (x64) | `latest-linux.yml` | `desktop-version-linux.json` | +| Platform | Artifacts | electron-updater metadata | Version feed | +| -------- | ---------------------- | ------------------------- | ---------------------------- | +| macOS | `.dmg`, `.zip` (arm64) | `latest-mac.yml` | `desktop-version.json` | +| Linux | `.AppImage` (x64) | `latest-linux.yml` | `desktop-version-linux.json` | macOS keeps the unsuffixed feed name because released macOS builds already request it. Linux artifacts are unsigned; only the macOS binaries wait on the @@ -211,6 +211,27 @@ is baked into the Electron main/preload bundles and selects the nightly product identity, yellow icon, and update URLs. Omit the variable (or set it to `latest`) for stable and local builds. +## About panel + +The app menu's About item opens a message box listing the facts a bug report +needs: version, build type, commit, build date and how old that build is +("3 days old"), plugin SDK version, Electron version, and OS. Its **Copy** +button puts that whole block on the clipboard. The age is computed when the +dialog opens, so a long-running session still reports it correctly. + +The native About panel is populated too, minus the age, since Electron takes +those options once at startup. `scripts/build.mjs` bakes the build-time half of +the facts into the bundles: + +| Variable | Default when unset | +| ----------------------- | ----------------------------------------------------- | +| `BB_DESKTOP_COMMIT` | `GITHUB_SHA`, else `git rev-parse HEAD`, else unknown | +| `BB_DESKTOP_BUILD_DATE` | The build's own timestamp, ISO 8601 | + +The plugin SDK version is read from `packages/plugin-sdk/package.json` at build +time. A checkout with no git metadata reports `Commit: unknown` rather than +failing the build. + ## macOS signing + notarization The desktop package is ready for Developer ID signing and Apple notarization. diff --git a/apps/desktop/scripts/build.mjs b/apps/desktop/scripts/build.mjs index ec85784ba4..8d91d054d4 100644 --- a/apps/desktop/scripts/build.mjs +++ b/apps/desktop/scripts/build.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { readFile, rm } from "node:fs/promises"; import { resolve } from "node:path"; import { build } from "esbuild"; @@ -6,8 +7,16 @@ import { resolveDesktopReleaseChannel } from "./desktop-release-channel.mjs"; const packageRoot = process.cwd(); const distDir = resolve(packageRoot, "dist"); const packageJsonPath = resolve(packageRoot, "package.json"); +const pluginSdkPackageJsonPath = resolve( + packageRoot, + "..", + "..", + "packages", + "plugin-sdk", + "package.json", +); -function readPackageVersion(packageJsonText) { +function readPackageVersion(packageJsonText, label) { const packageJson = JSON.parse(packageJsonText); if ( typeof packageJson !== "object" || @@ -15,21 +24,62 @@ function readPackageVersion(packageJsonText) { typeof packageJson.version !== "string" || packageJson.version.length === 0 ) { - throw new Error("apps/desktop/package.json must define a version"); + throw new Error(`${label} must define a version`); } return packageJson.version; } +/** + * The About panel reports the commit a build came from. A tarball checkout or + * a shallow CI clone can have no usable git metadata, so an unknown commit is + * reported as such rather than failing the build. + */ +function readBuildCommit(env) { + const injected = + env.BB_DESKTOP_COMMIT?.trim() ?? env.GITHUB_SHA?.trim() ?? ""; + if (injected.length > 0) { + return injected; + } + try { + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd: packageRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +function readBuildDate(env) { + const injected = env.BB_DESKTOP_BUILD_DATE?.trim() ?? ""; + if (injected.length > 0) { + return injected; + } + return new Date().toISOString(); +} + await rm(distDir, { force: true, recursive: true }); const desktopVersion = readPackageVersion( await readFile(packageJsonPath, "utf8"), + "apps/desktop/package.json", +); +const pluginSdkVersion = readPackageVersion( + await readFile(pluginSdkPackageJsonPath, "utf8"), + "packages/plugin-sdk/package.json", ); const desktopReleaseChannel = resolveDesktopReleaseChannel(process.env); +const desktopCommit = readBuildCommit(process.env); +const desktopBuildDate = readBuildDate(process.env); const commonOptions = { bundle: true, define: { + "process.env.BB_DESKTOP_BUILD_DATE": JSON.stringify(desktopBuildDate), + "process.env.BB_DESKTOP_COMMIT": JSON.stringify(desktopCommit), + "process.env.BB_DESKTOP_PLUGIN_SDK_VERSION": + JSON.stringify(pluginSdkVersion), "process.env.BB_DESKTOP_RELEASE_CHANNEL": JSON.stringify( desktopReleaseChannel, ), diff --git a/apps/desktop/scripts/desktop-release-channel.d.mts b/apps/desktop/scripts/desktop-release-channel.d.mts index c79bc7d9df..f084557e1e 100644 --- a/apps/desktop/scripts/desktop-release-channel.d.mts +++ b/apps/desktop/scripts/desktop-release-channel.d.mts @@ -17,8 +17,6 @@ export interface DesktopReleaseConfig { updateMetadataFileNames: DesktopUpdateMetadataFileNames; } -export const DESKTOP_RELEASE_CHANNEL_ENV_NAME: "BB_DESKTOP_RELEASE_CHANNEL"; - export function resolveDesktopReleaseChannel( env: NodeJS.ProcessEnv, ): DesktopReleaseChannel; diff --git a/apps/desktop/scripts/desktop-release-channel.mjs b/apps/desktop/scripts/desktop-release-channel.mjs index de2bff803b..44eb19b964 100644 --- a/apps/desktop/scripts/desktop-release-channel.mjs +++ b/apps/desktop/scripts/desktop-release-channel.mjs @@ -1,4 +1,4 @@ -export const DESKTOP_RELEASE_CHANNEL_ENV_NAME = "BB_DESKTOP_RELEASE_CHANNEL"; +const DESKTOP_RELEASE_CHANNEL_ENV_NAME = "BB_DESKTOP_RELEASE_CHANNEL"; export function resolveDesktopReleaseChannel(env) { const rawChannel = env[DESKTOP_RELEASE_CHANNEL_ENV_NAME]?.trim(); diff --git a/apps/desktop/scripts/run-electron-builder.mjs b/apps/desktop/scripts/run-electron-builder.mjs index e833184020..9e14bd40a9 100644 --- a/apps/desktop/scripts/run-electron-builder.mjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -158,7 +158,7 @@ function createSigningPlan(env) { }; } -export function resolveElectronBuilderConfig(baseConfig, env) { +function resolveElectronBuilderConfig(baseConfig, env) { const signingPlan = createSigningPlan(env); const releaseChannel = resolveDesktopReleaseChannel(env); const releaseConfig = createDesktopReleaseConfig(releaseChannel); @@ -297,10 +297,3 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { process.exitCode = 1; }); } - -export const electronBuilderSigningEnvironment = { - codeSigningKeys, - missingEnvironmentKeys, - notarizationKeys, - requiredSigningEnvironmentKeys, -}; diff --git a/apps/desktop/scripts/smoke-packaged-app.mjs b/apps/desktop/scripts/smoke-packaged-app.mjs index 2026275c83..140fd09965 100644 --- a/apps/desktop/scripts/smoke-packaged-app.mjs +++ b/apps/desktop/scripts/smoke-packaged-app.mjs @@ -144,8 +144,7 @@ async function startSmokeServer({ customThemes: [], dataDir, experiments: { - claudeCodeMockCliTraffic: false, - newOnboarding: false, + mobileApp: false, providerSessionReaping: false, }, featureFlags: { diff --git a/apps/desktop/src/app-paths.ts b/apps/desktop/src/app-paths.ts index 8b3f8d4131..9a5efc247d 100644 --- a/apps/desktop/src/app-paths.ts +++ b/apps/desktop/src/app-paths.ts @@ -7,21 +7,16 @@ export interface DesktopPathContext { resourcesPath: string; } -export interface ResolveDesktopBridgePathArgs { +interface ResolveDesktopBridgePathArgs { paths: DesktopPathContext; } -export interface ResolveDesktopAssetPathArgs { - fileName: string; - paths: DesktopPathContext; -} - -export interface ResolveDesktopIconPathArgs { +interface ResolveDesktopIconPathArgs { packagedIconFileName: string; paths: DesktopPathContext; } -export interface AssertPathExistsArgs { +interface AssertPathExistsArgs { label: string; path: string; } @@ -44,21 +39,14 @@ export function resolveDesktopBridgePath( return join(args.paths.appPath, "dist", "bb-app-bridge.mjs"); } -export function resolveDesktopAssetPath( - args: ResolveDesktopAssetPathArgs, -): string { - return join(args.paths.appPath, "assets", args.fileName); -} - export function resolveDesktopIconPath( args: ResolveDesktopIconPathArgs, ): string { - return resolveDesktopAssetPath({ - fileName: args.paths.isPackaged - ? args.packagedIconFileName - : "icon-dev.png", - paths: args.paths, - }); + return join( + args.paths.appPath, + "assets", + args.paths.isPackaged ? args.packagedIconFileName : "icon-dev.png", + ); } export function assertPathExists(args: AssertPathExistsArgs): void { diff --git a/apps/desktop/src/bb-process.ts b/apps/desktop/src/bb-process.ts index f4c26649e7..c10f603c8c 100644 --- a/apps/desktop/src/bb-process.ts +++ b/apps/desktop/src/bb-process.ts @@ -1,15 +1,15 @@ import { spawn, type ChildProcess } from "node:child_process"; -export interface RuntimeLogBuffer { +interface RuntimeLogBuffer { append(chunk: Buffer | string): void; text(): string; } -export interface CreateRuntimeLogBufferArgs { +interface CreateRuntimeLogBufferArgs { maxLines: number; } -export interface StartBbAppProcessArgs { +interface StartBbAppProcessArgs { bridgePath: string; cwd: string; env: NodeJS.ProcessEnv; @@ -30,30 +30,26 @@ export interface BbAppProcessExit { signal: NodeJS.Signals | null; } -export interface StopBbAppProcessArgs { +interface StopBbAppProcessArgs { killSignal: NodeJS.Signals; killTimeoutMs: number; signal: NodeJS.Signals; timeoutMs: number; } -export interface CreateElectronNodeEnvArgs { - env: NodeJS.ProcessEnv; -} +type BbAppProcessRuntimeMode = "electron-node" | "node"; -export type BbAppProcessRuntimeMode = "electron-node" | "node"; - -export interface BbAppProcessRuntime { +interface BbAppProcessRuntime { executablePath: string; mode: BbAppProcessRuntimeMode; } -export interface CreateBbAppProcessEnvArgs { +interface CreateBbAppProcessEnvArgs { env: NodeJS.ProcessEnv; runtimeMode: BbAppProcessRuntimeMode; } -export interface ResolveBbAppProcessRuntimeArgs { +interface ResolveBbAppProcessRuntimeArgs { env: NodeJS.ProcessEnv; isPackaged: boolean; processExecPath: string; @@ -69,7 +65,7 @@ type ResolveWaitForProcessExitWithTimeout = ( result: WaitForProcessExitWithTimeoutResult, ) => void; -export function createRuntimeLogBuffer( +function createRuntimeLogBuffer( args: CreateRuntimeLogBufferArgs, ): RuntimeLogBuffer { const lines: string[] = []; @@ -93,20 +89,11 @@ export function createRuntimeLogBuffer( }; } -export function createElectronNodeEnv( - args: CreateElectronNodeEnvArgs, -): NodeJS.ProcessEnv { - return { - ...args.env, - ELECTRON_RUN_AS_NODE: "1", - }; -} - export function createBbAppProcessEnv( args: CreateBbAppProcessEnvArgs, ): NodeJS.ProcessEnv { if (args.runtimeMode === "electron-node") { - return createElectronNodeEnv({ env: args.env }); + return { ...args.env, ELECTRON_RUN_AS_NODE: "1" }; } const env = { ...args.env }; diff --git a/apps/desktop/src/connect-credential-cache.ts b/apps/desktop/src/connect-credential-cache.ts index 8380e7066c..f113bccb30 100644 --- a/apps/desktop/src/connect-credential-cache.ts +++ b/apps/desktop/src/connect-credential-cache.ts @@ -5,7 +5,7 @@ import { type ConnectCredential, } from "@bb/connect-client"; -export const CONNECT_CREDENTIAL_FILE_NAME = "connect-credential.bin"; +const CONNECT_CREDENTIAL_FILE_NAME = "connect-credential.bin"; /** Electron's `safeStorage`, narrowed to what the cache uses. */ export interface ConnectCredentialEncryption { @@ -20,7 +20,7 @@ export interface ConnectCredentialCacheFs { writeFile(path: string, data: Buffer): Promise<void>; } -export interface CreateConnectCredentialCacheArgs { +interface CreateConnectCredentialCacheArgs { encryption: ConnectCredentialEncryption; fs?: ConnectCredentialCacheFs; userDataPath: string; diff --git a/apps/desktop/src/connect-desktop-session.ts b/apps/desktop/src/connect-desktop-session.ts index e0c8915e0b..b0fd55f77d 100644 --- a/apps/desktop/src/connect-desktop-session.ts +++ b/apps/desktop/src/connect-desktop-session.ts @@ -17,14 +17,14 @@ const rpcSuccessSchema = z.object({ }), }); -export interface DesktopSessionCookie { +interface DesktopSessionCookie { domain: string; expiresAt: number; name: string; value: string; } -export interface DesktopCookie { +interface DesktopCookie { domain?: string; name: string; value: string; @@ -45,7 +45,7 @@ export interface DesktopCookieStore { }): Promise<void>; } -export type ConnectDesktopSessionFailureCode = +type ConnectDesktopSessionFailureCode = | "cookie_install_failed" | "cookie_verification_failed" | "invalid_response" @@ -62,13 +62,12 @@ export type ConnectDesktopSessionResult = ok: false; }; -export type MintDesktopSessionCookieResult = +type MintDesktopSessionCookieResult = | { cookie: DesktopSessionCookie; ok: true } | { code: ConnectDesktopSessionFailureCode; detail: string; ok: false }; /** Where a session cookie comes from: the local plugin, or the connect gate. */ -export type DesktopSessionCookieSource = - () => Promise<MintDesktopSessionCookieResult>; +type DesktopSessionCookieSource = () => Promise<MintDesktopSessionCookieResult>; function failure( code: ConnectDesktopSessionFailureCode, diff --git a/apps/desktop/src/connect-machine-enrollment.ts b/apps/desktop/src/connect-machine-enrollment.ts index 1a6c766370..90ff9643a7 100644 --- a/apps/desktop/src/connect-machine-enrollment.ts +++ b/apps/desktop/src/connect-machine-enrollment.ts @@ -6,7 +6,7 @@ import { type ConnectCredential, } from "@bb/connect-client"; -export const CREATE_MACHINE_CODE_RPC = "createMachineCode"; +const CREATE_MACHINE_CODE_RPC = "createMachineCode"; const machineCodeRpcSchema = z .object({ @@ -29,17 +29,17 @@ const rpcFailureSchema = z.object({ error: z.object({ code: z.string(), message: z.string() }), }); -export type EnrollDesktopMachineFailureCode = +type EnrollDesktopMachineFailureCode = | "machine_limit" | "not_paired" | "network" | "invalid_response"; -export type EnrollDesktopMachineResult = +type EnrollDesktopMachineResult = | { ok: true; credential: ConnectCredential } | { code: EnrollDesktopMachineFailureCode; detail: string; ok: false }; -export interface EnrollDesktopMachineArgs { +interface EnrollDesktopMachineArgs { fetchImpl?: typeof fetch; /** Local builtin server origin, e.g. `http://127.0.0.1:38886`. */ localServerUrl: string; diff --git a/apps/desktop/src/connect-server-sync.ts b/apps/desktop/src/connect-server-sync.ts index e7fb267831..2f088c89d2 100644 --- a/apps/desktop/src/connect-server-sync.ts +++ b/apps/desktop/src/connect-server-sync.ts @@ -6,7 +6,7 @@ import { } from "@bb/connect-client"; /** POST /api/v1/plugins/connect/rpc/listAccountServers result body. */ -export const connectAccountServerSchema = z +const connectAccountServerSchema = z .object({ handle: z.string().min(1), name: z.string().min(1), @@ -16,14 +16,14 @@ export const connectAccountServerSchema = z .strict(); export type ConnectAccountServer = z.infer<typeof connectAccountServerSchema>; -export const connectListAccountServersResultSchema = z +const connectListAccountServersResultSchema = z .object({ servers: z.array(connectAccountServerSchema), selfHandle: z.string().min(1), }) .strict(); -export type ConnectListAccountServersResult = z.infer< +type ConnectListAccountServersResult = z.infer< typeof connectListAccountServersResultSchema >; @@ -34,26 +34,59 @@ const rpcSuccessSchema = z }) .strict(); +/** + * `{ ok: false, error }` from the plugin route. A string `error` is the + * route's own refusal (plugin not running, auth); an object is the structured + * handler failure, whose `message` carries the connect plugin's stable code. + */ const rpcFailureSchema = z .object({ ok: z.literal(false), - error: z.string().optional(), + error: z.union([ + z.string(), + z.object({ code: z.string(), message: z.string() }).passthrough(), + ]), }) .passthrough(); -export const CONNECT_PLUGIN_ID = "connect"; -export const LIST_ACCOUNT_SERVERS_RPC = "listAccountServers"; -export const CONNECT_SERVER_SYNC_INTERVAL_MS = 10 * 60 * 1000; -export const CONNECT_SERVER_SYNC_MIN_INTERVAL_MS = 60 * 1000; +/** + * Why a sync produced no server list. Every value is actionable from the + * Server menu, so the menu can say which one it was. + * + * - `no-credential`: no local runtime, and the app never enrolled a machine + * credential of its own (it gets one after a Connect sign-in via the local + * server). + * - `plugin-disabled`: the local server answered but its connect plugin is + * not running. + * - `not-paired`: the local server's connect plugin is on but not paired. + * - `unauthorized`: the gate refused the app's cached credential. + * - `unavailable`: the local server or the gate could not be reached or + * answered with something unexpected. + */ +export type ConnectServerSyncSkipReason = + | "no-credential" + | "plugin-disabled" + | "not-paired" + | "unauthorized" + | "unavailable"; -export type ConnectServerSyncFetch = ( +export type FetchConnectAccountServersResult = + | { ok: true; result: ConnectListAccountServersResult } + | { ok: false; reason: ConnectServerSyncSkipReason }; + +const CONNECT_PLUGIN_ID = "connect"; +const LIST_ACCOUNT_SERVERS_RPC = "listAccountServers"; +const CONNECT_SERVER_SYNC_INTERVAL_MS = 10 * 60 * 1000; +const CONNECT_SERVER_SYNC_MIN_INTERVAL_MS = 60 * 1000; + +type ConnectServerSyncFetch = ( input: string, init?: RequestInit, ) => Promise<Pick<Response, "ok" | "status" | "json" | "text">>; -export type ConnectServerSyncLog = (message: string) => void; +type ConnectServerSyncLog = (message: string) => void; -export interface FetchConnectAccountServersArgs { +interface FetchConnectAccountServersArgs { /** Local builtin server origin, e.g. `http://127.0.0.1:38886`. */ serverUrl: string; fetchImpl?: ConnectServerSyncFetch; @@ -64,13 +97,15 @@ export interface FetchConnectAccountServersArgs { * `POST /api/v1/plugins/connect/rpc/listAccountServers` * * Auth is the plugin route "local" policy: `content-type: application/json` - * on POST (no Origin required when the header is absent). Returns null when - * the plugin is unavailable, unpaired, or the server is down — callers treat - * that as a silent no-op. + * on POST (no Origin required when the header is absent). A failure carries + * the reason the server gave: HTTP 503 means the plugin is not running, + * `{ code: "handler_error", message: "not_paired" }` means it is on but + * unpaired, and anything else (server down, non-JSON, unknown shape) is + * `unavailable`. */ export async function fetchConnectAccountServers( args: FetchConnectAccountServersArgs, -): Promise<ConnectListAccountServersResult | null> { +): Promise<FetchConnectAccountServersResult> { const fetchImpl = args.fetchImpl ?? globalThis.fetch; const base = args.serverUrl.replace(/\/$/u, ""); const url = `${base}/api/v1/plugins/${encodeURIComponent(CONNECT_PLUGIN_ID)}/rpc/${encodeURIComponent(LIST_ACCOUNT_SERVERS_RPC)}`; @@ -83,24 +118,34 @@ export async function fetchConnectAccountServers( body: "null", }); } catch { - return null; + return { ok: false, reason: "unavailable" }; } let body: unknown; try { body = await response.json(); } catch { - return null; + return { ok: false, reason: "unavailable" }; } const success = rpcSuccessSchema.safeParse(body); if (success.success) { - return success.data.result; + return { ok: true, result: success.data.result }; } - // ok:false, wrong shape, HTTP error body — all map to silent no-op. - rpcFailureSchema.safeParse(body); - return null; + if (response.status === 503) { + return { ok: false, reason: "plugin-disabled" }; + } + const failure = rpcFailureSchema.safeParse(body); + if ( + failure.success && + typeof failure.data.error === "object" && + failure.data.error.code === "handler_error" && + failure.data.error.message === "not_paired" + ) { + return { ok: false, reason: "not-paired" }; + } + return { ok: false, reason: "unavailable" }; } /** @@ -113,7 +158,7 @@ export function selectTargetableConnectServers( return result.servers.filter((server) => server.handle !== result.selfHandle); } -export interface CreateConnectServerSyncArgs { +interface CreateConnectServerSyncArgs { /** * The app's own cached machine credential, or null when it has none. Used * when no local runtime is up, so a remote target still lists servers. @@ -123,6 +168,8 @@ export interface CreateConnectServerSyncArgs { getLocalServerUrl: () => string | null; /** Fresh targetable server list after every successful sync. */ onServers: (servers: ConnectAccountServer[]) => void; + /** A sync that produced no list, with the reason the Server menu shows. */ + onSkipped: (reason: ConnectServerSyncSkipReason) => void; /** The gate refused the cached credential — the caller must drop it. */ onUnauthorized: () => void; /** @@ -134,7 +181,6 @@ export interface CreateConnectServerSyncArgs { fetchImpl?: ConnectServerSyncFetch; log?: ConnectServerSyncLog; now?: () => number; - intervalMs?: number; minIntervalMs?: number; setIntervalFn?: (handler: () => void, timeout: number) => unknown; clearIntervalFn?: (handle: unknown) => void; @@ -162,7 +208,7 @@ export interface ConnectServerSync { export function createConnectServerSync( args: CreateConnectServerSyncArgs, ): ConnectServerSync { - const intervalMs = args.intervalMs ?? CONNECT_SERVER_SYNC_INTERVAL_MS; + const intervalMs = CONNECT_SERVER_SYNC_INTERVAL_MS; const minIntervalMs = args.minIntervalMs ?? CONNECT_SERVER_SYNC_MIN_INTERVAL_MS; const now = args.now ?? Date.now; @@ -179,14 +225,14 @@ export function createConnectServerSync( let timer: unknown = null; let lastSyncAttemptAt = 0; let inFlight: Promise<void> | null = null; - let loggedFailure = false; + let loggedSkipReason: ConnectServerSyncSkipReason | null = null; /** * Prefer the local server: it holds the pairing secret and always reflects * whether the plugin is on. Fall back to the app's own credential so a * remote target keeps a live server list with no local runtime. */ - async function fetchServers(): Promise<ConnectListAccountServersResult | null> { + async function fetchServers(): Promise<FetchConnectAccountServersResult> { const serverUrl = args.getLocalServerUrl(); if (serverUrl !== null) { return fetchConnectAccountServers({ @@ -196,33 +242,35 @@ export function createConnectServerSync( } const credential = args.getCredential(); if (credential === null) { - return null; + return { ok: false, reason: "no-credential" }; } try { - return await listAccountServers(credential, args.gateFetchImpl); + const result = await listAccountServers(credential, args.gateFetchImpl); + return { ok: true, result }; } catch (error) { if (error instanceof ConnectListError && error.code === "unauthorized") { args.onUnauthorized(); + return { ok: false, reason: "unauthorized" }; } - return null; + return { ok: false, reason: "unavailable" }; } } async function runSync(): Promise<void> { lastSyncAttemptAt = now(); - const result = await fetchServers(); - if (result === null) { - if (!loggedFailure) { - loggedFailure = true; - log?.( - "connect server sync skipped (plugin disabled, not paired, or no local server and no cached credential)", - ); + const outcome = await fetchServers(); + if (!outcome.ok) { + // One log line per failure streak, plus one when the reason changes. + if (loggedSkipReason !== outcome.reason) { + loggedSkipReason = outcome.reason; + log?.(`connect server sync skipped (${outcome.reason})`); } + args.onSkipped(outcome.reason); return; } - loggedFailure = false; - args.onServers(selectTargetableConnectServers(result)); + loggedSkipReason = null; + args.onServers(selectTargetableConnectServers(outcome.result)); } function syncNow(): Promise<void> { diff --git a/apps/desktop/src/connect-session-renewal.ts b/apps/desktop/src/connect-session-renewal.ts index 1131a83eab..1a3d3dbf74 100644 --- a/apps/desktop/src/connect-session-renewal.ts +++ b/apps/desktop/src/connect-session-renewal.ts @@ -1,5 +1,5 @@ -export const CONNECT_SESSION_RENEWAL_LEAD_MS = 5 * 60 * 1000; -export const CONNECT_SESSION_MIN_RENEWAL_DELAY_MS = 30 * 1000; +const CONNECT_SESSION_RENEWAL_LEAD_MS = 5 * 60 * 1000; +const CONNECT_SESSION_MIN_RENEWAL_DELAY_MS = 30 * 1000; export type ConnectSessionAuthenticateResult = | { expiresAt: number; ok: true } @@ -12,17 +12,15 @@ export type ConnectSessionAuthenticateResult = * authentication must consult it before any expensive fallback, because the * user can switch targets while it runs. */ -export type ConnectSessionAuthenticate = ( +type ConnectSessionAuthenticate = ( remoteServerUrl: string, isCurrent: () => boolean, ) => Promise<ConnectSessionAuthenticateResult>; -export interface CreateConnectSessionRenewalArgs { +interface CreateConnectSessionRenewalArgs { authenticate: ConnectSessionAuthenticate; clearTimeoutFn?: (handle: unknown) => void; - leadMs?: number; log?: (message: string) => void; - minDelayMs?: number; now?: () => number; setTimeoutFn?: (handler: () => void, timeout: number) => unknown; } @@ -53,8 +51,8 @@ export interface ConnectSessionRenewal { export function createConnectSessionRenewal( args: CreateConnectSessionRenewalArgs, ): ConnectSessionRenewal { - const leadMs = args.leadMs ?? CONNECT_SESSION_RENEWAL_LEAD_MS; - const minDelayMs = args.minDelayMs ?? CONNECT_SESSION_MIN_RENEWAL_DELAY_MS; + const leadMs = CONNECT_SESSION_RENEWAL_LEAD_MS; + const minDelayMs = CONNECT_SESSION_MIN_RENEWAL_DELAY_MS; const now = args.now ?? Date.now; const setTimeoutFn = args.setTimeoutFn ?? diff --git a/apps/desktop/src/desktop-about-panel.ts b/apps/desktop/src/desktop-about-panel.ts new file mode 100644 index 0000000000..196496eeb8 --- /dev/null +++ b/apps/desktop/src/desktop-about-panel.ts @@ -0,0 +1,160 @@ +/** + * Facts shown in the About dialog. Everything here is either injected at build + * time (version, channel, commit, build date, plugin SDK version) or read from + * the running process, so a bug report can be reproduced against the exact + * build the user is running. + */ +export interface DesktopAboutFacts { + applicationName: string; + /** ISO 8601 timestamp of when the build was produced. */ + buildDate: string; + channel: "latest" | "nightly"; + /** Full git SHA, or empty when the build had no git metadata. */ + commit: string; + electronVersion: string; + osArch: string; + osRelease: string; + /** `os.type()`, e.g. "Darwin" or "Linux". */ + osType: string; + platform: NodeJS.Platform; + pluginSdkVersion: string; + version: string; +} + +export interface DesktopAboutPanelOptions { + applicationName: string; + applicationVersion: string; + credits?: string; +} + +export interface DesktopAboutDialogOptions { + buttons: string[]; + cancelId: number; + /** Index into `buttons` whose click should copy `detail` to the clipboard. */ + copyButtonId: number; + defaultId: number; + detail: string; + message: string; + type: "info"; +} + +export const ABOUT_DIALOG_COPY_BUTTON_LABEL = "Copy"; +const ABOUT_DIALOG_DISMISS_BUTTON_LABEL = "OK"; +const UNKNOWN_VALUE = "unknown"; +const MILLISECONDS_PER_DAY = 86_400_000; + +function displayValue(value: string): string { + const trimmed = value.trim(); + return trimmed.length === 0 ? UNKNOWN_VALUE : trimmed; +} + +/** + * How stale the running build is, in the "3 days old" form. Returns null for an + * unparseable build date so the Date line degrades to the raw value instead of + * claiming an age it cannot know. + */ +export function formatBuildAge( + buildDate: string, + nowMs: number, +): string | null { + const buildMs = Date.parse(buildDate); + if (Number.isNaN(buildMs)) { + return null; + } + // A build that reads as newer than the clock means skew, not a future + // release; report it as fresh rather than as a negative age. + const days = Math.max( + 0, + Math.floor((nowMs - buildMs) / MILLISECONDS_PER_DAY), + ); + if (days === 0) { + return "today"; + } + return days === 1 ? "1 day old" : `${days} days old`; +} + +function formatBuildDate(buildDate: string, nowMs: number | null): string { + const trimmed = buildDate.trim(); + if (trimmed.length === 0) { + return UNKNOWN_VALUE; + } + if (nowMs === null) { + return trimmed; + } + const age = formatBuildAge(trimmed, nowMs); + return age === null ? trimmed : `${trimmed} (${age})`; +} + +/** + * The detail block a user copies into a bug report, one `Label: value` per + * line, most build-identifying first. Pass null for `nowMs` where the block is + * rendered once and read later — a build age frozen at launch would be wrong by + * the time a long-running session reads it. + */ +export function buildDesktopAboutDetails( + facts: DesktopAboutFacts, + nowMs: number | null, +): string { + const lines: [string, string][] = [ + ["Version", facts.version], + ["Build Type", facts.channel === "nightly" ? "Nightly" : "Stable"], + ["Commit", facts.commit], + ["Date", formatBuildDate(facts.buildDate, nowMs)], + ["Plugin SDK", facts.pluginSdkVersion], + ["Electron", facts.electronVersion], + ["OS", `${facts.osType} ${facts.osArch} ${facts.osRelease}`], + ]; + return lines + .map(([label, value]) => `${label}: ${displayValue(value)}`) + .join("\n"); +} + +/** + * The About dialog shown from the app menu. It replaces the native About panel + * because only a message box can carry a Copy button, and the whole point of + * the detail block is pasting it into a bug report. + */ +export function createDesktopAboutDialogOptions( + facts: DesktopAboutFacts, + nowMs: number, +): DesktopAboutDialogOptions { + return { + buttons: [ + ABOUT_DIALOG_DISMISS_BUTTON_LABEL, + ABOUT_DIALOG_COPY_BUTTON_LABEL, + ], + cancelId: 0, + copyButtonId: 1, + defaultId: 0, + detail: buildDesktopAboutDetails(facts, nowMs), + message: facts.applicationName, + type: "info", + }; +} + +/** + * The native panel stays populated for any path that opens it without going + * through the app menu. Electron accepts these options once at startup, so it + * omits the build age. + */ +export function createDesktopAboutPanelOptions( + facts: DesktopAboutFacts, +): DesktopAboutPanelOptions { + const details = buildDesktopAboutDetails(facts, null); + + // `credits` is macOS/Windows only. The GTK about dialog has no equivalent + // free-text field, so Linux gets the details under the version instead of + // silently dropping them. + if (facts.platform === "linux") { + return { + applicationName: facts.applicationName, + applicationVersion: `${facts.version}\n\n${details}`, + }; + } + + return { + applicationName: facts.applicationName, + applicationVersion: facts.version, + credits: details, + }; +} diff --git a/apps/desktop/src/desktop-auto-update.ts b/apps/desktop/src/desktop-auto-update.ts index bb646fec37..1dd05a9355 100644 --- a/apps/desktop/src/desktop-auto-update.ts +++ b/apps/desktop/src/desktop-auto-update.ts @@ -55,7 +55,7 @@ export interface DesktopAutoUpdaterAdapter { setLogger(logger: DesktopAutoUpdateLogger): void; } -export interface CreateDesktopAutoUpdateServiceArgs { +interface CreateDesktopAutoUpdateServiceArgs { currentVersion: string; enabled: boolean; forceDevUpdateConfig: boolean; @@ -65,7 +65,7 @@ export interface CreateDesktopAutoUpdateServiceArgs { updater: DesktopAutoUpdaterAdapter; } -export interface ShouldEnableDesktopAutoUpdateArgs { +interface ShouldEnableDesktopAutoUpdateArgs { env: NodeJS.ProcessEnv; isPackaged: boolean; } diff --git a/apps/desktop/src/desktop-browser-ipc.ts b/apps/desktop/src/desktop-browser-ipc.ts index c1686f6b96..1da27a5322 100644 --- a/apps/desktop/src/desktop-browser-ipc.ts +++ b/apps/desktop/src/desktop-browser-ipc.ts @@ -12,11 +12,15 @@ export const BB_DESKTOP_BROWSER_GO_FORWARD_CHANNEL = "bb-desktop:browser:go-forward"; export const BB_DESKTOP_BROWSER_RELOAD_CHANNEL = "bb-desktop:browser:reload"; export const BB_DESKTOP_BROWSER_STOP_CHANNEL = "bb-desktop:browser:stop"; +export const BB_DESKTOP_BROWSER_FOCUS_CHANNEL = "bb-desktop:browser:focus"; export const BB_DESKTOP_BROWSER_SET_BOUNDS_CHANNEL = "bb-desktop:browser:set-bounds"; export const BB_DESKTOP_BROWSER_SET_VISIBLE_CHANNEL = "bb-desktop:browser:set-visible"; +export const BB_DESKTOP_BROWSER_SET_VISIBLE_WITHOUT_FOCUS_CHANNEL = + "bb-desktop:browser:set-visible-without-focus"; export const BB_DESKTOP_BROWSER_STATE_CHANNEL = "bb-desktop:browser:state"; +export const BB_DESKTOP_BROWSER_FOCUSED_CHANNEL = "bb-desktop:browser:focused"; export const BB_DESKTOP_BROWSER_OPEN_TAB_CHANNEL = "bb-desktop:browser:open-tab"; export const BB_DESKTOP_BROWSER_SCOPED_OPEN_TAB_CHANNEL = diff --git a/apps/desktop/src/desktop-browser-main-ipc.ts b/apps/desktop/src/desktop-browser-main-ipc.ts index 71fc7a31c8..db490314ad 100644 --- a/apps/desktop/src/desktop-browser-main-ipc.ts +++ b/apps/desktop/src/desktop-browser-main-ipc.ts @@ -11,6 +11,7 @@ import { import { BB_DESKTOP_BROWSER_ATTACH_CHANNEL, BB_DESKTOP_BROWSER_DETACH_CHANNEL, + BB_DESKTOP_BROWSER_FOCUS_CHANNEL, BB_DESKTOP_BROWSER_FIND_IN_PAGE_CHANNEL, BB_DESKTOP_BROWSER_GO_BACK_CHANNEL, BB_DESKTOP_BROWSER_GO_FORWARD_CHANNEL, @@ -18,6 +19,7 @@ import { BB_DESKTOP_BROWSER_RELOAD_CHANNEL, BB_DESKTOP_BROWSER_SET_BOUNDS_CHANNEL, BB_DESKTOP_BROWSER_SET_VISIBLE_CHANNEL, + BB_DESKTOP_BROWSER_SET_VISIBLE_WITHOUT_FOCUS_CHANNEL, BB_DESKTOP_BROWSER_STOP_CHANNEL, BB_DESKTOP_BROWSER_STOP_FIND_IN_PAGE_CHANNEL, } from "./desktop-browser-ipc.js"; @@ -116,6 +118,21 @@ export function registerDesktopBrowserIpc( }, ); + ipcMain.on( + BB_DESKTOP_BROWSER_SET_VISIBLE_WITHOUT_FOCUS_CHANNEL, + (event, payload: unknown) => { + const hostWindow = hostWindowFromBrowserIpcEvent(event); + if (hostWindow === null) { + return; + } + const parsed = bbDesktopBrowserSetVisibleRequestSchema.safeParse(payload); + if (!parsed.success) { + return; + } + manager.setVisibleWithoutFocus({ hostWindow, request: parsed.data }); + }, + ); + ipcMain.on( BB_DESKTOP_BROWSER_FIND_IN_PAGE_CHANNEL, (event, payload: unknown) => { @@ -151,6 +168,10 @@ export function registerDesktopBrowserIpc( channel: BB_DESKTOP_BROWSER_DETACH_CHANNEL, run: (args) => manager.detach(args), }); + registerTabCommand({ + channel: BB_DESKTOP_BROWSER_FOCUS_CHANNEL, + run: (args) => manager.focus(args), + }); registerTabCommand({ channel: BB_DESKTOP_BROWSER_GO_BACK_CHANNEL, run: (args) => manager.goBack(args), diff --git a/apps/desktop/src/desktop-browser-policy.ts b/apps/desktop/src/desktop-browser-policy.ts index 0e4d4e540e..46fd0e1cfe 100644 --- a/apps/desktop/src/desktop-browser-policy.ts +++ b/apps/desktop/src/desktop-browser-policy.ts @@ -16,7 +16,7 @@ export function isAllowedBrowserUrl(url: string): boolean { return parsed.protocol === "http:" || parsed.protocol === "https:"; } -export interface WindowOpenDecision { +interface WindowOpenDecision { /** The URL to open as a new in-panel tab, or null to deny entirely. */ openTabUrl: string | null; } @@ -34,12 +34,12 @@ export function resolveWindowOpenAction(url: string): WindowOpenDecision { // --- Popup-tab rate limiting --- -export interface PopupRateDecision { +interface PopupRateDecision { allowed: boolean; timestamps: number[]; } -export interface EvaluatePopupRateArgs { +interface EvaluatePopupRateArgs { timestamps: readonly number[]; now: number; windowMs: number; diff --git a/apps/desktop/src/desktop-browser-view.ts b/apps/desktop/src/desktop-browser-view.ts index 0734e6b749..2e99f28aa2 100644 --- a/apps/desktop/src/desktop-browser-view.ts +++ b/apps/desktop/src/desktop-browser-view.ts @@ -13,6 +13,7 @@ import { type BbDesktopBrowserSetVisibleRequest, type BbDesktopBrowserSnapshot, type BbDesktopBrowserState, + type BbDesktopBrowserTabRef, type BbDesktopBrowserStopFindInPageRequest, type BbDesktopBrowserViewportBounds, type BbDesktopBrowserViewBounds, @@ -21,6 +22,7 @@ import type { AppCommandId, AppShortcutInput } from "@bb/domain"; import { BB_DESKTOP_BROWSER_FIND_RESULT_CHANNEL, BB_DESKTOP_BROWSER_OPEN_TAB_CHANNEL, + BB_DESKTOP_BROWSER_FOCUSED_CHANNEL, BB_DESKTOP_BROWSER_SCOPED_OPEN_TAB_CHANNEL, BB_DESKTOP_BROWSER_SNAPSHOT_CHANNEL, BB_DESKTOP_BROWSER_STATE_CHANNEL, @@ -55,7 +57,7 @@ function truncate(value: string, max: number): string { * Isolated, persistent partition for the in-app browser. Cookies/storage never * touch the bb app session (`defaultSession`) or the user's real browser. */ -export const BB_BROWSER_PARTITION = "persist:bb-browser"; +const BB_BROWSER_PARTITION = "persist:bb-browser"; /** * `did-fail-load` reports aborted main-frame loads (a user navigating away, a @@ -79,6 +81,7 @@ interface BrowserViewEntry { rendererRecoveryAttempts: number; rendererRecoveryState: "healthy" | "pending" | "blocked"; rendererRecoveryTimer: ReturnType<typeof setTimeout> | null; + suppressNextFocusNotification: boolean; visible: boolean; /** * Request id of the latest `findInPage` call, or null when no find session @@ -94,6 +97,7 @@ export type DesktopBrowserHostWebContentsPayload = | BbDesktopBrowserOpenTabRequest | BbDesktopBrowserScopedOpenTabRequest | BbDesktopBrowserSnapshot + | BbDesktopBrowserTabRef | BbDesktopBrowserFindResult; export interface DesktopBrowserHostContentBounds { @@ -119,7 +123,7 @@ export interface DesktopBrowserHostWindow { webContents: DesktopBrowserHostWebContents; } -export interface DispatchDesktopBrowserAppCommandArgs { +interface DispatchDesktopBrowserAppCommandArgs { command: AppCommandId; hostWebContentsId: number; } @@ -160,6 +164,7 @@ interface SetEntryDesiredBoundsArgs { export interface DesktopBrowserViewManager { attach(args: HostScopedRequestArgs<BbDesktopBrowserAttachRequest>): void; detach(args: HostScopedTabArgs): void; + focus(args: HostScopedTabArgs): void; navigate(args: HostScopedRequestArgs<BbDesktopBrowserNavigateRequest>): void; goBack(args: HostScopedTabArgs): void; goForward(args: HostScopedTabArgs): void; @@ -171,6 +176,9 @@ export interface DesktopBrowserViewManager { setVisible( args: HostScopedRequestArgs<BbDesktopBrowserSetVisibleRequest>, ): void; + setVisibleWithoutFocus( + args: HostScopedRequestArgs<BbDesktopBrowserSetVisibleRequest>, + ): void; /** * Find text in a tab's page. Results arrive asynchronously as * `found-in-page` events, relayed to the renderer over @@ -468,6 +476,14 @@ export function createDesktopBrowserViewManager( ): void { const webContents = entry.view.webContents; + webContents.on("focus", () => { + if (entry.suppressNextFocusNotification) { + entry.suppressNextFocusNotification = false; + return; + } + send(hostWindow, BB_DESKTOP_BROWSER_FOCUSED_CHANNEL, { tabId }); + }); + webContents.on("before-input-event", (event, input) => { if (input.type !== "keyDown" || input.isAutoRepeat || input.isComposing) { return; @@ -669,6 +685,7 @@ export function createDesktopBrowserViewManager( rendererRecoveryAttempts: 0, rendererRecoveryState: "healthy", rendererRecoveryTimer: null, + suppressNextFocusNotification: false, visible: false, activeFindRequestId: null, }; @@ -725,6 +742,52 @@ export function createDesktopBrowserViewManager( fn(entry); } + function hasOtherVisibleEntry( + hostWindow: DesktopBrowserHostWindow, + tabId: string, + ): boolean { + const hostPrefix = `${hostWindow.webContents.id}:`; + const currentKey = browserViewKey(hostWindow, tabId); + for (const [key, entry] of entries) { + if (key !== currentKey && key.startsWith(hostPrefix) && entry.visible) { + return true; + } + } + return false; + } + + function focusEntryWithoutNotifying(entry: BrowserViewEntry): void { + entry.suppressNextFocusNotification = true; + entry.view.webContents.focus(); + setTimeout(() => { + entry.suppressNextFocusNotification = false; + }, 0); + } + + function setEntryVisibility( + { + hostWindow, + request, + }: HostScopedRequestArgs<BbDesktopBrowserSetVisibleRequest>, + focusOnShow: boolean, + ): void { + withEntry({ hostWindow, tabId: request.tabId }, (entry) => { + const wasVisible = entry.visible; + entry.visible = request.visible; + applyEntryVisibility(entry, hostWindow); + scheduleEntryRendererRecovery(entry, hostWindow, request.tabId); + if ( + focusOnShow && + request.visible && + !wasVisible && + !hasOtherVisibleEntry(hostWindow, request.tabId) && + !entry.view.webContents.isDestroyed() + ) { + focusEntryWithoutNotifying(entry); + } + }); + } + return { attach({ hostWindow, request }) { const key = browserViewKey(hostWindow, request.tabId); @@ -747,9 +810,10 @@ export function createDesktopBrowserViewManager( if ( request.visible && !wasVisible && + !hasOtherVisibleEntry(hostWindow, request.tabId) && !entry.view.webContents.isDestroyed() ) { - entry.view.webContents.focus(); + focusEntryWithoutNotifying(entry); } loadIfNeeded(entry, request.url); pushState(hostWindow, request.tabId); @@ -757,6 +821,9 @@ export function createDesktopBrowserViewManager( detach({ hostWindow, tabId }) { destroyEntry(hostWindow, browserViewKey(hostWindow, tabId)); }, + focus({ hostWindow, tabId }) { + withEntry({ hostWindow, tabId }, focusEntryWithoutNotifying); + }, navigate({ hostWindow, request }) { withEntry({ hostWindow, tabId: request.tabId }, (entry) => { resetEntryRendererRecovery(entry); @@ -819,23 +886,10 @@ export function createDesktopBrowserViewManager( }); }, setVisible({ hostWindow, request }) { - withEntry({ hostWindow, tabId: request.tabId }, (entry) => { - const wasVisible = entry.visible; - entry.visible = request.visible; - applyEntryVisibility(entry, hostWindow); - scheduleEntryRendererRecovery(entry, hostWindow, request.tabId); - // Focus the view only on a real not-visible → visible transition so the - // Edit-menu copy/cut/paste roles and Cmd+C target this view's - // webContents (the focused one). Skip redundant re-syncs so we never - // yank focus away from the React address bar mid-interaction. - if ( - request.visible && - !wasVisible && - !entry.view.webContents.isDestroyed() - ) { - entry.view.webContents.focus(); - } - }); + setEntryVisibility({ hostWindow, request }, true); + }, + setVisibleWithoutFocus({ hostWindow, request }) { + setEntryVisibility({ hostWindow, request }, false); }, beginWindowResize(hostWindow) { if (isHostResizing(hostWindow)) { diff --git a/apps/desktop/src/desktop-context-menu.ts b/apps/desktop/src/desktop-context-menu.ts index 2abbeaec1a..7422398797 100644 --- a/apps/desktop/src/desktop-context-menu.ts +++ b/apps/desktop/src/desktop-context-menu.ts @@ -1,22 +1,17 @@ import { + clipboard, Menu, type ContextMenuParams, type Event, type MenuItemConstructorOptions, type Session, } from "electron"; -import { - buildBbDesktopSpellcheckLookupScript, - parseBbDesktopSpellcheckCorrectionContext, -} from "./desktop-spellcheck-contract.js"; export interface DesktopContextMenuWebContents { on( eventName: "context-menu", listener: (event: Event, params: ContextMenuParams) => void, ): void; - executeJavaScript(script: string): Promise<unknown>; - insertText(text: string): Promise<void> | void; replaceMisspelling(text: string): void; session: Pick< Session, @@ -24,22 +19,20 @@ export interface DesktopContextMenuWebContents { >; } -export interface DesktopContextMenuSpellcheckContext { +interface DesktopContextMenuSpellcheckContext { dictionarySuggestions: string[]; misspelledWord: string; - replacementMode: "electron-misspelling" | "selected-text"; } -export interface BuildDesktopContextMenuTemplateArgs { +interface BuildDesktopContextMenuTemplateArgs { params: ContextMenuParams; - spellcheckContext?: DesktopContextMenuSpellcheckContext | null; webContents: Pick< DesktopContextMenuWebContents, - "executeJavaScript" | "insertText" | "replaceMisspelling" | "session" + "replaceMisspelling" | "session" >; } -export interface RegisterDesktopContextMenuArgs { +interface RegisterDesktopContextMenuArgs { webContents: DesktopContextMenuWebContents; } @@ -65,83 +58,29 @@ function trimTrailingSeparator( function getSpellcheckContextFromParams( params: ContextMenuParams, ): DesktopContextMenuSpellcheckContext | null { - if ( - !params.isEditable || - !params.spellcheckEnabled || - params.misspelledWord.length === 0 - ) { + if (!params.isEditable || params.misspelledWord.length === 0) { return null; } return { dictionarySuggestions: params.dictionarySuggestions, misspelledWord: params.misspelledWord, - replacementMode: "electron-misspelling", }; } -function selectedSpellcheckWord(params: ContextMenuParams): string | null { - const word = params.selectionText.trim(); - if (word.length === 0 || word.length > 80 || /\s/u.test(word)) { - return null; - } - return word; -} - -export async function resolveDesktopSpellcheckFallback({ - params, - webContents, -}: BuildDesktopContextMenuTemplateArgs): Promise<DesktopContextMenuSpellcheckContext | null> { - if ( - getSpellcheckContextFromParams(params) !== null || - !params.isEditable || - !params.spellcheckEnabled - ) { - return null; - } - const word = selectedSpellcheckWord(params); - if (word === null) { - return null; - } - try { - const context = parseBbDesktopSpellcheckCorrectionContext( - await webContents.executeJavaScript( - buildBbDesktopSpellcheckLookupScript(word), - ), - ); - return context === null - ? null - : { - ...context, - replacementMode: "selected-text", - }; - } catch { - return null; - } -} - export function buildDesktopContextMenuTemplate({ params, - spellcheckContext, webContents, }: BuildDesktopContextMenuTemplateArgs): MenuItemConstructorOptions[] { const template: MenuItemConstructorOptions[] = []; - const resolvedSpellcheckContext = - getSpellcheckContextFromParams(params) ?? spellcheckContext ?? null; + const spellcheckContext = getSpellcheckContextFromParams(params); - if (resolvedSpellcheckContext !== null) { - if (resolvedSpellcheckContext.dictionarySuggestions.length > 0) { - for (const suggestion of resolvedSpellcheckContext.dictionarySuggestions) { + if (spellcheckContext !== null) { + if (spellcheckContext.dictionarySuggestions.length > 0) { + for (const suggestion of spellcheckContext.dictionarySuggestions) { template.push({ label: suggestion, click: () => { - if ( - resolvedSpellcheckContext.replacementMode === - "electron-misspelling" - ) { - webContents.replaceMisspelling(suggestion); - return; - } - void webContents.insertText(suggestion); + webContents.replaceMisspelling(suggestion); }, }); } @@ -152,16 +91,26 @@ export function buildDesktopContextMenuTemplate({ }); } template.push({ - label: `Add "${resolvedSpellcheckContext.misspelledWord}" to Dictionary`, + label: `Add "${spellcheckContext.misspelledWord}" to Dictionary`, click: () => { webContents.session.addWordToSpellCheckerDictionary( - resolvedSpellcheckContext.misspelledWord, + spellcheckContext.misspelledWord, ); }, }); pushSeparatorIfNeeded(template); } + if (params.linkURL.length > 0) { + template.push({ + label: "Copy Link", + click: () => { + clipboard.writeText(params.linkURL); + }, + }); + pushSeparatorIfNeeded(template); + } + if (params.isEditable) { const { editFlags } = params; template.push( @@ -182,26 +131,20 @@ export function buildDesktopContextMenuTemplate({ template.push({ role: "copy", enabled: true }); } if (params.editFlags.canSelectAll) { - pushSeparatorIfNeeded(template); template.push({ role: "selectAll", enabled: true }); } return trimTrailingSeparator(template); } -async function showDesktopContextMenu({ +function showDesktopContextMenu({ params, webContents, }: RegisterDesktopContextMenuArgs & { params: ContextMenuParams; -}): Promise<void> { - const spellcheckContext = await resolveDesktopSpellcheckFallback({ - params, - webContents, - }); +}): void { const template = buildDesktopContextMenuTemplate({ params, - spellcheckContext, webContents, }); if (template.length === 0) { @@ -215,6 +158,6 @@ export function registerDesktopContextMenu({ }: RegisterDesktopContextMenuArgs): void { webContents.session.setSpellCheckerEnabled(true); webContents.on("context-menu", (_event, params) => { - void showDesktopContextMenu({ params, webContents }); + showDesktopContextMenu({ params, webContents }); }); } diff --git a/apps/desktop/src/desktop-reload-shortcut.ts b/apps/desktop/src/desktop-reload-shortcut.ts index 405b6cbfda..3ad822f455 100644 --- a/apps/desktop/src/desktop-reload-shortcut.ts +++ b/apps/desktop/src/desktop-reload-shortcut.ts @@ -1,4 +1,4 @@ -export interface DesktopReloadShortcutInput { +interface DesktopReloadShortcutInput { alt: boolean; control: boolean; isAutoRepeat: boolean; @@ -9,7 +9,7 @@ export interface DesktopReloadShortcutInput { type: string; } -export type DesktopReloadShortcut = "reload" | "force-reload"; +type DesktopReloadShortcut = "reload" | "force-reload"; export function resolveDesktopReloadShortcut( input: DesktopReloadShortcutInput, diff --git a/apps/desktop/src/desktop-session-cache.ts b/apps/desktop/src/desktop-session-cache.ts deleted file mode 100644 index f41219bda3..0000000000 --- a/apps/desktop/src/desktop-session-cache.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface DesktopSessionHttpCache { - clearCache(): Promise<void>; -} - -export interface ClearPackagedSessionHttpCacheArgs { - isPackaged: boolean; - session: DesktopSessionHttpCache; -} - -export async function clearPackagedSessionHttpCache( - args: ClearPackagedSessionHttpCacheArgs, -): Promise<void> { - if (!args.isPackaged) { - return; - } - - await args.session.clearCache(); -} diff --git a/apps/desktop/src/desktop-shell-path.ts b/apps/desktop/src/desktop-shell-path.ts index 71df7a47ba..8546a928fe 100644 --- a/apps/desktop/src/desktop-shell-path.ts +++ b/apps/desktop/src/desktop-shell-path.ts @@ -26,12 +26,12 @@ export type SpawnLoginShellPath = ( args: SpawnLoginShellPathArgs, ) => ShellPathSpawnResult; -export type EnsurePackagedUserShellPathResult = +type EnsurePackagedUserShellPathResult = | ShellPathSkippedResult | ShellPathUpdatedResult | ShellPathUnchangedResult; -export interface EnsurePackagedUserShellPathArgs { +interface EnsurePackagedUserShellPathArgs { env: NodeJS.ProcessEnv; isPackaged: boolean; logger: DesktopShellPathLogger; @@ -39,17 +39,17 @@ export interface EnsurePackagedUserShellPathArgs { spawnLoginShellPath?: SpawnLoginShellPath; } -export interface ShellPathSkippedResult { +interface ShellPathSkippedResult { kind: "skipped"; reason: "not-packaged" | "unsupported-platform"; } -export interface ShellPathUnchangedResult { +interface ShellPathUnchangedResult { kind: "unchanged"; reason: "empty-output" | "non-zero-status" | "shell-error" | "signal"; } -export interface ShellPathUpdatedResult { +interface ShellPathUpdatedResult { kind: "updated"; path: string; } diff --git a/apps/desktop/src/desktop-shutdown.ts b/apps/desktop/src/desktop-shutdown.ts index 07838b8cc4..5bf0e56076 100644 --- a/apps/desktop/src/desktop-shutdown.ts +++ b/apps/desktop/src/desktop-shutdown.ts @@ -1,7 +1,7 @@ export type DesktopShutdownSignal = "SIGINT" | "SIGTERM"; export type DesktopSignalListener = () => void; -export interface DesktopShutdownState { +interface DesktopShutdownState { inProgress: boolean; } @@ -10,7 +10,7 @@ export interface DesktopSignalProcess { on(signal: DesktopShutdownSignal, listener: DesktopSignalListener): void; } -export interface HandleDesktopShutdownSignalArgs { +interface HandleDesktopShutdownSignalArgs { exitProcess(code: number): void; quitApplication(): void; signal: DesktopShutdownSignal; @@ -18,7 +18,7 @@ export interface HandleDesktopShutdownSignalArgs { stopOwnedRuntime(): Promise<void>; } -export interface RegisterDesktopShutdownSignalHandlersArgs { +interface RegisterDesktopShutdownSignalHandlersArgs { exitProcess(code: number): void; processEvents: DesktopSignalProcess; quitApplication(): void; @@ -26,7 +26,7 @@ export interface RegisterDesktopShutdownSignalHandlersArgs { stopOwnedRuntime(): Promise<void>; } -export interface RegisteredDesktopShutdownSignalHandlers { +interface RegisteredDesktopShutdownSignalHandlers { remove(): void; } @@ -38,7 +38,7 @@ export function createDesktopShutdownState(): DesktopShutdownState { return { inProgress: false }; } -export function signalExitCode(args: SignalExitCodeArgs): number { +function signalExitCode(args: SignalExitCodeArgs): number { return args.signal === "SIGINT" ? 130 : 143; } diff --git a/apps/desktop/src/desktop-spellcheck-contract.ts b/apps/desktop/src/desktop-spellcheck-contract.ts deleted file mode 100644 index 56851facd9..0000000000 --- a/apps/desktop/src/desktop-spellcheck-contract.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const BB_DESKTOP_SPELLCHECK_GLOBAL_NAME = "__bbDesktopSpellcheck"; - -export interface BbDesktopSpellcheckCorrectionContext { - dictionarySuggestions: string[]; - misspelledWord: string; -} - -export interface BbDesktopSpellcheckApi { - getCorrectionContext( - word: string, - ): BbDesktopSpellcheckCorrectionContext | null; -} - -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null; -} - -export function parseBbDesktopSpellcheckCorrectionContext( - value: unknown, -): BbDesktopSpellcheckCorrectionContext | null { - if (!isRecord(value)) { - return null; - } - const { dictionarySuggestions, misspelledWord } = value; - if ( - typeof misspelledWord !== "string" || - !Array.isArray(dictionarySuggestions) || - dictionarySuggestions.some((suggestion) => typeof suggestion !== "string") - ) { - return null; - } - return { - dictionarySuggestions, - misspelledWord, - }; -} - -export function buildBbDesktopSpellcheckLookupScript(word: string): string { - return `globalThis[${JSON.stringify(BB_DESKTOP_SPELLCHECK_GLOBAL_NAME)}]?.getCorrectionContext(${JSON.stringify(word)}) ?? null`; -} diff --git a/apps/desktop/src/desktop-system-config.ts b/apps/desktop/src/desktop-system-config.ts index 5c2f714d0f..0a8aa1fea6 100644 --- a/apps/desktop/src/desktop-system-config.ts +++ b/apps/desktop/src/desktop-system-config.ts @@ -23,7 +23,7 @@ const desktopSystemConfigSchema = z.object({ keybindings: z.array(desktopKeybindingSchema).max(256), }); -export interface DesktopSystemConfig { +interface DesktopSystemConfig { keybindings: AppKeybindings; } diff --git a/apps/desktop/src/desktop-update-check.ts b/apps/desktop/src/desktop-update-check.ts index a2788ff98f..e067c9d5c2 100644 --- a/apps/desktop/src/desktop-update-check.ts +++ b/apps/desktop/src/desktop-update-check.ts @@ -14,11 +14,11 @@ export const DESKTOP_UPDATE_ACTIVE_MIN_INTERVAL_MS = 15 * 60 * 1000; type DesktopUpdateIntervalHandle = ReturnType<typeof setInterval>; -export interface DesktopUpdateLogger { +interface DesktopUpdateLogger { warn(message: string): void; } -export interface ParseDesktopVersionFeedArgs { +interface ParseDesktopVersionFeedArgs { channel: BbDesktopVersionFeed["channel"]; checkedAt: string; currentVersion: string; @@ -37,11 +37,11 @@ interface MalformedDesktopVersionFeedParseResult { reason: string; } -export type DesktopVersionFeedParseResult = +type DesktopVersionFeedParseResult = | MalformedDesktopVersionFeedParseResult | ValidDesktopVersionFeedParseResult; -export interface CreateDesktopUpdateServiceArgs { +interface CreateDesktopUpdateServiceArgs { channel: BbDesktopVersionFeed["channel"]; currentVersion: string; enabled: boolean; diff --git a/apps/desktop/src/desktop-update-info.ts b/apps/desktop/src/desktop-update-info.ts index 1744836e94..ac8a97a879 100644 --- a/apps/desktop/src/desktop-update-info.ts +++ b/apps/desktop/src/desktop-update-info.ts @@ -1,6 +1,6 @@ import type { BbDesktopInfo } from "@bb/desktop-contract"; -export interface MergeDesktopUpdateInfoArgs { +interface MergeDesktopUpdateInfoArgs { autoInfo: BbDesktopInfo | null; feedInfo: BbDesktopInfo | null; } diff --git a/apps/desktop/src/desktop-update-provider.ts b/apps/desktop/src/desktop-update-provider.ts index 90ab0f3c36..d802b4751a 100644 --- a/apps/desktop/src/desktop-update-provider.ts +++ b/apps/desktop/src/desktop-update-provider.ts @@ -3,9 +3,9 @@ import { type BbDesktopVersionFeedPlatform, } from "@bb/desktop-contract"; -export type DesktopReleaseChannel = "latest" | "nightly"; +type DesktopReleaseChannel = "latest" | "nightly"; -export interface DesktopReleaseInfo { +interface DesktopReleaseInfo { applicationName: "bb" | "bb Nightly"; channel: DesktopReleaseChannel; iconFileName: "icon.png" | "icon-nightly.png"; @@ -49,9 +49,8 @@ export const DESKTOP_RELEASE_CHANNEL = resolveBuiltDesktopReleaseChannel( export const DESKTOP_RELEASE_INFO = createDesktopReleaseInfo( DESKTOP_RELEASE_CHANNEL, ); -export const DESKTOP_UPDATE_RELEASE_BASE_URL = +const DESKTOP_UPDATE_RELEASE_BASE_URL = DESKTOP_RELEASE_INFO.updateReleaseBaseUrl; -export const DESKTOP_UPDATE_CHANNEL = DESKTOP_RELEASE_CHANNEL; export function createDesktopUpdateFeedUrl( platform: BbDesktopVersionFeedPlatform, @@ -66,12 +65,12 @@ export interface DesktopAutoUpdateFeedConfig { } export const DESKTOP_AUTO_UPDATE_FEED_CONFIG: DesktopAutoUpdateFeedConfig = { - channel: DESKTOP_UPDATE_CHANNEL, + channel: DESKTOP_RELEASE_CHANNEL, provider: "generic", url: DESKTOP_UPDATE_RELEASE_BASE_URL, }; -export interface DesktopUpdateSupport { +interface DesktopUpdateSupport { /** * electron-updater can download a replacement build and install it. Linux * only qualifies inside an AppImage, which is the one Linux target that can @@ -82,7 +81,7 @@ export interface DesktopUpdateSupport { versionCheck: boolean; } -export interface ResolveDesktopUpdateSupportArgs { +interface ResolveDesktopUpdateSupportArgs { /** * Whether the AppImage at this path can actually be replaced in place. * Injected so the decision stays testable without touching a real file. diff --git a/apps/desktop/src/desktop-window-factory.ts b/apps/desktop/src/desktop-window-factory.ts index 300b5409be..7eb7e83ee1 100644 --- a/apps/desktop/src/desktop-window-factory.ts +++ b/apps/desktop/src/desktop-window-factory.ts @@ -17,7 +17,7 @@ import { } from "./window-state.js"; import type { DesktopContextMenuWebContents } from "./desktop-context-menu.js"; -export type DesktopWindowIcon = BrowserWindowConstructorOptions["icon"]; +type DesktopWindowIcon = BrowserWindowConstructorOptions["icon"]; // Inset the macOS traffic lights an equal distance from the window's top and // left edges so they sit on a 45° diagonal from the top-left corner. The shared @@ -32,11 +32,11 @@ const MACOS_TRAFFIC_LIGHT_POSITION = { y: MACOS_TRAFFIC_LIGHT_DIAGONAL_INSET, }; -export interface DesktopWindowOpenDetails { +interface DesktopWindowOpenDetails { url: string; } -export interface DesktopWindowOpenHandlerResult { +interface DesktopWindowOpenHandlerResult { action: "deny"; } @@ -78,11 +78,11 @@ export interface DesktopBrowserWindowCreator { create(options: BrowserWindowConstructorOptions): DesktopBrowserWindow; } -export interface OpenExternalUrlArgs { +interface OpenExternalUrlArgs { url: string; } -export interface CreateDesktopWindowFactoryArgs { +interface CreateDesktopWindowFactoryArgs { browserWindowCreator: DesktopBrowserWindowCreator; createWindowStateKey(): WindowStateKey; displayWorkAreas: DisplayWorkArea[] | null; @@ -94,16 +94,16 @@ export interface CreateDesktopWindowFactoryArgs { userDataPath: string; } -export interface CreateDesktopWindowArgs { +interface CreateDesktopWindowArgs { initialUrl: string | null; stateKey: WindowStateKey | null; } -export interface RestoreDesktopWindowsArgs { +interface RestoreDesktopWindowsArgs { initialUrl: string | null; } -export interface LoadDesktopWindowsUrlArgs { +interface LoadDesktopWindowsUrlArgs { url: string; } diff --git a/apps/desktop/src/existing-server-dialog.ts b/apps/desktop/src/existing-server-dialog.ts index bc6d95dbb8..e983a19244 100644 --- a/apps/desktop/src/existing-server-dialog.ts +++ b/apps/desktop/src/existing-server-dialog.ts @@ -6,9 +6,9 @@ import { } from "./existing-server-dialog-ipc.js"; import type { ForeignRuntimeDetails } from "./foreign-runtime.js"; -export type ExistingServerDialogChoice = "connect" | "quit" | "replace"; +type ExistingServerDialogChoice = "connect" | "quit" | "replace"; -export interface OpenExistingServerDialogArgs { +interface OpenExistingServerDialogArgs { /** Null when the running bb is too old to describe itself. */ details: ForeignRuntimeDetails | null; parentWindow: BrowserWindow | null; @@ -69,7 +69,7 @@ function buildDetailRows(args: { return rows; } -export interface RenderExistingServerDialogHtmlArgs { +interface RenderExistingServerDialogHtmlArgs { details: ForeignRuntimeDetails | null; now: Date; serverUrl: string; diff --git a/apps/desktop/src/foreign-runtime.ts b/apps/desktop/src/foreign-runtime.ts index eab3c8261a..f5199cfad3 100644 --- a/apps/desktop/src/foreign-runtime.ts +++ b/apps/desktop/src/foreign-runtime.ts @@ -25,12 +25,12 @@ export interface ForeignRuntimeDetails { version: string; } -export interface ReadForeignRuntimeDetailsArgs { +interface ReadForeignRuntimeDetailsArgs { dataDir: string | null; serverUrl: string; } -export interface StopForeignRuntimeArgs { +interface StopForeignRuntimeArgs { /** The record the person saw and approved, not a fresh read. */ details: ForeignRuntimeDetails; killTimeoutMs: number; @@ -38,7 +38,7 @@ export interface StopForeignRuntimeArgs { timeoutMs: number; } -export type StopForeignRuntimeResult = +type StopForeignRuntimeResult = | { kind: "not-running" } | { kind: "replaced" } | { kind: "still-running"; pid: number } diff --git a/apps/desktop/src/local-view.ts b/apps/desktop/src/local-view.ts index 1937c575f8..f1f293ada1 100644 --- a/apps/desktop/src/local-view.ts +++ b/apps/desktop/src/local-view.ts @@ -6,26 +6,26 @@ export type LocalViewModel = | LoadingViewModel | StartupErrorViewModel; -export interface LoadingViewModel { +interface LoadingViewModel { kind: "loading"; message: string; title: string; } -export interface InfoViewModel { +interface InfoViewModel { kind: "info"; message: string; title: string; } -export interface StartupErrorViewModel { +interface StartupErrorViewModel { details: string; kind: "error"; logText: string; title: string; } -export interface CreateLocalViewUrlArgs { +interface CreateLocalViewUrlArgs { viewModel: LocalViewModel; } diff --git a/apps/desktop/src/log-viewer-contract.ts b/apps/desktop/src/log-viewer-contract.ts index 2747fa65ee..0f96ccff58 100644 --- a/apps/desktop/src/log-viewer-contract.ts +++ b/apps/desktop/src/log-viewer-contract.ts @@ -6,7 +6,7 @@ export const LOG_VIEWER_SNAPSHOT_CHANNEL = "bb:log-viewer:snapshot"; export const LOG_VIEWER_VISIBLE_LINE_LIMIT = 10_000; export type LogViewerComponent = "host-daemon" | "server"; -export type LogViewerLineSource = LogViewerComponent | "system"; +type LogViewerLineSource = LogViewerComponent | "system"; export interface LogViewerLine { source: LogViewerLineSource; diff --git a/apps/desktop/src/log-viewer.ts b/apps/desktop/src/log-viewer.ts index c495f6352b..b0d0f290f9 100644 --- a/apps/desktop/src/log-viewer.ts +++ b/apps/desktop/src/log-viewer.ts @@ -16,16 +16,16 @@ const LOG_VIEWER_INITIAL_TAIL_LINES = 400; const LOG_VIEWER_ROTATION_POLL_INTERVAL_MS = 2_000; const LOG_VIEWER_COMPONENTS: LogViewerComponent[] = ["server", "host-daemon"]; -export interface CreateLogViewerViewUrlArgs { +interface CreateLogViewerViewUrlArgs { logDir: string; } -export interface ResolveCurrentLogFileArgs { +interface ResolveCurrentLogFileArgs { component: LogViewerComponent; logDir: string; } -export interface CreateLogTailerArgs { +interface CreateLogTailerArgs { logDir: string; onLines(lines: LogViewerLine[]): void; } @@ -36,7 +36,7 @@ export interface LogTailer { stop(): void; } -export interface CreateLogLineBufferArgs { +interface CreateLogLineBufferArgs { flushIntervalMs: number; flushLineCount: number; maxLines: number; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ff5b6b186e..018cd3fe88 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,11 +1,12 @@ import { randomUUID } from "node:crypto"; import { accessSync, constants as fsConstants } from "node:fs"; -import { homedir } from "node:os"; +import { arch, homedir, release, type as osType } from "node:os"; import { dirname, join, resolve } from "node:path"; import { app, BrowserWindow, clipboard, + dialog, ipcMain, nativeImage, nativeTheme, @@ -68,6 +69,7 @@ import { type CompatibleServerProbeResult, type ServerProbeResult, } from "./server-probe.js"; +import { loadRemoteServerPage } from "./remote-server-load.js"; import { BUILTIN_SERVER_NAME, createServerTargetStore, @@ -80,6 +82,7 @@ import { createConnectServerSync, type ConnectAccountServer, type ConnectServerSync, + type ConnectServerSyncSkipReason, } from "./connect-server-sync.js"; import { createCredentialCookieSource, @@ -106,6 +109,11 @@ import { type DesktopBrowserWindowCreator, type DesktopWindowFactory, } from "./desktop-window-factory.js"; +import { + createDesktopAboutDialogOptions, + createDesktopAboutPanelOptions, + type DesktopAboutFacts, +} from "./desktop-about-panel.js"; import { registerDesktopContextMenu } from "./desktop-context-menu.js"; import { resolveBbDesktopPlatform } from "./desktop-platform.js"; import { @@ -114,8 +122,8 @@ import { type DesktopUpdateService, } from "./desktop-update-check.js"; import { + DESKTOP_RELEASE_CHANNEL, DESKTOP_RELEASE_INFO, - DESKTOP_UPDATE_CHANNEL, resolveDesktopUpdateSupport, } from "./desktop-update-provider.js"; import { @@ -151,7 +159,6 @@ import { resolveDesktopBrowserAppCommand } from "./desktop-browser-shortcuts.js" import { registerDesktopBrowserIpc } from "./desktop-browser-main-ipc.js"; import { parseDesktopSystemConfig } from "./desktop-system-config.js"; import { ensurePackagedUserShellPath } from "./desktop-shell-path.js"; -import { clearPackagedSessionHttpCache } from "./desktop-session-cache.js"; import { resolveDesktopReloadShortcut } from "./desktop-reload-shortcut.js"; import { createLogTailer, @@ -320,6 +327,8 @@ let enrollingDesktopMachine: Promise<void> | null = null; let connectSessionRenewal: ConnectSessionRenewal | null = null; let serverTargetGeneration = 0; let connectAccountServers: ConnectAccountServer[] = []; +/** Why the last Connect sync listed nothing; null after a successful sync. */ +let connectServerSyncSkipReason: ConnectServerSyncSkipReason | null = null; let builtinServerUrl: string = DEFAULT_BB_SERVER_URL; let desktopBridgePath: string | null = null; let desktopUserDataPath: string | null = null; @@ -375,7 +384,7 @@ function canReplaceAppImage(appImagePath: string): boolean { try { accessSync( dirname(appImagePath), - // eslint-disable-next-line no-bitwise + // oxlint-disable-next-line no-bitwise fsConstants.W_OK | fsConstants.X_OK, ); return true; @@ -401,6 +410,48 @@ function getDesktopVersion(version: string | undefined): string { return version; } +function readDesktopAboutFacts(applicationName: string): DesktopAboutFacts { + return { + applicationName, + buildDate: process.env.BB_DESKTOP_BUILD_DATE ?? "", + channel: DESKTOP_RELEASE_CHANNEL, + commit: process.env.BB_DESKTOP_COMMIT ?? "", + electronVersion: process.versions.electron, + osArch: arch(), + osRelease: release(), + osType: osType(), + platform: process.platform, + pluginSdkVersion: process.env.BB_DESKTOP_PLUGIN_SDK_VERSION ?? "", + version: getDesktopVersion(process.env.BB_DESKTOP_VERSION), + }; +} + +function installAboutPanel(applicationName: string): void { + app.setAboutPanelOptions( + createDesktopAboutPanelOptions(readDesktopAboutFacts(applicationName)), + ); +} + +/** + * The About dialog is read at click time, not at launch, so a session left open + * for days still reports the build's real age. + */ +async function showAboutDialog(): Promise<void> { + const { copyButtonId, ...messageBoxOptions } = + createDesktopAboutDialogOptions( + readDesktopAboutFacts(app.getName()), + Date.now(), + ); + const parentWindow = getFocusedApplicationWindow(); + const result = + parentWindow === null + ? await dialog.showMessageBox(messageBoxOptions) + : await dialog.showMessageBox(parentWindow, messageBoxOptions); + if (result.response === copyButtonId) { + clipboard.writeText(messageBoxOptions.detail); + } +} + function getCurrentDesktopInfo(): BbDesktopInfo | null { return mergeDesktopUpdateInfo({ autoInfo: desktopAutoUpdateService?.getInfo() ?? null, @@ -639,7 +690,7 @@ function listMenuConnectServers(): ConnectServerRef[] { return servers; } -function buildMenuServerItems(): Array<{ +function buildMenuServerItems(connectServers: ConnectServerRef[]): Array<{ checked: boolean; id: string; name: string; @@ -652,7 +703,7 @@ function buildMenuServerItems(): Array<{ name: BUILTIN_SERVER_NAME, }, ]; - for (const server of listMenuConnectServers()) { + for (const server of connectServers) { items.push({ checked: target.kind === "connect" && target.server.handle === server.handle, @@ -672,8 +723,13 @@ function buildMenuServerItems(): Array<{ } function installCurrentApplicationMenu(): void { + const connectServers = listMenuConnectServers(); installApplicationMenu({ accelerators: currentApplicationMenuAccelerators, + // Only explain an empty Connect list; a persisted selection that is still + // listed needs no note beneath it. + connectServersSkipReason: + connectServers.length === 0 ? connectServerSyncSkipReason : null, isMac: process.platform === "darwin", createNewWindow() { void createApplicationWindow({ @@ -681,6 +737,9 @@ function installCurrentApplicationMenu(): void { stateKey: null, }); }, + openAbout() { + void showAboutDialog(); + }, openNewTab() { const browserWindow = getFocusedApplicationWindow(); if (browserWindow !== null) { @@ -760,7 +819,7 @@ function installCurrentApplicationMenu(): void { connectServerSync?.onListRequested(); }, serverDaemonLogsMenuEnabled: shouldEnableServerDaemonLogsMenu(), - servers: buildMenuServerItems(), + servers: buildMenuServerItems(connectServers), }); } @@ -1217,24 +1276,51 @@ async function applyServerTarget(): Promise<void> { expiresAt: result.expiresAt, remoteServerUrl: target.server.url, }); - bbAppLoaded = true; - await loadWindowUrl({ url: target.server.url }); + const loaded = await loadRemoteServerTarget(target.server.url, isCurrent); if (!isCurrent()) { return; } - startRemoteSystemConfigSync(target.server.url); + if (!loaded) { + // No session to keep alive for a server that is not on screen. + connectSessionRenewal?.stop(); + } } else { // A custom server is a plain web load with no bb Connect involved. - bbAppLoaded = true; - await loadWindowUrl({ url: target.url }); + await loadRemoteServerTarget(target.url, isCurrent); if (!isCurrent()) { return; } - startRemoteSystemConfigSync(target.url); } refreshApplicationMenu(); } +/** + * Load a connect or custom server's page. An unreachable host renders the + * startup error view instead of rejecting, so the app never lands on the + * crash screen or a blank window. `bbAppLoaded` flips only once the page is + * really up. Resolves to whether the page loaded. + */ +async function loadRemoteServerTarget( + serverUrl: string, + isCurrent: () => boolean, +): Promise<boolean> { + const loaded = await loadRemoteServerPage({ + isCurrent, + loadStartupError, + loadUrl: loadWindowUrl, + logWarning: (message) => { + createDesktopLogger().warn(message); + }, + serverUrl, + }); + if (!loaded || !isCurrent()) { + return loaded; + } + bbAppLoaded = true; + startRemoteSystemConfigSync(serverUrl); + return true; +} + async function setActiveServerTarget(serverId: string): Promise<void> { if (serverTargetStore === null) { return; @@ -1978,7 +2064,11 @@ async function runDesktopApp(): Promise<void> { platform: process.platform, }); - app.setName(app.isPackaged ? DESKTOP_RELEASE_INFO.applicationName : "bb-dev"); + const applicationName = app.isPackaged + ? DESKTOP_RELEASE_INFO.applicationName + : "bb-dev"; + app.setName(applicationName); + installAboutPanel(applicationName); if (!app.requestSingleInstanceLock()) { app.quit(); @@ -2040,10 +2130,9 @@ async function runDesktopApp(): Promise<void> { }); await app.whenReady(); - await clearPackagedSessionHttpCache({ - isPackaged: app.isPackaged, - session: session.defaultSession, - }); + if (app.isPackaged) { + await session.defaultSession.clearCache(); + } const paths = createDesktopPathContext(); const iconPath = resolveDesktopIconPath({ @@ -2129,8 +2218,14 @@ async function runDesktopApp(): Promise<void> { onUnauthorized() { void clearCachedConnectCredential(); }, + onSkipped(reason) { + connectServerSyncSkipReason = reason; + // Electron menus are immutable once built: rebuild so the reason shows. + refreshApplicationMenu(); + }, onServers(servers) { connectAccountServers = servers; + connectServerSyncSkipReason = null; const selected = serverTargetStore?.getConnectServer() ?? null; const synced = servers.find( (server) => server.handle === selected?.handle, @@ -2170,7 +2265,7 @@ async function runDesktopApp(): Promise<void> { platform: desktopPlatform, }); desktopUpdateService = createDesktopUpdateService({ - channel: DESKTOP_UPDATE_CHANNEL, + channel: DESKTOP_RELEASE_CHANNEL, currentVersion: desktopVersion, enabled: desktopUpdateSupport.versionCheck && diff --git a/apps/desktop/src/menu.ts b/apps/desktop/src/menu.ts index 51bae62c54..af67ae59bb 100644 --- a/apps/desktop/src/menu.ts +++ b/apps/desktop/src/menu.ts @@ -5,22 +5,37 @@ import { type MenuItemConstructorOptions, } from "electron"; import type { ApplicationMenuAccelerators } from "./desktop-menu-shortcuts.js"; +import type { ConnectServerSyncSkipReason } from "./connect-server-sync.js"; -export const SERVER_DAEMON_LOGS_MENU_LABEL = "Server & Daemon Logs"; -export const OPEN_NEW_TAB_MENU_LABEL = "New Tab"; -export const NEW_THREAD_MENU_LABEL = "New Thread"; -export const NEW_WINDOW_MENU_LABEL = "New Window"; -export const CLOSE_WINDOW_MENU_LABEL = "Close Window"; -export const OPEN_SETTINGS_MENU_LABEL = "Settings…"; -export const TOGGLE_DEVELOPER_TOOLS_MENU_LABEL = "Toggle Developer Tools"; -export const TOGGLE_DEVELOPER_TOOLS_ACCELERATOR = "Command+Option+I"; -export const RELOAD_ACCELERATOR = "CommandOrControl+R"; -export const FORCE_RELOAD_ACCELERATOR = "CommandOrControl+Shift+R"; -export const SERVER_MENU_LABEL = "Server"; -export const SERVER_MENU_ITEM_ID = "bb-server-menu"; +const SERVER_DAEMON_LOGS_MENU_LABEL = "Server & Daemon Logs"; +const OPEN_NEW_TAB_MENU_LABEL = "New Tab"; +const NEW_THREAD_MENU_LABEL = "New Thread"; +const NEW_WINDOW_MENU_LABEL = "New Window"; +const CLOSE_WINDOW_MENU_LABEL = "Close Window"; +const OPEN_SETTINGS_MENU_LABEL = "Settings…"; +const TOGGLE_DEVELOPER_TOOLS_MENU_LABEL = "Toggle Developer Tools"; +const TOGGLE_DEVELOPER_TOOLS_ACCELERATOR = "Command+Option+I"; +const RELOAD_ACCELERATOR = "CommandOrControl+R"; +const FORCE_RELOAD_ACCELERATOR = "CommandOrControl+Shift+R"; +const SERVER_MENU_LABEL = "Server"; +const SERVER_MENU_ITEM_ID = "bb-server-menu"; export const SET_SERVER_URL_MENU_LABEL = "Set Server URL…"; +/** + * Disabled row shown in place of the Connect server list when the last sync + * produced none, so an empty list is not mistaken for an empty account. + */ +export const CONNECT_SERVERS_SKIPPED_MENU_LABELS: Record< + ConnectServerSyncSkipReason, + string +> = { + "no-credential": "No Connect servers — sign in to bb Connect", + "not-paired": "No Connect servers — Connect not paired on This Mac", + "plugin-disabled": "No Connect servers — Connect plugin disabled", + unauthorized: "No Connect servers — sign in to bb Connect again", + unavailable: "No Connect servers — could not reach bb Connect", +}; -export interface ApplicationMenuServerItem { +interface ApplicationMenuServerItem { checked: boolean; id: string; name: string; @@ -29,6 +44,7 @@ export interface ApplicationMenuServerItem { export interface InstallApplicationMenuArgs { accelerators: ApplicationMenuAccelerators; isMac: boolean; + openAbout(): void; openNewTab(): void; openNewThread(): void; openSettings(): void; @@ -45,6 +61,11 @@ export interface InstallApplicationMenuArgs { onServerMenuWillShow?: () => void; serverDaemonLogsMenuEnabled: boolean; servers: ApplicationMenuServerItem[]; + /** + * Why `servers` lists no Connect servers, or null when it does (or when + * the account really has none). + */ + connectServersSkipReason: ConnectServerSyncSkipReason | null; } function createServerDaemonLogsMenuItems( @@ -75,8 +96,17 @@ function createServerMenuItems( type: "radio" as const, }), ); + const skipReason = args.connectServersSkipReason; return [ ...serverItems, + ...(skipReason === null + ? [] + : [ + { + enabled: false, + label: CONNECT_SERVERS_SKIPPED_MENU_LABELS[skipReason], + }, + ]), { type: "separator" }, { label: SET_SERVER_URL_MENU_LABEL, @@ -94,7 +124,12 @@ export function buildApplicationMenuTemplate( { label: app.name, submenu: [ - { role: "about" }, + { + label: `About ${app.name}`, + click() { + args.openAbout(); + }, + }, { type: "separator" }, { accelerator: args.accelerators.openSettings, @@ -218,10 +253,7 @@ export function buildApplicationMenuTemplate( submenu: createServerMenuItems(args), }, ...(args.isMac - ? [ - { type: "separator" as const }, - { role: "front" as const }, - ] + ? [{ type: "separator" as const }, { role: "front" as const }] : []), ], }, diff --git a/apps/desktop/src/owned-runtime-supervisor.ts b/apps/desktop/src/owned-runtime-supervisor.ts index cdd5b743d3..d6b360d55b 100644 --- a/apps/desktop/src/owned-runtime-supervisor.ts +++ b/apps/desktop/src/owned-runtime-supervisor.ts @@ -18,36 +18,36 @@ const ownedRuntimePidFileSchema = z.object({ startedAt: z.string().min(1), }); -export type ReapStaleOwnedRuntimeResult = +type ReapStaleOwnedRuntimeResult = | ClearedStaleOwnedRuntimePidFileResult | FailedToStopOwnedRuntimeResult | NoStaleOwnedRuntimePidFileResult | ReapedStaleOwnedRuntimeResult | SkippedStaleOwnedRuntimeResult; -export interface OwnedRuntimePidFile { +interface OwnedRuntimePidFile { bridgePath: string; pid: number; serverUrl: string; startedAt: string; } -export interface WriteOwnedRuntimePidFileArgs { +interface WriteOwnedRuntimePidFileArgs { bridgePath: string; pid: number; serverUrl: string; userDataPath: string; } -export interface ClearOwnedRuntimePidFileArgs { +interface ClearOwnedRuntimePidFileArgs { userDataPath: string; } -export interface ReadOwnedRuntimePidFileArgs { +interface ReadOwnedRuntimePidFileArgs { userDataPath: string; } -export interface ReapStaleOwnedRuntimeArgs { +interface ReapStaleOwnedRuntimeArgs { processOps?: OwnedRuntimeProcessOps; signal: NodeJS.Signals; timeoutMs: number; @@ -57,26 +57,26 @@ export interface ReapStaleOwnedRuntimeArgs { export type OwnedRuntimeProcessOps = VerifiedProcessOps; export type { WaitForProcessExitArgs }; -export interface NoStaleOwnedRuntimePidFileResult { +interface NoStaleOwnedRuntimePidFileResult { kind: "no-pid-file"; } -export interface ClearedStaleOwnedRuntimePidFileResult { +interface ClearedStaleOwnedRuntimePidFileResult { kind: "cleared-stale-pid-file"; pid: number; } -export interface ReapedStaleOwnedRuntimeResult { +interface ReapedStaleOwnedRuntimeResult { kind: "reaped"; pid: number; } -export interface FailedToStopOwnedRuntimeResult { +interface FailedToStopOwnedRuntimeResult { kind: "failed-to-stop"; pid: number; } -export interface SkippedStaleOwnedRuntimeResult { +interface SkippedStaleOwnedRuntimeResult { command: string | null; kind: "skipped-unverified-process"; pid: number; @@ -86,7 +86,7 @@ function ownedRuntimePidFilePath(userDataPath: string): string { return join(userDataPath, OWNED_RUNTIME_PID_FILE_NAME); } -export function createNodeOwnedRuntimeProcessOps(): OwnedRuntimeProcessOps { +function createNodeOwnedRuntimeProcessOps(): OwnedRuntimeProcessOps { return createNodeVerifiedProcessOps(); } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2de07e89d5..837b487e1c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,6 +4,7 @@ import { bbDesktopBrowserFindResultSchema, bbDesktopBrowserOpenTabRequestSchema, bbDesktopBrowserScopedOpenTabRequestSchema, + bbDesktopBrowserTabRefSchema, bbDesktopBrowserSnapshotSchema, bbDesktopBrowserStateSchema, bbDesktopInfoSchema, @@ -14,6 +15,7 @@ import { type BbDesktopBrowserFindResultHandler, type BbDesktopBrowserOpenTabHandler, type BbDesktopBrowserScopedOpenTabHandler, + type BbDesktopBrowserFocusHandler, type BbDesktopBrowserSnapshotHandler, type BbDesktopBrowserStateHandler, type BbDesktopBrowserUnsubscribe, @@ -38,6 +40,8 @@ import { import { BB_DESKTOP_BROWSER_ATTACH_CHANNEL, BB_DESKTOP_BROWSER_DETACH_CHANNEL, + BB_DESKTOP_BROWSER_FOCUS_CHANNEL, + BB_DESKTOP_BROWSER_FOCUSED_CHANNEL, BB_DESKTOP_BROWSER_FIND_IN_PAGE_CHANNEL, BB_DESKTOP_BROWSER_FIND_RESULT_CHANNEL, BB_DESKTOP_BROWSER_GO_BACK_CHANNEL, @@ -48,6 +52,7 @@ import { BB_DESKTOP_BROWSER_SCOPED_OPEN_TAB_CHANNEL, BB_DESKTOP_BROWSER_SET_BOUNDS_CHANNEL, BB_DESKTOP_BROWSER_SET_VISIBLE_CHANNEL, + BB_DESKTOP_BROWSER_SET_VISIBLE_WITHOUT_FOCUS_CHANNEL, BB_DESKTOP_BROWSER_SNAPSHOT_CHANNEL, BB_DESKTOP_BROWSER_STATE_CHANNEL, BB_DESKTOP_BROWSER_STOP_CHANNEL, @@ -61,10 +66,6 @@ import { BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, BB_DESKTOP_WINDOW_STATE_CHANGED_CHANNEL, } from "./desktop-window-command-ipc.js"; -import { - BB_DESKTOP_SPELLCHECK_GLOBAL_NAME, - type BbDesktopSpellcheckApi, -} from "./desktop-spellcheck-contract.js"; import { resolveBbDesktopPlatform } from "./desktop-platform.js"; function getDesktopVersion(version: string | undefined): string { @@ -165,38 +166,13 @@ const browserStateListeners = new Set<BbDesktopBrowserStateHandler>(); const browserOpenTabListeners = new Set<BbDesktopBrowserOpenTabHandler>(); const browserScopedOpenTabListeners = new Set<BbDesktopBrowserScopedOpenTabHandler>(); +const browserFocusListeners = new Set<BbDesktopBrowserFocusHandler>(); const browserSnapshotListeners = new Set<BbDesktopBrowserSnapshotHandler>(); -const browserFindResultListeners = - new Set<BbDesktopBrowserFindResultHandler>(); +const browserFindResultListeners = new Set<BbDesktopBrowserFindResultHandler>(); const closeWindowRequestListeners = new Set<BbDesktopCloseWindowRequestHandler>(); const openNewTabListeners = new Set<BbDesktopOpenNewTabHandler>(); -function normalizeSpellcheckWord(word: string): string | null { - const normalized = word.trim(); - if ( - normalized.length === 0 || - normalized.length > 80 || - /\s/u.test(normalized) - ) { - return null; - } - return normalized; -} - -const bbSpellcheckApi: BbDesktopSpellcheckApi = { - getCorrectionContext(word) { - const normalized = normalizeSpellcheckWord(word); - if (normalized === null || !webFrame.isWordMisspelled(normalized)) { - return null; - } - return { - dictionarySuggestions: webFrame.getWordSuggestions(normalized), - misspelledWord: normalized, - }; - }, -}; - function browserViewBoundsAtWindowScale( bounds: BbDesktopBrowserViewBounds, ): BbDesktopBrowserViewBounds { @@ -242,6 +218,9 @@ const bbBrowserApi: BbDesktopBrowserApi = { stop(tabId): void { ipcRenderer.send(BB_DESKTOP_BROWSER_STOP_CHANNEL, { tabId }); }, + focus(tabId): void { + ipcRenderer.send(BB_DESKTOP_BROWSER_FOCUS_CHANNEL, { tabId }); + }, setBounds(request): void { ipcRenderer.send(BB_DESKTOP_BROWSER_SET_BOUNDS_CHANNEL, { ...request, @@ -251,6 +230,12 @@ const bbBrowserApi: BbDesktopBrowserApi = { setVisible(request): void { ipcRenderer.send(BB_DESKTOP_BROWSER_SET_VISIBLE_CHANNEL, request); }, + setVisibleWithoutFocus(request): void { + ipcRenderer.send( + BB_DESKTOP_BROWSER_SET_VISIBLE_WITHOUT_FOCUS_CHANNEL, + request, + ); + }, onState(listener): BbDesktopBrowserUnsubscribe { browserStateListeners.add(listener); return () => { @@ -269,6 +254,12 @@ const bbBrowserApi: BbDesktopBrowserApi = { browserScopedOpenTabListeners.delete(listener); }; }, + onFocus(listener): BbDesktopBrowserUnsubscribe { + browserFocusListeners.add(listener); + return () => { + browserFocusListeners.delete(listener); + }; + }, onSnapshot(listener): BbDesktopBrowserUnsubscribe { browserSnapshotListeners.add(listener); return () => { @@ -405,6 +396,19 @@ ipcRenderer.on(BB_DESKTOP_BROWSER_STATE_CHANNEL, (_event, payload: unknown) => { } }); +ipcRenderer.on( + BB_DESKTOP_BROWSER_FOCUSED_CHANNEL, + (_event, payload: unknown) => { + const parsed = bbDesktopBrowserTabRefSchema.safeParse(payload); + if (!parsed.success) { + return; + } + for (const listener of browserFocusListeners) { + listener(parsed.data.tabId); + } + }, +); + ipcRenderer.on( BB_DESKTOP_BROWSER_OPEN_TAB_CHANNEL, (_event, payload: unknown) => { @@ -461,8 +465,4 @@ ipcRenderer.on( void invokeDesktopInfo(BB_DESKTOP_GET_INFO_CHANNEL); void invokeDesktopWindowState(); -contextBridge.exposeInMainWorld( - BB_DESKTOP_SPELLCHECK_GLOBAL_NAME, - bbSpellcheckApi, -); contextBridge.exposeInMainWorld("bbDesktop", bbDesktopApi); diff --git a/apps/desktop/src/remote-server-load.ts b/apps/desktop/src/remote-server-load.ts new file mode 100644 index 0000000000..3998dd0e15 --- /dev/null +++ b/apps/desktop/src/remote-server-load.ts @@ -0,0 +1,88 @@ +import { BUILTIN_SERVER_NAME } from "./server-target.js"; + +const ELECTRON_LOAD_ERROR_CODE = /\bERR_[A-Z_]+ \(-?\d+\)/u; + +interface RemoteServerStartupError { + details: string; + logs: string; + title: string; +} + +export interface LoadRemoteServerPageArgs { + /** Whether this target is still the one the user wants (generation check). */ + isCurrent(): boolean; + /** Shows the shared startup error screen. */ + loadStartupError(args: RemoteServerStartupError): Promise<void>; + /** Loads a page into the application windows. */ + loadUrl(args: { url: string }): Promise<void>; + logWarning(message: string): void; + serverUrl: string; +} + +/** + * Name a saved target without repeating anything secret. + * + * `normalizeCustomServerUrl()` keeps user information and the query string, so + * a saved target can hold a password or a token. Neither belongs on a screen + * the user photographs for a bug report or in a log they attach to one. Only + * the origin is printed; the load request still uses the complete URL. + */ +export function describeServerUrl(serverUrl: string): string { + let parsed: URL; + try { + parsed = new URL(serverUrl); + } catch { + return "the saved bb server"; + } + return `the bb server at ${parsed.origin}`; +} + +/** + * Keep only the Electron error code from a failed load. The full message + * repeats the URL it tried, which can carry a credential. + */ +function formatLoadFailure(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return ELECTRON_LOAD_ERROR_CODE.exec(message)?.[0] ?? "the page load failed"; +} + +/** + * Load a remote bb server and keep an unreachable host recoverable. + * + * `BrowserWindow.loadURL` rejects when the host is asleep, the tunnel is down, + * or nothing listens on the port. Left alone, that rejection unwinds to the + * top-level startup handler at launch, which prints the Electron stack on a + * screen with no way out, and from the Server menu it is an unhandled + * rejection that leaves a blank window. Here the detail goes to the log and + * the user sees the server name plus the menu path that recovers. + * + * Resolves true when the page loaded; false when it did not. A load that the + * user has already superseded shows nothing, so the newer target keeps the + * window. + */ +export async function loadRemoteServerPage( + args: LoadRemoteServerPageArgs, +): Promise<boolean> { + try { + await args.loadUrl({ url: args.serverUrl }); + return true; + } catch (error) { + if (!args.isCurrent()) { + return false; + } + const label = describeServerUrl(args.serverUrl); + args.logWarning( + `[desktop] could not load ${label}: ${formatLoadFailure(error)}`, + ); + await args.loadStartupError({ + details: + `${label.charAt(0).toUpperCase()}${label.slice(1)} did not answer. ` + + "Check that the machine is awake and reachable, then choose " + + "Window ▸ Server to retry this server or switch to " + + `${BUILTIN_SERVER_NAME}.`, + logs: "", + title: "Could not reach this bb server", + }); + return false; + } +} diff --git a/apps/desktop/src/server-probe.ts b/apps/desktop/src/server-probe.ts index 6ece27cab9..50dd2bcf63 100644 --- a/apps/desktop/src/server-probe.ts +++ b/apps/desktop/src/server-probe.ts @@ -33,25 +33,25 @@ export interface CompatibleServerProbeResult { serverUrl: string; } -export interface IncompatibleServerProbeResult { +interface IncompatibleServerProbeResult { kind: "incompatible"; reason: string; serverUrl: string; } -export interface UnavailableServerProbeResult { +interface UnavailableServerProbeResult { kind: "unavailable"; reason: string; serverUrl: string; } -export interface ProbeBbServerArgs { +interface ProbeBbServerArgs { fetchImpl?: ServerProbeFetch; serverUrl: string; timeoutMs: number; } -export interface WaitForCompatibleServerArgs { +interface WaitForCompatibleServerArgs { intervalMs: number; serverUrl: string; timeoutMs: number; diff --git a/apps/desktop/src/server-target.ts b/apps/desktop/src/server-target.ts index e6fc206b32..d5a33b0eca 100644 --- a/apps/desktop/src/server-target.ts +++ b/apps/desktop/src/server-target.ts @@ -12,7 +12,7 @@ export interface ConnectServerRef { url: string; } -export type DesktopServerTarget = +type DesktopServerTarget = | { kind: "builtin" } | { kind: "connect"; server: ConnectServerRef } | { kind: "custom"; url: string }; @@ -26,7 +26,7 @@ export interface ServerTargetFs { writeFile(path: string, data: string, encoding: "utf8"): Promise<void>; } -export interface CreateServerTargetStoreArgs { +interface CreateServerTargetStoreArgs { fs?: ServerTargetFs; storagePath: string; } diff --git a/apps/desktop/src/server-url-dialog-ipc.ts b/apps/desktop/src/server-url-dialog-ipc.ts index a428eb11df..ef2e969410 100644 --- a/apps/desktop/src/server-url-dialog-ipc.ts +++ b/apps/desktop/src/server-url-dialog-ipc.ts @@ -10,9 +10,6 @@ export const serverUrlDialogSubmitRequestSchema = z url: z.string().max(4096), }) .strict(); -export type ServerUrlDialogSubmitRequest = z.infer< - typeof serverUrlDialogSubmitRequestSchema ->; export const serverUrlDialogSubmitResponseSchema = z.discriminatedUnion("ok", [ z.object({ ok: z.literal(true) }).strict(), diff --git a/apps/desktop/src/server-url-dialog.ts b/apps/desktop/src/server-url-dialog.ts index 2dcb2f6399..3014e83b7a 100644 --- a/apps/desktop/src/server-url-dialog.ts +++ b/apps/desktop/src/server-url-dialog.ts @@ -8,12 +8,12 @@ import { } from "./server-url-dialog-ipc.js"; import { normalizeCustomServerUrl } from "./server-target.js"; -export type ServerUrlDialogResult = +type ServerUrlDialogResult = | { kind: "cancelled" } | { kind: "clear" } | { kind: "set"; url: string }; -export interface OpenServerUrlDialogArgs { +interface OpenServerUrlDialogArgs { initialUrl: string | null; parentWindow: BrowserWindow | null; preloadPath: string; diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 63e365be87..8adbab2054 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -1,7 +1,7 @@ -export const DEFAULT_BB_SERVER_PORT = 38886; +const DEFAULT_BB_SERVER_PORT = 38886; export const DEFAULT_BB_SERVER_URL = `http://127.0.0.1:${DEFAULT_BB_SERVER_PORT}`; -export const DEFAULT_WINDOW_HEIGHT = 900; -export const DEFAULT_WINDOW_WIDTH = 1280; +const DEFAULT_WINDOW_HEIGHT = 900; +const DEFAULT_WINDOW_WIDTH = 1280; export const MIN_WINDOW_HEIGHT = 600; export const MIN_WINDOW_WIDTH = 500; export const STARTUP_POLL_INTERVAL_MS = 250; @@ -42,13 +42,7 @@ export interface DisplayWorkArea { y: number; } -export interface DefaultWindowState { - bounds: WindowBounds; - isFullScreen: boolean; - isMaximized: boolean; -} - -export const DEFAULT_WINDOW_STATE: DefaultWindowState = { +export const DEFAULT_WINDOW_STATE: PersistedWindowState = { bounds: { height: DEFAULT_WINDOW_HEIGHT, width: DEFAULT_WINDOW_WIDTH, diff --git a/apps/desktop/src/window-state.ts b/apps/desktop/src/window-state.ts index ca33cd9d08..739e4a29da 100644 --- a/apps/desktop/src/window-state.ts +++ b/apps/desktop/src/window-state.ts @@ -5,7 +5,6 @@ import { z } from "zod"; import { DEFAULT_WINDOW_STATE, PRIMARY_WINDOW_STATE_KEY, - type DefaultWindowState, type DisplayWorkArea, type PersistedWindowStateEntry, type PersistedWindowStateFile, @@ -38,33 +37,27 @@ const persistedWindowStateFileSchema = z.object({ windows: z.array(persistedWindowStateEntrySchema), }); -export interface ReadPersistedWindowStateArgs { +interface ReadPersistedWindowStateArgs { stateKey: WindowStateKey; userDataPath: string; } -export interface ReadPersistedWindowStateEntriesArgs { +interface ReadPersistedWindowStateEntriesArgs { userDataPath: string; } -export interface WritePersistedWindowStateArgs { - state: PersistedWindowState; - stateKey: WindowStateKey; - userDataPath: string; -} - -export interface WritePersistedWindowStateEntriesArgs { +interface WritePersistedWindowStateEntriesArgs { entries: PersistedWindowStateEntry[]; userDataPath: string; } -export interface RestoreWindowStateArgs { - defaultState?: DefaultWindowState; +interface RestoreWindowStateArgs { + defaultState?: PersistedWindowState; displayWorkAreas: DisplayWorkArea[]; persistedState: PersistedWindowState | null; } -export interface HasVisibleAreaArgs { +interface HasVisibleAreaArgs { bounds: WindowBounds; displayWorkAreas: DisplayWorkArea[]; } @@ -81,33 +74,28 @@ export interface PersistBrowserWindowStateSnapshot { stateKey: WindowStateKey; } -export interface PersistBrowserWindowStatesArgs { +interface PersistBrowserWindowStatesArgs { snapshots: PersistBrowserWindowStateSnapshot[]; userDataPath: string; } -export interface RestoreBrowserWindowStateArgs { +interface RestoreBrowserWindowStateArgs { displayWorkAreas: DisplayWorkArea[] | null; stateKey: WindowStateKey; userDataPath: string; } -export interface RemovePersistedWindowStateArgs { +interface RemovePersistedWindowStateArgs { stateKey: WindowStateKey; userDataPath: string; } -export interface UpsertPersistedWindowStateEntryArgs { - entries: PersistedWindowStateEntry[]; - entry: PersistedWindowStateEntry; -} - -export interface RemovePersistedWindowStateEntryArgs { +interface RemovePersistedWindowStateEntryArgs { entries: PersistedWindowStateEntry[]; stateKey: WindowStateKey; } -export interface CreatePersistedWindowStateEntryArgs { +interface CreatePersistedWindowStateEntryArgs { browserWindow: StatefulBrowserWindow; stateKey: WindowStateKey; } @@ -173,7 +161,7 @@ export function hasVisibleArea(args: HasVisibleAreaArgs): boolean { export function restoreWindowState( args: RestoreWindowStateArgs, -): DefaultWindowState { +): PersistedWindowState { const defaultState = args.defaultState ?? DEFAULT_WINDOW_STATE; if (args.persistedState === null) { return defaultState; @@ -222,24 +210,6 @@ export async function readPersistedWindowStateEntries( } } -export async function writePersistedWindowState( - args: WritePersistedWindowStateArgs, -): Promise<void> { - const entries = await readPersistedWindowStateEntries({ - userDataPath: args.userDataPath, - }); - await writePersistedWindowStateEntries({ - entries: upsertPersistedWindowStateEntry({ - entries, - entry: { - ...args.state, - stateKey: args.stateKey, - }, - }), - userDataPath: args.userDataPath, - }); -} - export async function writePersistedWindowStateEntries( args: WritePersistedWindowStateEntriesArgs, ): Promise<void> { @@ -269,7 +239,7 @@ function browserWindowBounds( export async function restoreBrowserWindowState( args: RestoreBrowserWindowStateArgs, -): Promise<DefaultWindowState> { +): Promise<PersistedWindowState> { return restoreWindowState({ displayWorkAreas: args.displayWorkAreas ?? getDisplayWorkAreas(), persistedState: await readPersistedWindowState({ @@ -279,7 +249,7 @@ export async function restoreBrowserWindowState( }); } -export function createPersistedWindowStateEntry( +function createPersistedWindowStateEntry( args: CreatePersistedWindowStateEntryArgs, ): PersistedWindowStateEntry { return { @@ -290,31 +260,7 @@ export function createPersistedWindowStateEntry( }; } -export function upsertPersistedWindowStateEntry( - args: UpsertPersistedWindowStateEntryArgs, -): PersistedWindowStateEntry[] { - const entries: PersistedWindowStateEntry[] = []; - let replaced = false; - - for (const entry of args.entries) { - if (entry.stateKey === args.entry.stateKey) { - if (!replaced) { - entries.push(args.entry); - replaced = true; - } - } else { - entries.push(entry); - } - } - - if (!replaced) { - entries.push(args.entry); - } - - return entries; -} - -export function removePersistedWindowStateEntry( +function removePersistedWindowStateEntry( args: RemovePersistedWindowStateEntryArgs, ): PersistedWindowStateEntry[] { const entries: PersistedWindowStateEntry[] = []; diff --git a/apps/desktop/test/connect-server-sync.test.ts b/apps/desktop/test/connect-server-sync.test.ts index e81aff6563..841237257d 100644 --- a/apps/desktop/test/connect-server-sync.test.ts +++ b/apps/desktop/test/connect-server-sync.test.ts @@ -46,11 +46,15 @@ describe("fetchConnectAccountServers", () => { serverUrl: "http://127.0.0.1:38886/", fetchImpl, }); - expect(result?.selfHandle).toBe("me"); - expect(result?.servers).toHaveLength(2); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error(`unexpected skip: ${result.reason}`); + } + expect(result.result.selfHandle).toBe("me"); + expect(result.result.servers).toHaveLength(2); }); - it("returns null on network failure, non-JSON, or ok:false", async () => { + it("names the reason on network failure, plugin disabled, or not paired", async () => { await expect( fetchConnectAccountServers({ serverUrl: "http://127.0.0.1:1", @@ -58,31 +62,68 @@ describe("fetchConnectAccountServers", () => { throw new Error("ECONNREFUSED"); }, }), - ).resolves.toBeNull(); + ).resolves.toEqual({ ok: false, reason: "unavailable" }); + // The plugin route answers 503 with a string error when the plugin is off. + await expect( + fetchConnectAccountServers({ + serverUrl: "http://127.0.0.1:38886", + fetchImpl: async () => ({ + ok: false, + status: 503, + json: async () => ({ + ok: false, + error: 'plugin "connect" is not running (status: disabled)', + }), + text: async () => "", + }), + }), + ).resolves.toEqual({ ok: false, reason: "plugin-disabled" }); + + // The RPC handler rethrows ConnectListError codes as the error message. await expect( fetchConnectAccountServers({ serverUrl: "http://127.0.0.1:38886", fetchImpl: async () => ({ ok: false, status: 500, - json: async () => ({ ok: false, error: "not_paired" }), + json: async () => ({ + ok: false, + error: { code: "handler_error", message: "not_paired" }, + }), text: async () => "", }), }), - ).resolves.toBeNull(); + ).resolves.toEqual({ ok: false, reason: "not-paired" }); + // Any other handler failure, non-JSON body, or unknown shape. await expect( fetchConnectAccountServers({ serverUrl: "http://127.0.0.1:38886", fetchImpl: async () => ({ ok: false, - status: 422, - json: async () => ({ ok: false, error: "plugin disabled" }), + status: 500, + json: async () => ({ + ok: false, + error: { code: "handler_error", message: "network" }, + }), text: async () => "", }), }), - ).resolves.toBeNull(); + ).resolves.toEqual({ ok: false, reason: "unavailable" }); + await expect( + fetchConnectAccountServers({ + serverUrl: "http://127.0.0.1:38886", + fetchImpl: async () => ({ + ok: false, + status: 502, + json: async () => { + throw new SyntaxError("not json"); + }, + text: async () => "", + }), + }), + ).resolves.toEqual({ ok: false, reason: "unavailable" }); }); }); @@ -130,6 +171,7 @@ describe("createConnectServerSync", () => { onServers(servers) { received = servers; }, + onSkipped: () => undefined, onUnauthorized: () => undefined, fetchImpl, now: () => now, @@ -167,24 +209,35 @@ describe("createConnectServerSync", () => { expect(fetchImpl).toHaveBeenCalledTimes(2); }); - it("does not call onServers on failure and logs the failure only once until a success", async () => { + it("reports each skipped sync with its reason and logs once per failure streak", async () => { const logs: string[] = []; + const skipped: string[] = []; let onServersCalls = 0; - let fail = true; - const fetchImpl = vi.fn(async () => { - if (fail) { - throw new Error("down"); - } - return { - ok: true, - status: 200, - json: async () => ({ + let mode: "down" | "disabled" | "up" = "down"; + const fetchImpl = vi.fn( + async (): Promise<Pick<Response, "ok" | "status" | "json" | "text">> => { + if (mode === "down") { + throw new Error("down"); + } + if (mode === "disabled") { + return { + ok: false, + status: 503, + json: async () => ({ ok: false, error: "plugin not running" }), + text: async () => "", + }; + } + return { ok: true, - result: { selfHandle: "me", servers: [] }, - }), - text: async () => "", - }; - }); + status: 200, + json: async () => ({ + ok: true, + result: { selfHandle: "me", servers: [] }, + }), + text: async () => "", + }; + }, + ); const sync = createConnectServerSync({ getCredential: () => null, @@ -192,6 +245,9 @@ describe("createConnectServerSync", () => { onServers() { onServersCalls += 1; }, + onSkipped(reason) { + skipped.push(reason); + }, onUnauthorized: () => undefined, fetchImpl, log: (message) => { @@ -203,15 +259,25 @@ describe("createConnectServerSync", () => { await sync.syncNow(); await sync.syncNow(); - expect(logs).toHaveLength(1); + expect(skipped).toEqual(["unavailable", "unavailable"]); + expect(logs).toEqual(["connect server sync skipped (unavailable)"]); expect(onServersCalls).toBe(0); - fail = false; + // A different reason inside the same streak is logged once more. + mode = "disabled"; + await sync.syncNow(); + expect(skipped).toEqual(["unavailable", "unavailable", "plugin-disabled"]); + expect(logs).toEqual([ + "connect server sync skipped (unavailable)", + "connect server sync skipped (plugin-disabled)", + ]); + + mode = "up"; await sync.syncNow(); expect(onServersCalls).toBe(1); - fail = true; + mode = "disabled"; await sync.syncNow(); - expect(logs).toHaveLength(2); + expect(logs).toHaveLength(3); }); }); @@ -243,6 +309,7 @@ describe("createConnectServerSync without a local server", () => { onServers(servers) { received = servers; }, + onSkipped: () => undefined, onUnauthorized: () => undefined, setIntervalFn: () => 0, clearIntervalFn: () => undefined, @@ -268,11 +335,15 @@ describe("createConnectServerSync without a local server", () => { it("reports a refused credential so the caller drops it", async () => { let unauthorized = 0; + const skipped: string[] = []; const sync = createConnectServerSync({ getCredential: () => credential, getLocalServerUrl: () => null, gateFetchImpl: async () => new Response("no", { status: 403 }), onServers: () => undefined, + onSkipped(reason) { + skipped.push(reason); + }, onUnauthorized() { unauthorized += 1; }, @@ -282,21 +353,51 @@ describe("createConnectServerSync without a local server", () => { await sync.syncNow(); expect(unauthorized).toBe(1); + expect(skipped).toEqual(["unauthorized"]); + }); + + it("reports a gate outage as unavailable", async () => { + const skipped: string[] = []; + const sync = createConnectServerSync({ + getCredential: () => credential, + getLocalServerUrl: () => null, + gateFetchImpl: async () => new Response("oops", { status: 502 }), + onServers: () => undefined, + onSkipped(reason) { + skipped.push(reason); + }, + onUnauthorized: () => undefined, + setIntervalFn: () => 0, + clearIntervalFn: () => undefined, + }); + + await sync.syncNow(); + expect(skipped).toEqual(["unavailable"]); }); - it("stays quiet when the app has no credential", async () => { + it("reports no-credential without calling the gate when the app has none", async () => { const gateFetchImpl = vi.fn(async () => new Response("{}")); + const skipped: string[] = []; + const logs: string[] = []; const sync = createConnectServerSync({ getCredential: () => null, getLocalServerUrl: () => null, gateFetchImpl, onServers: () => undefined, + onSkipped(reason) { + skipped.push(reason); + }, onUnauthorized: () => undefined, + log: (message) => { + logs.push(message); + }, setIntervalFn: () => 0, clearIntervalFn: () => undefined, }); await sync.syncNow(); expect(gateFetchImpl).not.toHaveBeenCalled(); + expect(skipped).toEqual(["no-credential"]); + expect(logs).toEqual(["connect server sync skipped (no-credential)"]); }); }); diff --git a/apps/desktop/test/desktop-browser-main-ipc.test.ts b/apps/desktop/test/desktop-browser-main-ipc.test.ts index 60a093dfe0..211164f83b 100644 --- a/apps/desktop/test/desktop-browser-main-ipc.test.ts +++ b/apps/desktop/test/desktop-browser-main-ipc.test.ts @@ -11,6 +11,7 @@ import { import { BB_DESKTOP_BROWSER_ATTACH_CHANNEL, BB_DESKTOP_BROWSER_DETACH_CHANNEL, + BB_DESKTOP_BROWSER_FOCUS_CHANNEL, BB_DESKTOP_BROWSER_FIND_IN_PAGE_CHANNEL, BB_DESKTOP_BROWSER_GO_BACK_CHANNEL, BB_DESKTOP_BROWSER_GO_FORWARD_CHANNEL, @@ -18,6 +19,7 @@ import { BB_DESKTOP_BROWSER_RELOAD_CHANNEL, BB_DESKTOP_BROWSER_SET_BOUNDS_CHANNEL, BB_DESKTOP_BROWSER_SET_VISIBLE_CHANNEL, + BB_DESKTOP_BROWSER_SET_VISIBLE_WITHOUT_FOCUS_CHANNEL, BB_DESKTOP_BROWSER_STOP_CHANNEL, BB_DESKTOP_BROWSER_STOP_FIND_IN_PAGE_CHANNEL, } from "../src/desktop-browser-ipc.js"; @@ -102,6 +104,7 @@ class RecordingDesktopBrowserViewManager implements DesktopBrowserViewManager { public readonly destroyAllCalls: string[] = []; public readonly detachCalls: DetachCall[] = []; public readonly endWindowResizeCalls: WindowResizeCall[] = []; + public readonly focusCalls: TabCommandCall[] = []; public readonly findInPageCalls: FindInPageCall[] = []; public readonly stopFindInPageCalls: StopFindInPageCall[] = []; public readonly goBackCalls: TabCommandCall[] = []; @@ -111,6 +114,7 @@ class RecordingDesktopBrowserViewManager implements DesktopBrowserViewManager { public readonly reloadCalls: TabCommandCall[] = []; public readonly setBoundsCalls: SetBoundsCall[] = []; public readonly setVisibleCalls: SetVisibleCall[] = []; + public readonly setVisibleWithoutFocusCalls: SetVisibleCall[] = []; public readonly stopCalls: TabCommandCall[] = []; attach(args: AttachCall): void { @@ -133,6 +137,10 @@ class RecordingDesktopBrowserViewManager implements DesktopBrowserViewManager { this.endWindowResizeCalls.push(hostWindow); } + focus(args: TabCommandCall): void { + this.focusCalls.push(args); + } + findInPage(args: FindInPageCall): void { this.findInPageCalls.push(args); } @@ -169,6 +177,10 @@ class RecordingDesktopBrowserViewManager implements DesktopBrowserViewManager { this.setVisibleCalls.push(args); } + setVisibleWithoutFocus(args: SetVisibleCall): void { + this.setVisibleWithoutFocusCalls.push(args); + } + stop(args: TabCommandCall): void { this.stopCalls.push(args); } @@ -246,6 +258,11 @@ describe("registerDesktopBrowserIpc", () => { payload: { tabId: "browser:a" }, sender: renderer.sender, }); + sendBrowserIpc({ + channel: BB_DESKTOP_BROWSER_FOCUS_CHANNEL, + payload: { tabId: "browser:a" }, + sender: renderer.sender, + }); expect(manager.attachCalls).toHaveLength(1); expect(manager.attachCalls[0]?.hostWindow).toBe(renderer.hostWindow); @@ -256,6 +273,9 @@ describe("registerDesktopBrowserIpc", () => { expect(manager.reloadCalls).toEqual([ { hostWindow: renderer.hostWindow, tabId: "browser:a" }, ]); + expect(manager.focusCalls).toEqual([ + { hostWindow: renderer.hostWindow, tabId: "browser:a" }, + ]); }); it("dispatches validated find-in-page requests and rejects malformed ones", () => { @@ -387,6 +407,11 @@ describe("registerDesktopBrowserIpc", () => { payload: { tabId: "browser:a", visible: "yes" }, sender: renderer.sender, }); + sendBrowserIpc({ + channel: BB_DESKTOP_BROWSER_SET_VISIBLE_WITHOUT_FOCUS_CHANNEL, + payload: visibleRequest, + sender: renderer.sender, + }); sendBrowserIpc({ channel: BB_DESKTOP_BROWSER_SET_VISIBLE_CHANNEL, payload: visibleRequest, @@ -395,6 +420,7 @@ describe("registerDesktopBrowserIpc", () => { for (const channel of [ BB_DESKTOP_BROWSER_DETACH_CHANNEL, + BB_DESKTOP_BROWSER_FOCUS_CHANNEL, BB_DESKTOP_BROWSER_GO_BACK_CHANNEL, BB_DESKTOP_BROWSER_GO_FORWARD_CHANNEL, BB_DESKTOP_BROWSER_RELOAD_CHANNEL, @@ -434,6 +460,9 @@ describe("registerDesktopBrowserIpc", () => { expect(manager.setVisibleCalls).toEqual([ { hostWindow: renderer.hostWindow, request: visibleRequest }, ]); + expect(manager.setVisibleWithoutFocusCalls).toEqual([ + { hostWindow: renderer.hostWindow, request: visibleRequest }, + ]); expect(manager.detachCalls).toEqual([ { hostWindow: renderer.hostWindow, tabId: "browser:a" }, ]); diff --git a/apps/desktop/test/desktop-browser-view-manager.test.ts b/apps/desktop/test/desktop-browser-view-manager.test.ts index 125d105cce..057fcf3db4 100644 --- a/apps/desktop/test/desktop-browser-view-manager.test.ts +++ b/apps/desktop/test/desktop-browser-view-manager.test.ts @@ -133,6 +133,7 @@ interface FakeFindInPageCall { } interface FakeWebContentsEventMap { + focus: FakeVoidWebContentsListener; "before-input-event": FakeBeforeInputListener; "will-frame-navigate": FakeWillFrameNavigateListener; "will-navigate": FakeWillNavigateListener; @@ -260,6 +261,7 @@ const electronMock = vi.hoisted(() => { (image: FakeNativeImage) => void > = []; private readonly listeners: FakeWebContentsListeners = { + focus: [], "before-input-event": [], "will-frame-navigate": [], "will-navigate": [], @@ -310,6 +312,7 @@ const electronMock = vi.hoisted(() => { focus(): void { this.focusCalls += 1; + this.emitFocus(); } findInPage( @@ -381,6 +384,10 @@ const electronMock = vi.hoisted(() => { } } + emitFocus(): void { + for (const listener of this.listeners.focus) listener(); + } + emitRenderProcessGone(details: FakeRenderProcessGoneDetails): void { for (const listener of this.listeners["render-process-gone"]) { listener(fakeWebContentsEvent, details); @@ -546,6 +553,7 @@ interface FakeHostWindowArgs { class FakeHostWebContents implements DesktopBrowserHostWebContents { public destroyed = false; public readonly sentPayloads: DesktopBrowserHostWebContentsPayload[] = []; + public readonly sentChannels: string[] = []; public readonly id: number; constructor(id: number) { @@ -556,7 +564,8 @@ class FakeHostWebContents implements DesktopBrowserHostWebContents { return this.destroyed; } - send(_channel: string, payload: DesktopBrowserHostWebContentsPayload): void { + send(channel: string, payload: DesktopBrowserHostWebContentsPayload): void { + this.sentChannels.push(channel); this.sentPayloads.push(payload); } } @@ -1280,6 +1289,43 @@ describe("DesktopBrowserViewManager", () => { expect(view.webContents.focusCalls).toBe(1); }); + it("reports user focus but suppresses programmatic focus used for restoration", () => { + const manager = createDesktopBrowserViewManager({ + partition: "persist:test", + }); + const hostWindow = new FakeHostWindow({ + contentBounds: { width: 700, height: 450 }, + webContentsId: 79, + }); + + manager.attach({ + hostWindow, + request: { + tabId: "browser:a", + url: "https://example.com", + bounds: { x: 100, y: 50, width: 500, height: 350 }, + visible: true, + }, + }); + const view = requireFakeView(0); + expect(hostWindow.webContents.sentChannels).not.toContain( + "bb-desktop:browser:focused", + ); + + manager.focus({ hostWindow, tabId: "browser:a" }); + expect(hostWindow.webContents.sentChannels).not.toContain( + "bb-desktop:browser:focused", + ); + + view.webContents.emitFocus(); + expect(hostWindow.webContents.sentChannels).toContain( + "bb-desktop:browser:focused", + ); + expect(hostWindow.webContents.sentPayloads.at(-1)).toEqual({ + tabId: "browser:a", + }); + }); + it("defers hidden memory-eviction recovery until the panel shows the current page", () => { vi.useFakeTimers(); const { hostWindow, manager, view } = createRendererRecoveryFixture(75); @@ -1471,6 +1517,119 @@ describe("DesktopBrowserViewManager", () => { expect(view.webContents.focusCalls).toBe(2); }); + it("does not let an unfocused split view steal focus on mount or restore", () => { + const manager = createDesktopBrowserViewManager({ + partition: "persist:test", + }); + const hostWindow = new FakeHostWindow({ + contentBounds: { width: 900, height: 600 }, + webContentsId: 80, + }); + + for (const [tabId, x] of [ + ["browser:focused", 0], + ["browser:sibling", 450], + ] as const) { + manager.attach({ + hostWindow, + request: { + tabId, + url: `https://example.com/${tabId}`, + bounds: { x, y: 0, width: 450, height: 600 }, + visible: true, + }, + }); + } + const focusedView = requireFakeView(0); + const siblingView = requireFakeView(1); + expect(focusedView.webContents.focusCalls).toBe(1); + expect(siblingView.webContents.focusCalls).toBe(0); + + manager.setVisible({ + hostWindow, + request: { tabId: "browser:sibling", visible: false }, + }); + manager.setVisible({ + hostWindow, + request: { tabId: "browser:sibling", visible: true }, + }); + + expect(focusedView.webContents.focusCalls).toBe(1); + expect(siblingView.webContents.focusCalls).toBe(0); + }); + + it("shows a browser beside a focused non-browser pane without stealing focus", () => { + const manager = createDesktopBrowserViewManager({ + partition: "persist:test", + }); + const hostWindow = new FakeHostWindow({ + contentBounds: { width: 900, height: 600 }, + webContentsId: 82, + }); + + manager.attach({ + hostWindow, + request: { + tabId: "browser:sibling", + url: "https://example.com/browser", + bounds: { x: 450, y: 0, width: 450, height: 600 }, + visible: false, + }, + }); + const browserView = requireFakeView(0); + + manager.setVisibleWithoutFocus({ + hostWindow, + request: { tabId: "browser:sibling", visible: true }, + }); + expect(browserView.visible).toBe(true); + expect(browserView.webContents.focusCalls).toBe(0); + + manager.setVisibleWithoutFocus({ + hostWindow, + request: { tabId: "browser:sibling", visible: false }, + }); + manager.setVisibleWithoutFocus({ + hostWindow, + request: { tabId: "browser:sibling", visible: true }, + }); + expect(browserView.visible).toBe(true); + expect(browserView.webContents.focusCalls).toBe(0); + }); + + it("lets logical focus override first-visible mount order", () => { + const manager = createDesktopBrowserViewManager({ + partition: "persist:test", + }); + const hostWindow = new FakeHostWindow({ + contentBounds: { width: 900, height: 600 }, + webContentsId: 81, + }); + + for (const [tabId, x] of [ + ["browser:sibling", 0], + ["browser:focused", 450], + ] as const) { + manager.attach({ + hostWindow, + request: { + tabId, + url: `https://example.com/${tabId}`, + bounds: { x, y: 0, width: 450, height: 600 }, + visible: true, + }, + }); + } + const siblingView = requireFakeView(0); + const focusedView = requireFakeView(1); + expect(siblingView.webContents.focusCalls).toBe(1); + expect(focusedView.webContents.focusCalls).toBe(0); + + manager.focus({ hostWindow, tabId: "browser:focused" }); + + expect(focusedView.webContents.focusCalls).toBe(1); + }); + it("allows clipboard-sanitized-write but denies clipboard-read and device permissions", () => { // Write-only clipboard lets in-page copy buttons work; read and every // device/capability permission stay denied. diff --git a/apps/desktop/test/desktop-context-menu.test.ts b/apps/desktop/test/desktop-context-menu.test.ts index 0f2c55c1b4..0a1cff71d6 100644 --- a/apps/desktop/test/desktop-context-menu.test.ts +++ b/apps/desktop/test/desktop-context-menu.test.ts @@ -3,13 +3,18 @@ import { describe, expect, it, vi } from "vitest"; import { buildDesktopContextMenuTemplate, registerDesktopContextMenu, - resolveDesktopSpellcheckFallback, type DesktopContextMenuWebContents, } from "../src/desktop-context-menu.js"; const popup = vi.fn(); +const { writeClipboardText } = vi.hoisted(() => ({ + writeClipboardText: vi.fn(), +})); vi.mock("electron", () => ({ + clipboard: { + writeText: writeClipboardText, + }, Menu: { buildFromTemplate(template: MenuItemConstructorOptions[]) { return { popup, template }; @@ -46,11 +51,9 @@ const DEFAULT_MEDIA_FLAGS = { interface FakeWebContents extends Pick< DesktopContextMenuWebContents, - "executeJavaScript" | "insertText" | "replaceMisspelling" | "session" + "replaceMisspelling" | "session" > { addedDictionaryWords: string[]; - executedScripts: string[]; - insertedTexts: string[]; replacedMisspellings: string[]; spellCheckerEnabledValues: boolean[]; } @@ -91,23 +94,12 @@ function createContextMenuParams( function createFakeWebContents(): FakeWebContents { const addedDictionaryWords: string[] = []; - const executedScripts: string[] = []; - const insertedTexts: string[] = []; const replacedMisspellings: string[] = []; const spellCheckerEnabledValues: boolean[] = []; return { addedDictionaryWords, - executedScripts, - insertedTexts, replacedMisspellings, spellCheckerEnabledValues, - executeJavaScript(script) { - executedScripts.push(script); - return Promise.resolve(null); - }, - insertText(text) { - insertedTexts.push(text); - }, replaceMisspelling(text) { replacedMisspellings.push(text); }, @@ -128,16 +120,18 @@ function clickMenuItem(item: MenuItemConstructorOptions | undefined): void { } describe("desktop context menu", () => { - it("offers spellcheck replacements for editable misspellings", () => { + it("uses Electron's spelling result even when spellcheckEnabled is false", () => { const webContents = createFakeWebContents(); + const params = createContextMenuParams({ + formControlType: "input-text", + isEditable: true, + spellcheckEnabled: false, + misspelledWord: "teh", + dictionarySuggestions: ["the", "tech"], + }); const template = buildDesktopContextMenuTemplate({ webContents, - params: createContextMenuParams({ - isEditable: true, - spellcheckEnabled: true, - misspelledWord: "teh", - dictionarySuggestions: ["the", "tech"], - }), + params, }); expect(template[0]).toMatchObject({ label: "the" }); @@ -148,80 +142,6 @@ describe("desktop context menu", () => { expect(webContents.replacedMisspellings).toEqual(["the"]); }); - it("offers renderer spellcheck replacements when Electron omits suggestions for selected prompt text", () => { - const webContents = createFakeWebContents(); - const template = buildDesktopContextMenuTemplate({ - webContents, - params: createContextMenuParams({ - isEditable: true, - selectionText: "recieve", - }), - spellcheckContext: { - dictionarySuggestions: ["receive", "relieve"], - misspelledWord: "recieve", - replacementMode: "selected-text", - }, - }); - - expect(template[0]).toMatchObject({ label: "receive" }); - expect(template[1]).toMatchObject({ label: "relieve" }); - - clickMenuItem(template[0]); - - expect(webContents.insertedTexts).toEqual(["receive"]); - expect(webContents.replacedMisspellings).toEqual([]); - }); - - it("looks up fallback spellcheck suggestions for a selected editable word", async () => { - const webContents = { - ...createFakeWebContents(), - executeJavaScript: vi.fn().mockResolvedValue({ - dictionarySuggestions: ["receive"], - misspelledWord: "recieve", - }), - } satisfies FakeWebContents; - - await expect( - resolveDesktopSpellcheckFallback({ - webContents, - params: createContextMenuParams({ - isEditable: true, - selectionText: "recieve", - spellcheckEnabled: true, - }), - }), - ).resolves.toEqual({ - dictionarySuggestions: ["receive"], - misspelledWord: "recieve", - replacementMode: "selected-text", - }); - expect(webContents.executeJavaScript).toHaveBeenCalledWith( - expect.stringContaining('"recieve"'), - ); - }); - - it("does not look up fallback spellcheck suggestions when spellcheck is disabled", async () => { - const webContents = { - ...createFakeWebContents(), - executeJavaScript: vi.fn().mockResolvedValue({ - dictionarySuggestions: ["receive"], - misspelledWord: "recieve", - }), - } satisfies FakeWebContents; - - await expect( - resolveDesktopSpellcheckFallback({ - webContents, - params: createContextMenuParams({ - isEditable: true, - selectionText: "recieve", - spellcheckEnabled: false, - }), - }), - ).resolves.toBeNull(); - expect(webContents.executeJavaScript).not.toHaveBeenCalled(); - }); - it("can add a misspelled word to the spellchecker dictionary", () => { const webContents = createFakeWebContents(); const template = buildDesktopContextMenuTemplate({ @@ -274,6 +194,69 @@ describe("desktop context menu", () => { ]); }); + it("copies a link target", () => { + const webContents = createFakeWebContents(); + const template = buildDesktopContextMenuTemplate({ + webContents, + params: createContextMenuParams({ + linkURL: "https://example.com/device", + }), + }); + + expect(template).toEqual([ + { label: "Copy Link", click: expect.any(Function) }, + ]); + + clickMenuItem(template[0]); + + expect(writeClipboardText).toHaveBeenCalledWith( + "https://example.com/device", + ); + }); + + it("groups selected-text actions together", () => { + const webContents = createFakeWebContents(); + const template = buildDesktopContextMenuTemplate({ + webContents, + params: createContextMenuParams({ + selectionText: "device authorization", + editFlags: { + ...DEFAULT_EDIT_FLAGS, + canCopy: true, + canSelectAll: true, + }, + }), + }); + + expect(template).toEqual([ + { role: "copy", enabled: true }, + { role: "selectAll", enabled: true }, + ]); + }); + + it("preserves selected-text actions for links", () => { + const webContents = createFakeWebContents(); + const template = buildDesktopContextMenuTemplate({ + webContents, + params: createContextMenuParams({ + linkURL: "https://example.com/device", + selectionText: "device authorization", + editFlags: { + ...DEFAULT_EDIT_FLAGS, + canCopy: true, + canSelectAll: true, + }, + }), + }); + + expect(template).toMatchObject([ + { label: "Copy Link" }, + { type: "separator" }, + { role: "copy", enabled: true }, + { role: "selectAll", enabled: true }, + ]); + }); + it("does not show an empty menu for inert content", () => { const webContents = createFakeWebContents(); diff --git a/apps/desktop/test/desktop-window-factory.test.ts b/apps/desktop/test/desktop-window-factory.test.ts index fe0d38107d..f8368ae666 100644 --- a/apps/desktop/test/desktop-window-factory.test.ts +++ b/apps/desktop/test/desktop-window-factory.test.ts @@ -65,8 +65,6 @@ class FakeDesktopWindowWebContents implements DesktopWindowWebContents { public readonly contextMenuListeners: Parameters< DesktopContextMenuWebContents["on"] >[1][] = []; - public readonly executedScripts: string[] = []; - public readonly insertedTexts: string[] = []; public readonly replacedMisspellings: string[] = []; public windowOpenHandler: DesktopWindowOpenHandler | null = null; public readonly zoomFactors: number[] = []; @@ -81,15 +79,6 @@ class FakeDesktopWindowWebContents implements DesktopWindowWebContents { } } - executeJavaScript(script: string): Promise<unknown> { - this.executedScripts.push(script); - return Promise.resolve(null); - } - - insertText(text: string): void { - this.insertedTexts.push(text); - } - send(channel: string, payload: unknown): void { this.sentMessages.push({ channel, payload }); } diff --git a/apps/desktop/test/menu.test.ts b/apps/desktop/test/menu.test.ts index 388df7b685..e103a0c5ef 100644 --- a/apps/desktop/test/menu.test.ts +++ b/apps/desktop/test/menu.test.ts @@ -10,6 +10,7 @@ import { Menu } from "electron"; import { buildApplicationMenuTemplate, + CONNECT_SERVERS_SKIPPED_MENU_LABELS, SET_SERVER_URL_MENU_LABEL, type InstallApplicationMenuArgs, } from "../src/menu.js"; @@ -27,8 +28,10 @@ function menuArgs( openSettings: undefined, }, closeWindowOrSideTab: () => {}, + connectServersSkipReason: null, createNewWindow: () => {}, isMac: true, + openAbout: () => {}, openNewTab: () => {}, openNewThread: () => {}, openServerDaemonLogs: () => {}, @@ -133,6 +136,40 @@ describe("application menu", () => { expect(setServerUrl).toHaveBeenCalledTimes(1); }); + it("explains an empty Connect list with a disabled row when the sync was skipped", () => { + // A saved custom target with no local runtime and no cached credential: + // the sync has nothing to ask, and the menu must say so (#1753). + const template = buildApplicationMenuTemplate( + menuArgs(() => {}, { + connectServersSkipReason: "no-credential", + servers: [ + { checked: false, id: "builtin", name: "This Mac" }, + { + checked: true, + id: "custom", + name: "old-host.tailnet.ts.net:38886", + }, + ], + }), + ); + const serverSubmenu = findServerSubmenu(template); + + expect(serverSubmenu.map((item) => item.label ?? `<${item.type}>`)).toEqual( + [ + "This Mac", + "old-host.tailnet.ts.net:38886", + CONNECT_SERVERS_SKIPPED_MENU_LABELS["no-credential"], + "<separator>", + SET_SERVER_URL_MENU_LABEL, + ], + ); + const note = serverSubmenu[2]; + expect(note?.enabled).toBe(false); + expect(note?.type).toBeUndefined(); + expect(note?.click).toBeUndefined(); + expect(note?.label).toMatch(/sign in to bb Connect/u); + }); + it("builds a native Linux menu with the Linux DevTools accelerator", () => { vi.mocked(Menu.sendActionToFirstResponder).mockClear(); const template = buildApplicationMenuTemplate( @@ -140,8 +177,7 @@ describe("application menu", () => { ); const appMenu = template[0]?.submenu as MenuItemConstructorOptions[]; const windowMenu = template.find((item) => item.label === "Window"); - const windowSubmenu = - windowMenu?.submenu as MenuItemConstructorOptions[]; + const windowSubmenu = windowMenu?.submenu as MenuItemConstructorOptions[]; const viewMenu = template.find((item) => item.label === "View"); const viewSubmenu = viewMenu?.submenu as MenuItemConstructorOptions[]; const fileMenu = template.find((item) => item.label === "File"); @@ -150,10 +186,7 @@ describe("application menu", () => { (item) => item.label === "Close Window", ); - expect(appMenu.map((item) => item.role).filter(Boolean)).toEqual([ - "about", - "quit", - ]); + expect(appMenu.map((item) => item.role).filter(Boolean)).toEqual(["quit"]); expect(windowSubmenu.map((item) => item.role).filter(Boolean)).toEqual([ "minimize", ]); diff --git a/apps/desktop/test/preload-browser-api.test.ts b/apps/desktop/test/preload-browser-api.test.ts index 20494ccf87..29c9f88016 100644 --- a/apps/desktop/test/preload-browser-api.test.ts +++ b/apps/desktop/test/preload-browser-api.test.ts @@ -19,6 +19,8 @@ import { import { BB_DESKTOP_BROWSER_ATTACH_CHANNEL, BB_DESKTOP_BROWSER_DETACH_CHANNEL, + BB_DESKTOP_BROWSER_FOCUS_CHANNEL, + BB_DESKTOP_BROWSER_FOCUSED_CHANNEL, BB_DESKTOP_BROWSER_FIND_IN_PAGE_CHANNEL, BB_DESKTOP_BROWSER_FIND_RESULT_CHANNEL, BB_DESKTOP_BROWSER_GO_BACK_CHANNEL, @@ -29,6 +31,7 @@ import { BB_DESKTOP_BROWSER_SCOPED_OPEN_TAB_CHANNEL, BB_DESKTOP_BROWSER_SET_BOUNDS_CHANNEL, BB_DESKTOP_BROWSER_SET_VISIBLE_CHANNEL, + BB_DESKTOP_BROWSER_SET_VISIBLE_WITHOUT_FOCUS_CHANNEL, BB_DESKTOP_BROWSER_SNAPSHOT_CHANNEL, BB_DESKTOP_BROWSER_STATE_CHANNEL, BB_DESKTOP_BROWSER_STOP_CHANNEL, @@ -42,8 +45,6 @@ import { BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, BB_DESKTOP_WINDOW_STATE_CHANGED_CHANNEL, } from "../src/desktop-window-command-ipc.js"; -import { BB_DESKTOP_SPELLCHECK_GLOBAL_NAME } from "../src/desktop-spellcheck-contract.js"; - const electronMock = vi.hoisted(() => { interface IpcRendererEvent {} @@ -72,12 +73,8 @@ const electronMock = vi.hoisted(() => { const invokeCalls: string[] = []; const listeners = new Map<string, IpcRendererListener>(); const sendCalls: SendCall[] = []; - const exposedNames: string[] = []; let exposedApi: BbDesktopApi | null = null; let exposedName: string | null = null; - let exposedSpellcheckApi: { - getCorrectionContext(word: string): unknown; - } | null = null; let zoomFactor = 1; return { @@ -87,18 +84,12 @@ const electronMock = vi.hoisted(() => { get exposedName() { return exposedName; }, - exposedNames, - get exposedSpellcheckApi() { - return exposedSpellcheckApi; - }, invokeCalls, listeners, sendCalls, reset(): void { exposedApi = null; exposedName = null; - exposedSpellcheckApi = null; - exposedNames.length = 0; invokeCalls.length = 0; listeners.clear(); sendCalls.length = 0; @@ -109,16 +100,9 @@ const electronMock = vi.hoisted(() => { }, contextBridge: { exposeInMainWorld(name: string, api: unknown): void { - exposedNames.push(name); if (name === "bbDesktop") { exposedName = name; exposedApi = api as BbDesktopApi; - return; - } - if (name !== "bbDesktop") { - exposedSpellcheckApi = api as { - getCorrectionContext(word: string): unknown; - }; } }, }, @@ -141,12 +125,6 @@ const electronMock = vi.hoisted(() => { getZoomFactor(): number { return zoomFactor; }, - getWordSuggestions(word: string): string[] { - return word === "recieve" ? ["receive", "relieve"] : []; - }, - isWordMisspelled(word: string): boolean { - return word === "recieve"; - }, }, }; }); @@ -186,27 +164,6 @@ function emitIpcPayload(args: EmitIpcPayloadArgs): void { } describe("desktop preload browser API", () => { - it("exposes a narrow spellcheck helper for desktop context menus", async () => { - await loadPreload(); - - expect(electronMock.exposedNames).toContain( - BB_DESKTOP_SPELLCHECK_GLOBAL_NAME, - ); - expect(electronMock.exposedSpellcheckApi).not.toBeNull(); - expect( - electronMock.exposedSpellcheckApi?.getCorrectionContext("recieve"), - ).toEqual({ - dictionarySuggestions: ["receive", "relieve"], - misspelledWord: "recieve", - }); - expect( - electronMock.exposedSpellcheckApi?.getCorrectionContext("receive"), - ).toBeNull(); - expect( - electronMock.exposedSpellcheckApi?.getCorrectionContext("two words"), - ).toBeNull(); - }, 15_000); - it("exposes only the typed browser commands and forwards them over fixed channels", async () => { const api = await loadPreload(); const attachRequest = { @@ -242,10 +199,12 @@ describe("desktop preload browser API", () => { "attach", "detach", "findInPage", + "focus", "goBack", "goForward", "navigate", "onFindResult", + "onFocus", "onOpenTab", "onScopedOpenTab", "onSnapshot", @@ -253,6 +212,7 @@ describe("desktop preload browser API", () => { "reload", "setBounds", "setVisible", + "setVisibleWithoutFocus", "stop", "stopFindInPage", ]); @@ -266,8 +226,10 @@ describe("desktop preload browser API", () => { api.browser.goForward("browser:a"); api.browser.reload("browser:a"); api.browser.stop("browser:a"); + api.browser.focus?.("browser:a"); api.browser.setBounds(boundsRequest); api.browser.setVisible(visibleRequest); + api.browser.setVisibleWithoutFocus?.(visibleRequest); api.browser.findInPage?.(findRequest); api.browser.stopFindInPage?.(stopFindRequest); api.setTheme("dark"); @@ -303,6 +265,10 @@ describe("desktop preload browser API", () => { channel: BB_DESKTOP_BROWSER_STOP_CHANNEL, payload: { tabId: "browser:a" }, }, + { + channel: BB_DESKTOP_BROWSER_FOCUS_CHANNEL, + payload: { tabId: "browser:a" }, + }, { channel: BB_DESKTOP_BROWSER_SET_BOUNDS_CHANNEL, payload: boundsRequest, @@ -311,6 +277,10 @@ describe("desktop preload browser API", () => { channel: BB_DESKTOP_BROWSER_SET_VISIBLE_CHANNEL, payload: visibleRequest, }, + { + channel: BB_DESKTOP_BROWSER_SET_VISIBLE_WITHOUT_FOCUS_CHANNEL, + payload: visibleRequest, + }, { channel: BB_DESKTOP_BROWSER_FIND_IN_PAGE_CHANNEL, payload: findRequest, @@ -373,6 +343,7 @@ describe("desktop preload browser API", () => { const states: BbDesktopBrowserState[] = []; const openTabs: BbDesktopBrowserOpenTabRequest[] = []; const scopedOpenTabs: BbDesktopBrowserScopedOpenTabRequest[] = []; + const focusedTabs: string[] = []; const snapshots: BbDesktopBrowserSnapshot[] = []; const findResults: BbDesktopBrowserFindResult[] = []; let closeWindowRequestCount = 0; @@ -416,6 +387,9 @@ describe("desktop preload browser API", () => { api.browser.onScopedOpenTab?.((request) => { scopedOpenTabs.push(request); }); + api.browser.onFocus?.((tabId) => { + focusedTabs.push(tabId); + }); api.browser.onSnapshot?.((nextSnapshot) => { snapshots.push(nextSnapshot); }); @@ -448,6 +422,10 @@ describe("desktop preload browser API", () => { channel: BB_DESKTOP_BROWSER_SCOPED_OPEN_TAB_CHANNEL, payload: { tabId: "", url: "https://example.com/scoped-popup" }, }); + emitIpcPayload({ + channel: BB_DESKTOP_BROWSER_FOCUSED_CHANNEL, + payload: { tabId: "", extra: true }, + }); emitIpcPayload({ channel: BB_DESKTOP_BROWSER_SNAPSHOT_CHANNEL, payload: { tabId: "browser:a", dataUrl: 42 }, @@ -476,6 +454,10 @@ describe("desktop preload browser API", () => { channel: BB_DESKTOP_BROWSER_SCOPED_OPEN_TAB_CHANNEL, payload: scopedOpenTab, }); + emitIpcPayload({ + channel: BB_DESKTOP_BROWSER_FOCUSED_CHANNEL, + payload: { tabId: "browser:a" }, + }); emitIpcPayload({ channel: BB_DESKTOP_BROWSER_SNAPSHOT_CHANNEL, payload: snapshot, @@ -508,6 +490,7 @@ describe("desktop preload browser API", () => { expect(states).toEqual([state]); expect(openTabs).toEqual([openTab]); expect(scopedOpenTabs).toEqual([scopedOpenTab]); + expect(focusedTabs).toEqual(["browser:a"]); expect(snapshots).toEqual([snapshot]); expect(findResults).toEqual([findResult]); expect(windowStates).toEqual([{ isFullScreen: true }]); diff --git a/apps/desktop/test/preload-build.test.ts b/apps/desktop/test/preload-build.test.ts index 1b00d5a924..fd3c17762a 100644 --- a/apps/desktop/test/preload-build.test.ts +++ b/apps/desktop/test/preload-build.test.ts @@ -127,10 +127,11 @@ async function startDesktopSmokeServer( pluginThemes: [], dataDir: args.dataDir, experiments: { - claudeCodeMockCliTraffic: false, + changelogPreview: false, editMessages: false, - newOnboarding: false, + mobileApp: false, providerSessionReaping: false, + timelineWindowing: false, }, featureFlags: { placeholder: false, diff --git a/apps/desktop/test/remote-server-load.test.ts b/apps/desktop/test/remote-server-load.test.ts new file mode 100644 index 0000000000..cb2acab621 --- /dev/null +++ b/apps/desktop/test/remote-server-load.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import { + describeServerUrl, + loadRemoteServerPage, + type LoadRemoteServerPageArgs, +} from "../src/remote-server-load.js"; + +type StartupErrorView = Parameters< + LoadRemoteServerPageArgs["loadStartupError"] +>[0]; + +interface TestHarness extends LoadRemoteServerPageArgs { + shownErrors: StartupErrorView[]; + warnings: string[]; +} + +// What `BrowserWindow.loadURL` rejects with when nothing answers at the target. +function createElectronLoadError(url: string): Error { + const error = new Error(`ERR_CONNECTION_REFUSED (-102) loading '${url}/'`); + error.stack = + `Error: ERR_CONNECTION_REFUSED (-102) loading '${url}/'\n` + + " at rejectAndCleanup (node:electron/js2c/browser_init:2:89743)\n" + + " at WebContents.finishListener (node:electron/js2c/browser_init:2:89905)\n" + + " at WebContents.emit (node:events:509:28)"; + return error; +} + +function createHarness( + overrides: Partial<LoadRemoteServerPageArgs> = {}, +): TestHarness { + const serverUrl = + overrides.serverUrl ?? "http://bb-host.tailnet.ts.net:38886"; + const shownErrors: StartupErrorView[] = []; + const warnings: string[] = []; + return { + isCurrent: () => true, + loadStartupError: async (view) => { + shownErrors.push(view); + }, + loadUrl: vi.fn(async () => { + throw createElectronLoadError(serverUrl); + }), + logWarning: (message) => { + warnings.push(message); + }, + serverUrl, + shownErrors, + warnings, + ...overrides, + }; +} + +describe("loadRemoteServerPage", () => { + it("turns an unreachable host into a named error view with the way out", async () => { + const harness = createHarness(); + + await expect(loadRemoteServerPage(harness)).resolves.toBe(false); + + expect(harness.shownErrors).toHaveLength(1); + const view = harness.shownErrors[0]; + expect(view?.title).toBe("Could not reach this bb server"); + expect(view?.details).toContain("http://bb-host.tailnet.ts.net:38886"); + expect(view?.details).toContain("Window ▸ Server"); + expect(view?.details).toContain("This Mac"); + expect(view?.details).not.toContain("rejectAndCleanup"); + expect(view?.details).not.toContain("node:electron"); + + expect(harness.warnings).toHaveLength(1); + expect(harness.warnings[0]).toContain("ERR_CONNECTION_REFUSED (-102)"); + }); + + it("keeps credentials and query tokens off the screen and out of the log", async () => { + const harness = createHarness({ + serverUrl: "https://user:hunter2@bb.example.com:8443/?token=s3cret", + }); + + await expect(loadRemoteServerPage(harness)).resolves.toBe(false); + + const details = harness.shownErrors[0]?.details ?? ""; + expect(details).toContain("https://bb.example.com:8443"); + expect(details).not.toContain("hunter2"); + expect(details).not.toContain("s3cret"); + const logged = harness.warnings[0] ?? ""; + expect(logged).toContain("https://bb.example.com:8443"); + expect(logged).not.toContain("hunter2"); + expect(logged).not.toContain("s3cret"); + }); + + it("shows nothing for a load the user already superseded", async () => { + const harness = createHarness({ isCurrent: () => false }); + + await expect(loadRemoteServerPage(harness)).resolves.toBe(false); + + expect(harness.shownErrors).toHaveLength(0); + expect(harness.warnings).toHaveLength(0); + }); + + it("reports a successful load without touching the error view", async () => { + const harness = createHarness({ loadUrl: vi.fn(async () => {}) }); + + await expect(loadRemoteServerPage(harness)).resolves.toBe(true); + + expect(harness.shownErrors).toHaveLength(0); + expect(harness.warnings).toHaveLength(0); + }); +}); + +describe("describeServerUrl", () => { + it("names only the origin", () => { + expect( + describeServerUrl("http://user:pw@host.ts.net:38886/app?token=x#y"), + ).toBe("the bb server at http://host.ts.net:38886"); + }); + + it("falls back to a generic label for an unparseable URL", () => { + expect(describeServerUrl("not a url")).toBe("the saved bb server"); + }); +}); diff --git a/apps/desktop/test/window-state.test.ts b/apps/desktop/test/window-state.test.ts index f31edb72d7..4760eaa909 100644 --- a/apps/desktop/test/window-state.test.ts +++ b/apps/desktop/test/window-state.test.ts @@ -5,13 +5,10 @@ import { afterEach, describe, expect, it } from "vitest"; import { hasVisibleArea, readPersistedWindowStateEntries, - readPersistedWindowState, restoreWindowState, writePersistedWindowStateEntries, - writePersistedWindowState, } from "../src/window-state.js"; import type { - DefaultWindowState, DisplayWorkArea, PersistedWindowStateEntry, PersistedWindowState, @@ -30,7 +27,7 @@ const displayWorkAreas: DisplayWorkArea[] = [ }, ]; -const defaultState: DefaultWindowState = { +const defaultState: PersistedWindowState = { bounds: { height: 900, width: 1280, @@ -116,33 +113,6 @@ describe("window state helpers", () => { ).toBe(false); }); - it("persists and reads window state from disk", async () => { - const tempDir = await createTempDir(); - const persistedState: PersistedWindowState = { - bounds: { - height: 720, - width: 1100, - x: 40, - y: 60, - }, - isFullScreen: false, - isMaximized: true, - }; - - await writePersistedWindowState({ - state: persistedState, - stateKey: "main", - userDataPath: tempDir.path, - }); - - await expect( - readPersistedWindowState({ - stateKey: "main", - userDataPath: tempDir.path, - }), - ).resolves.toEqual(persistedState); - }); - it("persists and reads multiple window states across restart", async () => { const tempDir = await createTempDir(); const persistedStates: PersistedWindowStateEntry[] = [ diff --git a/apps/host-daemon/package.json b/apps/host-daemon/package.json index 55dd10811b..3d809afbfe 100644 --- a/apps/host-daemon/package.json +++ b/apps/host-daemon/package.json @@ -13,7 +13,6 @@ "scripts": { "build": "node ../../scripts/build-node-entry.mjs src/index.ts dist/index.js --clean-dist --external ./start-host-daemon.js && node ../../scripts/build-node-entry.mjs src/start-host-daemon.ts dist/start-host-daemon.js && node ../../scripts/build-node-entry.mjs src/plugin-host-worker.ts dist/plugin-host-worker.js && node ./scripts/build-bundles.mjs", "bundle": "node ./scripts/build-bundles.mjs", - "bundle:check": "node ./scripts/check-bundles.mjs", "start": "node dist/index.js", "start:prod": "cross-env NODE_ENV=production node dist/index.js", "dev": "node --conditions=source --import tsx scripts/dev-supervisor.mjs", @@ -34,6 +33,7 @@ "@bb/host-workspace": "workspace:*", "@bb/logger": "workspace:*", "@bb/process-utils": "workspace:*", + "@bb/provider-bridge-protocol": "workspace:*", "@bb/templates": "workspace:*", "@bb/tunnel-client": "workspace:*", "@bb/tunnel-contract": "workspace:*", @@ -61,6 +61,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@bb/plugin-build": "workspace:*", "@bb/scripts": "workspace:*", "@bb/test-helpers": "workspace:*", "@bb/tsconfig": "workspace:*", diff --git a/apps/host-daemon/scripts/bundle-manifest.mjs b/apps/host-daemon/scripts/bundle-manifest.mjs index 1c85d9281c..72c56a03b2 100644 --- a/apps/host-daemon/scripts/bundle-manifest.mjs +++ b/apps/host-daemon/scripts/bundle-manifest.mjs @@ -5,7 +5,7 @@ const scriptsDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(scriptsDir, ".."); const workspaceRoot = resolve(packageRoot, "..", ".."); -export const NODE_ESM_REQUIRE_BANNER = [ +const NODE_ESM_REQUIRE_BANNER = [ 'import { createRequire as __createRequire } from "node:module";', 'import { dirname as __pathDirname } from "node:path";', 'import { fileURLToPath as __fileURLToPath } from "node:url";', diff --git a/apps/host-daemon/scripts/check-bundles.mjs b/apps/host-daemon/scripts/check-bundles.mjs deleted file mode 100644 index 04f482ba7f..0000000000 --- a/apps/host-daemon/scripts/check-bundles.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import { execFile } from "node:child_process"; -import { readFile, stat } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { promisify } from "node:util"; -import { bundleTargets } from "./bundle-manifest.mjs"; - -const execFileAsync = promisify(execFile); -const scriptsDir = dirname(fileURLToPath(import.meta.url)); -const packageRoot = resolve(scriptsDir, ".."); - -async function main() { - let totalBytes = 0; - - for (const target of bundleTargets) { - await execFileAsync("node", ["--check", target.outfile]); - const bundleStats = await stat(target.outfile); - totalBytes += bundleStats.size; - console.log(`${target.label}: syntax ok (${bundleStats.size} bytes)`); - - const requiredLiterals = target.requiredLiterals ?? []; - if (requiredLiterals.length > 0) { - const bundleSource = await readFile(target.outfile, "utf8"); - const missing = requiredLiterals.filter( - (literal) => !bundleSource.includes(literal), - ); - if (missing.length > 0) { - throw new Error( - `${target.label}: bundle is missing required literals: ${missing.join(", ")}`, - ); - } - console.log( - `${target.label}: ${requiredLiterals.length} required literals present`, - ); - } - } - - const importTargets = [ - { - label: "daemon entry", - path: resolve(packageRoot, "dist", "index.js"), - }, - { - label: "daemon bundle", - path: bundleTargets.find((target) => target.label === "daemon")?.outfile, - }, - ]; - - for (const target of importTargets) { - if (!target.path) { - throw new Error(`Missing ${target.label} import target`); - } - await import(pathToFileURL(target.path).href); - console.log(`${target.label}: runtime import ok`); - } - - console.log(`total bundle size: ${totalBytes} bytes`); -} - -void main().catch((error) => { - const message = - error instanceof Error ? (error.stack ?? error.message) : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; -}); diff --git a/apps/host-daemon/src/app.test.ts b/apps/host-daemon/src/app.test.ts index d7e482262d..bc3689a9ea 100644 --- a/apps/host-daemon/src/app.test.ts +++ b/apps/host-daemon/src/app.test.ts @@ -14,6 +14,7 @@ import { type HostDaemonInteractiveRequestResponse, } from "@bb/host-daemon-contract"; import type { HostWatcher } from "@bb/host-watcher"; +import { createDeferredPromise } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DISPATCH_TEST_BRIDGE_LAUNCH, @@ -63,12 +64,6 @@ interface HostDaemonAppFixture { runtimeOptions: RuntimeOptionsRef; } -interface Deferred<T> { - promise: Promise<T>; - reject(error: Error): void; - resolve(value: T): void; -} - type StartIdleProviderSessionReaperArgsForTest = Parameters< typeof startIdleProviderSessionReaper >[0]; @@ -84,23 +79,6 @@ function createLogger() { } satisfies HostDaemonLogger; } -function createDeferred<T>(): Deferred<T> { - let resolveFn: ((value: T) => void) | null = null; - let rejectFn: ((error: Error) => void) | null = null; - const promise = new Promise<T>((resolve, reject) => { - resolveFn = resolve; - rejectFn = reject; - }); - if (!resolveFn || !rejectFn) { - throw new Error("Failed to create deferred promise"); - } - return { - promise, - reject: rejectFn, - resolve: resolveFn, - }; -} - async function makeTempDir(prefix: string): Promise<string> { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); tempDirs.push(dir); @@ -272,6 +250,18 @@ function createFakeRuntime(): AgentRuntime { selectedOnlyModels: [], }; }, + async providerHealth() { + return { supported: false as const }; + }, + async providerUsage() { + return { supported: false as const }; + }, + async providerInstallationStatus() { + throw new Error("Unexpected provider installation status call"); + }, + async providerInstallationRun() { + throw new Error("Unexpected provider installation run call"); + }, listRunningProviders() { return []; }, @@ -593,7 +583,7 @@ describe("createHostDaemonApp", () => { it("runs the idle provider session reaper on a non-overlapping interval", async () => { const logger = createLogger(); const firstReap = - createDeferred<RuntimeManagerReapIdleProviderSessionsResult>(); + createDeferredPromise<RuntimeManagerReapIdleProviderSessionsResult>(); const failure = new Error("reaper failed"); const queuedReaps: Array< () => Promise<RuntimeManagerReapIdleProviderSessionsResult> @@ -802,6 +792,7 @@ describe("createHostDaemonApp", () => { .filter((request) => request.pathname === "/internal/session/open") .map((request) => JSON.parse(request.body ?? "{}")); expect(openSessionBody[0]).toMatchObject({ + localApiPort: null, loadedEnvironments: [{ environmentId: "env-app-retired" }], }); } finally { diff --git a/apps/host-daemon/src/app.ts b/apps/host-daemon/src/app.ts index 3b5cac969a..0eeed2ddb8 100644 --- a/apps/host-daemon/src/app.ts +++ b/apps/host-daemon/src/app.ts @@ -11,7 +11,7 @@ import { } from "./interactive-request-registry.js"; import { startEventLoopStallMonitor } from "./event-loop-stall-monitor.js"; import { startHostDaemonHealthMonitor } from "./host-daemon-health-monitor.js"; -import { shutdownDefaultListModelsRuntimes } from "./command-dispatch-support.js"; +import { shutdownDefaultProviderMaintenanceRuntimes } from "./command-dispatch-support.js"; import { startLocalApiServer, type LocalApiServer } from "./local-api.js"; import type { HostDaemonLocalApiConfig } from "./local-api-config.js"; import type { HostDaemonLogger } from "./logger.js"; @@ -102,7 +102,7 @@ interface StartIdleProviderSessionReaperArgs { setIntervalFn: IdleProviderSessionReaperIntervalFn; } -export interface CreateHostDaemonAppOptions { +interface CreateHostDaemonAppOptions { dataDir: string; serverUrl: string; hostKey: string; @@ -117,7 +117,6 @@ export interface CreateHostDaemonAppOptions { machineCredential?: string; connectMachineId?: string; autoUpdate?: boolean; - installUpdateTarball?: (tarballPath: string) => Promise<void>; releaseLock: () => Promise<void>; localApiConfig: HostDaemonLocalApiConfig | null; createRuntime?: RuntimeManagerOptions["createRuntime"]; @@ -127,7 +126,6 @@ export interface CreateHostDaemonAppOptions { NonNullable<AgentRuntimeOptions["shellEnv"]> >; nowMs?: () => number; - threadStorageRootPath?: string; hostWatcher?: HostWatcher; onToolCall?: (request: ToolCallRequest) => Promise<ToolCallResponse>; fetchFn?: FetchFn; @@ -230,12 +228,7 @@ interface MaybeInvalidateSessionArgs { export async function createHostDaemonApp( options: CreateHostDaemonAppOptions, ): Promise<HostDaemonApp> { - const threadStorageRootPath = await ensureThreadStorageRoot( - options.dataDir, - options.threadStorageRootPath - ? { configuredRoot: options.threadStorageRootPath } - : {}, - ); + const threadStorageRootPath = await ensureThreadStorageRoot(options.dataDir); const dataDirSkillsRootPath = await ensureDataDirSkillsRootPath( options.dataDir, ); @@ -298,13 +291,13 @@ export async function createHostDaemonApp( async function flushThreadEventsBeforeInteractiveRegistration(): Promise<void> { // Interactive registration creates server-owned turn-scoped timeline state, // so the server must first observe the provider turn/started for that turn. - await eventSink.flushRequired(); + await eventSink.flush(); } async function flushThreadEventsBeforeToolCall(): Promise<void> { // Dynamic tool calls can append server-owned turn-scoped events, so the // server must first observe any provider turn/started already emitted. - await eventSink.flushRequired(); + await eventSink.flush(); } const serverClient = createServerClient({ @@ -440,6 +433,7 @@ export async function createHostDaemonApp( hostWatcher: options.hostWatcher, refreshWorkspace: (args) => runtimeManager.refreshEnvironmentWorkspace(args), + shellEnv: () => runtimeManager.getShellEnv(), threadStorageRootPath, onThreadStorageChanged: ({ environmentId }) => { sendServerMessage({ @@ -627,14 +621,6 @@ export async function createHostDaemonApp( throw error; } }, - onStderr: (line) => { - if (line.includes('"component":"claude-code-mock-cli-traffic-proxy"')) { - options.logger.info( - { providerStderr: line }, - "Claude Code mock CLI traffic proxy request", - ); - } - }, onProcessExit: (info) => { const threadIds = info.threads.map((thread) => thread.threadId); if (!info.expected && info.stderr) { @@ -782,10 +768,38 @@ export async function createHostDaemonApp( terminalManager, listModels: async (args) => { await refreshRuntimeShellEnv(); - const runtime = await runtimeManager.ensureProviderMaintenanceRuntime({ - dataDir: options.dataDir, - }); - return runtime.listModels(args); + return runtimeManager.withProviderMaintenanceRuntime( + { dataDir: options.dataDir }, + (runtime) => runtime.listModels(args), + ); + }, + providerHealth: async (args) => { + await refreshRuntimeShellEnv(); + return runtimeManager.withProviderMaintenanceRuntime( + { dataDir: options.dataDir }, + (runtime) => runtime.providerHealth(args), + ); + }, + providerUsage: async (args) => { + await refreshRuntimeShellEnv(); + return runtimeManager.withProviderMaintenanceRuntime( + { dataDir: options.dataDir }, + (runtime) => runtime.providerUsage(args), + ); + }, + providerInstallationStatus: async (args) => { + await refreshRuntimeShellEnv(); + return runtimeManager.withProviderMaintenanceRuntime( + { dataDir: options.dataDir }, + (runtime) => runtime.providerInstallationStatus(args), + ); + }, + providerInstallationRun: async (args) => { + await refreshRuntimeShellEnv(); + return runtimeManager.withProviderMaintenanceRuntime( + { dataDir: options.dataDir }, + (runtime) => runtime.providerInstallationRun(args), + ); }, resolveInteractiveRequest: async (request) => { interactiveRequestRegistry.resolve(request); @@ -809,6 +823,7 @@ export async function createHostDaemonApp( hostType: options.hostType, dataDir: options.dataDir, instanceId: options.instanceId, + localApiPort: options.localApiConfig?.port ?? null, logger: options.logger, machineCredential: options.machineCredential, connectMachineId: options.connectMachineId, @@ -817,7 +832,6 @@ export async function createHostDaemonApp( dataDir: options.dataDir, enabled: options.autoUpdate ?? false, fetchFn: options.fetchFn, - installTarball: options.installUpdateTarball, logger: options.logger, serverUrl: options.serverUrl, }), @@ -905,6 +919,7 @@ export async function createHostDaemonApp( devAppPort: options.devAppPort, appUrl: options.appUrl, getConnected: () => connection.sessionId != null, + shellEnv: () => runtimeManager.getShellEnv(), }) : null; const eventLoopStallMonitor = startEventLoopStallMonitor({ @@ -946,7 +961,7 @@ export async function createHostDaemonApp( await runtimeManager.shutdownAll(); await eventSink.flush(); await eventSink.dispose(); - await shutdownDefaultListModelsRuntimes(); + await shutdownDefaultProviderMaintenanceRuntimes(); await connection.shutdown(); }, onStart: async () => { diff --git a/apps/host-daemon/src/codex-auth.ts b/apps/host-daemon/src/codex-auth.ts index f556348161..cc137e0ab9 100644 --- a/apps/host-daemon/src/codex-auth.ts +++ b/apps/host-daemon/src/codex-auth.ts @@ -56,7 +56,7 @@ function optionalBoolean(value: JsonValue | undefined): boolean | null { return typeof value === "boolean" ? value : null; } -function parseJsonValue(raw: string): JsonValue { +export function parseJsonValue(raw: string): JsonValue { return jsonValueSchema.parse(JSON.parse(raw)); } diff --git a/apps/host-daemon/src/codex-chatgpt-client.test.ts b/apps/host-daemon/src/codex-chatgpt-client.test.ts index 17f100ccdb..db89034e63 100644 --- a/apps/host-daemon/src/codex-chatgpt-client.test.ts +++ b/apps/host-daemon/src/codex-chatgpt-client.test.ts @@ -690,6 +690,97 @@ describe("Codex ChatGPT client", () => { expect(retryHeaders.get("authorization")).toBe(`Bearer ${accessToken}`); }); + it("classifies a persistent Cloudflare challenge as transient without leaking the challenge page", async () => { + const homeDir = await makeTempHome(); + const accessToken = createAccessToken({ + accountId: "account-123", + expSeconds: Math.floor(Date.now() / 1000) + 3600, + }); + await writeCodexAuth({ + homeDir, + accessToken, + refreshToken: "refresh-token", + }); + const fetchMock = setupFetchMock(); + fetchMock.mockImplementation( + async () => + new Response( + `<html>\n<head>\n<meta name="viewport" content="width=device-width" />\n<title>Just a moment...\n\n${"x".repeat(2000)}`, + { + status: 403, + headers: { + "content-type": "text/html; charset=UTF-8", + "cf-mitigated": "challenge", + server: "cloudflare", + "set-cookie": + "__cf_bm=cloudflare-cookie; Path=/; Secure; HttpOnly", + }, + }, + ), + ); + + let thrown: Error | null = null; + try { + await transcribeCodexVoice({ + type: "codex.voice.transcribe", + model: "gpt-4o-mini-transcribe", + audioBase64: Buffer.from("audio").toString("base64"), + mimeType: "audio/webm", + filename: "prompt.webm", + prompt: null, + timeoutMs: 30000, + }); + } catch (error) { + if (!(error instanceof Error)) { + throw new Error("Expected Error from challenged transcription"); + } + thrown = error; + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(thrown).toMatchObject({ + code: "codex_service_unavailable", + message: + "Codex transcription request failed with HTTP 403: chatgpt.com answered with a Cloudflare challenge that bb cannot solve. Retry, or set BB_TRANSCRIPTION to an openai/ model with OPENAI_API_KEY.", + }); + }); + + it("omits HTML error pages from Codex error messages", async () => { + const homeDir = await makeTempHome(); + const accessToken = createAccessToken({ + accountId: "account-123", + expSeconds: Math.floor(Date.now() / 1000) + 3600, + }); + await writeCodexAuth({ + homeDir, + accessToken, + refreshToken: "refresh-token", + }); + const fetchMock = setupFetchMock(); + fetchMock.mockResolvedValueOnce( + new Response("Access denied (error 1020)", { + status: 403, + headers: { "content-type": "text/html; charset=UTF-8" }, + }), + ); + + await expect( + transcribeCodexVoice({ + type: "codex.voice.transcribe", + model: "gpt-4o-mini-transcribe", + audioBase64: Buffer.from("audio").toString("base64"), + mimeType: "audio/webm", + filename: "prompt.webm", + prompt: null, + timeoutMs: 30000, + }), + ).rejects.toMatchObject({ + code: "codex_request_failed", + message: "Codex transcription request failed with HTTP 403", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("transcribes voice with Codex API key auth from ~/.codex/auth.json", async () => { const homeDir = await makeTempHome(); await writeCodexApiKeyAuth({ diff --git a/apps/host-daemon/src/codex-chatgpt-client.ts b/apps/host-daemon/src/codex-chatgpt-client.ts index 3889ce2529..e1f62eda0e 100644 --- a/apps/host-daemon/src/codex-chatgpt-client.ts +++ b/apps/host-daemon/src/codex-chatgpt-client.ts @@ -1,4 +1,4 @@ -import { jsonValueSchema, type JsonObject, type JsonValue } from "@bb/domain"; +import type { JsonObject, JsonValue } from "@bb/domain"; import type { HostDaemonCommand, HostDaemonCommandResult, @@ -9,6 +9,7 @@ import { storeChatGptCloudflareCookies, } from "./chatgpt-cloudflare-cookies.js"; import { + parseJsonValue, readCodexAuthCredentials, type CodexAuthCredentials, type CodexChatGptAuthCredentials, @@ -169,10 +170,6 @@ function optionalJsonArray(value: JsonValue | undefined): JsonValue[] | null { return Array.isArray(value) ? value : null; } -function parseJsonValue(raw: string): JsonValue { - return jsonValueSchema.parse(JSON.parse(raw)); -} - function isCloudflareChallenge(response: Response): boolean { return ( response.status === 403 && @@ -456,18 +453,42 @@ function extractProviderErrorMessage(rawText: string): string | null { } } +function isHtmlResponse(response: Response): boolean { + return ( + response.headers + .get("content-type") + ?.toLowerCase() + .startsWith("text/html") ?? false + ); +} + +const CODEX_API_KEY_ROUTE_HINT: Record = { + inference: "BB_INFERENCE", + transcription: "BB_TRANSCRIPTION", +}; + async function createCodexHttpError({ deadline, operation, response, }: CodexHttpErrorArgs): Promise { - const providerMessage = extractProviderErrorMessage( - await readErrorText(response, deadline), - ); + const prefix = `Codex ${operation} request failed with HTTP ${response.status}`; + if (isCloudflareChallenge(response)) { + // Cloudflare bot management decides per network and per request whether + // to challenge; a Node client cannot solve the JavaScript challenge, so + // the failure is transient and the HTML page is not a useful message. + return new ExpectedCommandDispatchError( + "codex_service_unavailable", + `${prefix}: chatgpt.com answered with a Cloudflare challenge that bb cannot solve. Retry, or set ${CODEX_API_KEY_ROUTE_HINT[operation]} to an openai/ model with OPENAI_API_KEY.`, + ); + } + const providerMessage = isHtmlResponse(response) + ? null + : extractProviderErrorMessage(await readErrorText(response, deadline)); const details = providerMessage ? `: ${providerMessage}` : ""; return new ExpectedCommandDispatchError( codexRequestErrorCode(response.status), - `Codex ${operation} request failed with HTTP ${response.status}${details}`, + `${prefix}${details}`, ); } diff --git a/apps/host-daemon/src/command-discovery.ts b/apps/host-daemon/src/command-discovery.ts index a8b262e6a7..86ec2971d6 100644 --- a/apps/host-daemon/src/command-discovery.ts +++ b/apps/host-daemon/src/command-discovery.ts @@ -20,32 +20,6 @@ const FRONTMATTER_DELIMITER = "---"; const MAX_SCAN_DEPTH = 24; const MAX_SCAN_ENTRY_COUNT = 1_000; -/** - * Scan shape for a root: - * - `skill`: one level of `//SKILL.md`; the command name is the - * parent directory name. User-origin skill entries/files may be symlinks - * because personal provider skill installs commonly use them; project-origin - * skill entry/file symlinks are skipped. - * - `skill-recursive`: every `SKILL.md` below ``; the command name is the - * name of the directory that contains the file. Symlinks are not followed. - * - `skill-directory`: a single `/SKILL.md` skill directory; the command - * name is the root directory name. - * - `skill-file`: a single `SKILL.md`; the command name comes from frontmatter - * `name`, with `fallbackName` when absent. This covers plugin-root skills. - * - `command`: recursive `/**​/*.md`; the command name is the path under - * the root with `/` replaced by `:` and the `.md` extension dropped - * (namespacing, e.g. `frontend/component.md` -> `frontend:component`). - * - `command-file`: a single command markdown file; the command name is the - * file name without `.md`. - */ -export type CommandScanShape = - | "skill" - | "skill-recursive" - | "skill-directory" - | "skill-file" - | "command" - | "command-file"; - interface CommandScanRootBase { /** Prefix prepended to the derived invocation name, e.g. `plugin-name:`. */ namePrefix: string; @@ -55,7 +29,7 @@ interface CommandScanRootBase { skillIdentitySeed?: string; } -export interface CommandScanDirectoryRoot extends CommandScanRootBase { +interface CommandScanDirectoryRoot extends CommandScanRootBase { /** Optional boundary that a project-origin recursive root must stay within. */ boundaryPath?: string; /** Absolute directory to scan. Missing dir -> no records (no throw). */ @@ -63,13 +37,13 @@ export interface CommandScanDirectoryRoot extends CommandScanRootBase { shape: "skill" | "skill-recursive" | "skill-directory" | "command"; } -export interface CommandScanFileRoot extends CommandScanRootBase { +interface CommandScanFileRoot extends CommandScanRootBase { /** Absolute file to scan. Missing file -> no record (no throw). */ filePath: string; shape: "command-file"; } -export interface CommandScanSkillFileRoot extends CommandScanRootBase { +interface CommandScanSkillFileRoot extends CommandScanRootBase { /** Fallback command name used when the file has no frontmatter `name`. */ fallbackName: string; /** Absolute SKILL.md file to scan. Missing file -> no record (no throw). */ @@ -78,12 +52,30 @@ export interface CommandScanSkillFileRoot extends CommandScanRootBase { source: "skill"; } +/** + * Scan shape for a root: + * - `skill`: one level of `//SKILL.md`; the command name is the + * parent directory name. User-origin skill entries/files may be symlinks + * because personal provider skill installs commonly use them; project-origin + * skill entry/file symlinks are skipped. + * - `skill-recursive`: every `SKILL.md` below ``; the command name is the + * name of the directory that contains the file. Symlinks are not followed. + * - `skill-directory`: a single `/SKILL.md` skill directory; the command + * name is the root directory name. + * - `skill-file`: a single `SKILL.md`; the command name comes from frontmatter + * `name`, with `fallbackName` when absent. This covers plugin-root skills. + * - `command`: recursive `/**​/*.md`; the command name is the path under + * the root with `/` replaced by `:` and the `.md` extension dropped + * (namespacing, e.g. `frontend/component.md` -> `frontend:component`). + * - `command-file`: a single command markdown file; the command name is the + * file name without `.md`. + */ export type CommandScanRoot = | CommandScanDirectoryRoot | CommandScanFileRoot | CommandScanSkillFileRoot; -export interface DiscoverProviderCommandsArgs { +interface DiscoverProviderCommandsArgs { roots: readonly CommandScanRoot[]; } @@ -352,7 +344,7 @@ async function walkMarkdownTree(args: WalkMarkdownTreeArgs): Promise { } } -function isPathWithinDirectory( +export function isPathWithinDirectory( directoryPath: string, candidatePath: string, ): boolean { @@ -542,7 +534,7 @@ export type SkillScanRoot = CommandScanRoot & { rootKind: SkillRootKind; }; -export interface DiscoverSkillsArgs { +interface DiscoverSkillsArgs { roots: readonly SkillScanRoot[]; } diff --git a/apps/host-daemon/src/command-dispatch-support.test.ts b/apps/host-daemon/src/command-dispatch-support.test.ts index e0529fc356..0238338c6d 100644 --- a/apps/host-daemon/src/command-dispatch-support.test.ts +++ b/apps/host-daemon/src/command-dispatch-support.test.ts @@ -21,7 +21,7 @@ import { defaultListModels, getErrorCode, isExpectedOnlineRpcFailureError, - shutdownDefaultListModelsRuntimes, + shutdownDefaultProviderMaintenanceRuntimes, } from "./command-dispatch-support.js"; interface MakeModelArgs { @@ -72,6 +72,18 @@ function makeRuntime(args: MakeRuntimeArgs): AgentRuntime { async archiveThread() {}, async unarchiveThread() {}, listModels: args.listModels, + async providerHealth() { + return { supported: false as const }; + }, + async providerUsage() { + return { supported: false as const }; + }, + async providerInstallationStatus() { + throw new Error("Unexpected provider installation status call"); + }, + async providerInstallationRun() { + throw new Error("Unexpected provider installation run call"); + }, listRunningProviders() { return []; }, @@ -102,7 +114,7 @@ function makeRuntime(args: MakeRuntimeArgs): AgentRuntime { describe("command dispatch support", () => { afterEach(async () => { - await shutdownDefaultListModelsRuntimes(); + await shutdownDefaultProviderMaintenanceRuntimes(); }); beforeEach(() => { @@ -176,7 +188,7 @@ describe("command dispatch support", () => { expect(listModels).toHaveBeenCalledTimes(2); expect(shutdowns).toEqual([]); - await shutdownDefaultListModelsRuntimes(); + await shutdownDefaultProviderMaintenanceRuntimes(); expect(shutdowns).toEqual(["runtime"]); }); @@ -265,7 +277,7 @@ describe("command dispatch support", () => { ).resolves.toMatchObject({ models: [{ id: "second" }] }); expect(createAgentRuntimeMock).toHaveBeenCalledTimes(2); - await shutdownDefaultListModelsRuntimes(); + await shutdownDefaultProviderMaintenanceRuntimes(); expect(shutdowns).toEqual(["first", "second"]); }); }); diff --git a/apps/host-daemon/src/command-dispatch-support.ts b/apps/host-daemon/src/command-dispatch-support.ts index c309b5333e..8c4681c22c 100644 --- a/apps/host-daemon/src/command-dispatch-support.ts +++ b/apps/host-daemon/src/command-dispatch-support.ts @@ -4,21 +4,25 @@ import { bridgeLaunchProcessKey, type AgentRuntime, type AgentRuntimeBridgeLaunch, - type AgentRuntimeOptions, } from "@bb/agent-runtime"; import type { AvailableModel } from "@bb/domain"; import type { EventSinkInput } from "./event-sink.js"; import type { HostDaemonCommand, HostDaemonAcpLaunchSpec, + ProviderHealthResult, + ProviderUsageResult, HostDaemonBridgeLaunch, HostDaemonInjectedSkillSource, HostDaemonOnlineRpcCommand, HostDaemonConnectTunnelIdentity, - ProviderCliInstallRequest, - ProviderCliStatus, WorkspaceContext, } from "@bb/host-daemon-contract"; +import type { + ExperimentalProviderInstallationCommand, + ExperimentalProviderInstallationRunResult, + ExperimentalProviderInstallationStatus, +} from "@bb/provider-bridge-protocol"; import { getPersonalWorkspaceRoot } from "@bb/host-workspace"; import { ensurePluginProcessDataDir } from "@bb/process-utils"; import type { InteractiveResolveCommandInput } from "./interactive-request-registry.js"; @@ -67,12 +71,37 @@ export interface CommandDispatchOptions { models: AvailableModel[]; selectedOnlyModels: AvailableModel[]; }>; - getProviderCliStatusForProvider?: ( - providerId: string, - ) => Promise; - streamProviderCliInstall?: ( - args: ProviderCliInstallRequest & { env?: NodeJS.ProcessEnv }, - ) => ReadableStream; + providerHealth?: (args: { + providerId: string; + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd?: string; + }) => Promise; + providerUsage?: (args: { + providerId: string; + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd?: string; + }) => Promise; + providerInstallationStatus?: (args: { + providerId: string; + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd?: string; + requirement?: "thread_rewind"; + }) => Promise; + providerInstallationRun?: (args: { + providerId: string; + action: "install" | "update"; + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd?: string; + }) => Promise; + streamProviderInstallation?: (args: { + providerId: string; + plan: ExperimentalProviderInstallationCommand; + env?: NodeJS.ProcessEnv; + }) => ReadableStream; resolveInteractiveRequest?: ( request: InteractiveResolveCommandInput, ) => Promise; @@ -140,6 +169,8 @@ export async function resolveRuntimeBridgeLaunch( ...bridgeLaunch.capabilities, permissionModes: [...bridgeLaunch.capabilities.permissionModes], }; + const providerOptions = { ...bridgeLaunch.providerOptions }; + const envPassthrough = [...bridgeLaunch.envPassthrough]; // Every bridge, artifact or bundled, is scoped to the plugin that ships it: // it gets that plugin's own persistent directory, the same one the plugin's // host worker would get, under its own `bridge-data` kind. @@ -154,6 +185,8 @@ export async function resolveRuntimeBridgeLaunch( dataDir, source: { ...bridgeLaunch.source }, capabilities, + providerOptions, + envPassthrough, }; } if (options.fetchPluginHostArtifact === undefined) { @@ -179,38 +212,53 @@ export async function resolveRuntimeBridgeLaunch( artifactPath, }, capabilities, + providerOptions, + envPassthrough, }; } -const defaultModelListRuntimes = new Map(); +const defaultProviderMaintenanceRuntimes = new Map(); -export async function shutdownDefaultListModelsRuntimes(): Promise { - const runtimes = [...defaultModelListRuntimes.values()]; - defaultModelListRuntimes.clear(); +export async function shutdownDefaultProviderMaintenanceRuntimes(): Promise { + const runtimes = [...defaultProviderMaintenanceRuntimes.values()]; + defaultProviderMaintenanceRuntimes.clear(); await Promise.all(runtimes.map((runtime) => runtime.shutdown())); } -export async function defaultListModels( - args: { - providerId: string; - acpLaunchSpec?: HostDaemonAcpLaunchSpec; - bridgeLaunch: AgentRuntimeBridgeLaunch; - }, - options: { bridgeBundleDir?: AgentRuntimeOptions["bridgeBundleDir"] } = {}, -): Promise<{ +export async function defaultListModels(args: { + providerId: string; + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; +}): Promise<{ models: AvailableModel[]; selectedOnlyModels: AvailableModel[]; }> { + const runtime = defaultProviderMaintenanceRuntime(args); + try { + return await runtime.listModels(args); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Unsupported provider") + ) { + throw new CommandDispatchError("unknown_provider", error.message); + } + throw error; + } +} + +function defaultProviderMaintenanceRuntime(args: { + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; +}): AgentRuntime { const runtimeKey = - `${options.bridgeBundleDir ?? ""}` + `#bridge:${bridgeLaunchProcessKey(args.bridgeLaunch)}` + (args.acpLaunchSpec !== undefined ? `#acp:${fingerprintAcpLaunchSpec(args.acpLaunchSpec)}` : ""); - let runtime = defaultModelListRuntimes.get(runtimeKey); + let runtime = defaultProviderMaintenanceRuntimes.get(runtimeKey); if (!runtime) { runtime = createAgentRuntime({ - bridgeBundleDir: options.bridgeBundleDir, workspacePath: process.cwd(), onEvent: () => {}, onToolCall: async () => ({ @@ -218,19 +266,49 @@ export async function defaultListModels( success: true, }), }); - defaultModelListRuntimes.set(runtimeKey, runtime); - } - try { - return await runtime.listModels(args); - } catch (error) { - if ( - error instanceof Error && - error.message.startsWith("Unsupported provider") - ) { - throw new CommandDispatchError("unknown_provider", error.message); - } - throw error; + defaultProviderMaintenanceRuntimes.set(runtimeKey, runtime); } + return runtime; +} + +export async function defaultProviderHealth(args: { + providerId: string; + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd?: string; +}): Promise { + return await defaultProviderMaintenanceRuntime(args).providerHealth(args); +} + +export async function defaultProviderUsage(args: { + providerId: string; + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd?: string; +}): Promise { + return await defaultProviderMaintenanceRuntime(args).providerUsage(args); +} + +export async function defaultProviderInstallationStatus(args: { + providerId: string; + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd?: string; + requirement?: "thread_rewind"; +}): Promise { + const runtime = defaultProviderMaintenanceRuntime(args); + return await runtime.providerInstallationStatus(args); +} + +export async function defaultProviderInstallationRun(args: { + providerId: string; + action: "install" | "update"; + acpLaunchSpec?: HostDaemonAcpLaunchSpec; + bridgeLaunch: AgentRuntimeBridgeLaunch; + cwd?: string; +}): Promise { + const runtime = defaultProviderMaintenanceRuntime(args); + return await runtime.providerInstallationRun(args); } export function getErrorCode(error: unknown): string { diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts index 8ae5e5c327..1a8f16f863 100644 --- a/apps/host-daemon/src/command-dispatch.test.ts +++ b/apps/host-daemon/src/command-dispatch.test.ts @@ -8,6 +8,7 @@ import type { ProviderCliStatus, } from "@bb/host-daemon-contract"; import type { HostWorkspace } from "@bb/host-workspace"; +import { createDeferredPromise } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; import { dispatchCommand, @@ -15,6 +16,7 @@ import { } from "./command-dispatch.js"; import { DISPATCH_TEST_BRIDGE_LAUNCH, + DISPATCH_TEST_RUNTIME_BRIDGE_LAUNCH, silentLogger, } from "../test/command/dispatch-helpers.js"; import type { CommandOf } from "./command-dispatch-support.js"; @@ -22,12 +24,6 @@ import { RuntimeManager } from "./runtime-manager.js"; const WORKSPACE_PATH = "/tmp/bb-command-dispatch-test"; -interface Deferred { - promise: Promise; - resolve: (value: TValue | PromiseLike) => void; - reject: (reason?: Error) => void; -} - interface WriteInjectedSkillSourceArgs { dataDir: string; token: string; @@ -124,16 +120,6 @@ async function setupBusySkillCatalogEnvironment(args: { }; } -function createDeferred(): Deferred { - let resolve!: Deferred["resolve"]; - let reject!: Deferred["reject"]; - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve; - reject = innerReject; - }); - return { promise, reject, resolve }; -} - async function unexpectedWorkspaceCall(): Promise { throw new Error("Unexpected workspace call"); } @@ -156,11 +142,9 @@ function createWorkspace(workspacePath = WORKSPACE_PATH): HostWorkspace { diffPatch: unexpectedWorkspaceCall, getPullRequest: unexpectedWorkspaceCall, runPullRequestAction: unexpectedWorkspaceCall, - listBranches: unexpectedWorkspaceCall, listFiles: unexpectedWorkspaceCall, commit: unexpectedWorkspaceCall, reset: unexpectedWorkspaceCall, - fetch: unexpectedWorkspaceCall, squashMerge: unexpectedWorkspaceCall, destroy: vi.fn(async () => undefined), }; @@ -204,6 +188,14 @@ function createRuntime(): FakeDispatchRuntime { models: [], selectedOnlyModels: [], })), + providerHealth: vi.fn(async () => ({ supported: false as const })), + providerUsage: vi.fn(async () => ({ supported: false as const })), + providerInstallationStatus: vi.fn(async () => { + throw new Error("Unexpected provider installation status call"); + }), + providerInstallationRun: vi.fn(async () => { + throw new Error("Unexpected provider installation run call"); + }), listRunningProviders: vi.fn(() => ["fake"]), getActiveTurnId: (threadId) => activeTurnsByThreadId.get(threadId) ?? null, waitForActiveTurn: vi.fn( @@ -215,7 +207,7 @@ function createRuntime(): FakeDispatchRuntime { : null, reapIdleProviderSessions: vi.fn(async () => ({ reapedSessions: [] })), hasThread: (threadId) => hostedThreadIds.has(threadId), - getLiveThreadIds: () => [...activeTurnsByThreadId.keys()], + getLiveThreadIds: vi.fn(() => [...activeTurnsByThreadId.keys()]), hasOpenBackgroundWork: () => false, shutdown: vi.fn(async () => undefined), setActiveTurn: (threadId, turnId) => { @@ -229,6 +221,44 @@ function createRuntime(): FakeDispatchRuntime { }; } +function createTurnSubmitCommand( + target: CommandOf<"turn.submit">["target"], +): CommandOf<"turn.submit"> { + return { + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + type: "turn.submit", + environmentId: "env-1", + threadId: "thread-1", + requestId: "creq_turn_submit", + input: [{ type: "text", text: "follow up", mentions: [] }], + options: { + model: "gpt-5", + serviceTier: "default", + reasoningLevel: "medium", + providerOptions: {}, + permissionMode: "full", + permissionScope: "full", + approvalReviewer: null, + permissionEscalation: null, + }, + resumeContext: { + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + workspaceContext: { + workspacePath: WORKSPACE_PATH, + workspaceProvisionType: "unmanaged", + }, + projectId: "proj_1", + providerId: "codex", + providerThreadId: "provider-thread-1", + instructions: "Be concise.", + dynamicTools: [], + injectedSkillSources: [], + instructionMode: "append", + }, + target, + }; +} + function createProviderCliInstallEventStream( events: readonly ProviderCliInstallEvent[], ): ReadableStream { @@ -261,7 +291,6 @@ function claudeCodeStatus(args: { installAction: { kind: "update", label: "Update", - commandKind: "exec", command: "claude update", }, needsUpdate: @@ -270,6 +299,24 @@ function claudeCodeStatus(args: { }; } +function supportedCodexInstallationStatus(): ProviderCliStatus { + return { + displayName: "Codex", + executableName: "codex", + executablePath: "/usr/local/bin/codex", + installed: true, + installSource: "npmGlobal", + currentVersion: "0.146.0", + latestVersion: null, + minimumSupportedVersion: "0.136.0", + npmPackageName: "@openai/codex", + npmGlobalPackageVersion: "0.146.0", + installAction: null, + needsUpdate: false, + versionUnsupported: false, + }; +} + async function runSuccessfulClaudeCodeUpdateVerification(args: { before: ProviderCliStatus; after: ProviderCliStatus; @@ -280,19 +327,16 @@ async function runSuccessfulClaudeCodeUpdateVerification(args: { createRuntime, provisionWorkspace: async () => createWorkspace(), }); - const getProviderCliStatusForProvider = vi - .fn() - .mockResolvedValueOnce(args.before) - .mockResolvedValueOnce(args.after); + const providerInstallationStatus = vi.fn().mockResolvedValueOnce(args.after); const events: ProviderCliInstallEvent[] = [ { type: "started", - provider: "claudeCode", + provider: "claude-code", command: "claude update", }, { type: "completed", - provider: "claudeCode", + provider: "claude-code", exitCode: 0, signal: null, success: true, @@ -300,9 +344,10 @@ async function runSuccessfulClaudeCodeUpdateVerification(args: { ]; const result = await dispatchOnlineRpcCommand( { - type: "provider_cli.install", - provider: "claudeCode", - actionKind: "update", + type: "provider.installation.run", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + providerId: "claude-code", + action: "update", }, { dataDir, @@ -314,17 +359,184 @@ async function runSuccessfulClaudeCodeUpdateVerification(args: { fetchProjectAttachment: async () => { throw new Error("Unexpected project attachment fetch"); }, - getProviderCliStatusForProvider, + providerInstallationStatus, + providerInstallationRun: async () => ({ + available: true, + command: { + command: "claude", + args: ["update"], + displayCommand: "claude update", + }, + verification: + args.before.latestVersion === null + ? { + kind: "version_changed", + previousVersion: args.before.currentVersion ?? "unknown", + } + : { + kind: "version_at_least", + version: args.before.latestVersion, + }, + }), runtimeManager: manager, - streamProviderCliInstall: () => + streamProviderInstallation: () => createProviderCliInstallEventStream(events), threadStorageRootPath: "/tmp/bb-thread-storage", }, ); - return { events, getProviderCliStatusForProvider, result }; + return { events, providerInstallationStatus, result }; } describe("dispatchCommand", () => { + it("steers an auto submit when the active turn appears after the server snapshot", async () => { + const runtime = createRuntime(); + const manager = new RuntimeManager({ + createRuntime: () => runtime, + provisionWorkspace: async () => createWorkspace(), + }); + await manager.ensureEnvironment({ + environmentId: "env-1", + workspacePath: WORKSPACE_PATH, + }); + runtime.setIdle("thread-1"); + vi.mocked(runtime.getLiveThreadIds).mockReturnValueOnce(["thread-1"]); + vi.mocked(runtime.waitForActiveTurn).mockImplementationOnce( + async (threadId) => { + runtime.setActiveTurn(threadId, "turn-starting"); + return "turn-starting"; + }, + ); + + const result = await dispatchCommand( + createTurnSubmitCommand({ mode: "auto", expectedTurnId: null }), + { + dataDir: "/tmp/bb-data", + logger: silentLogger, + eventSink: { emit: vi.fn(), flush: vi.fn(async () => undefined) }, + fetchProjectAttachment: async () => { + throw new Error("Unexpected project attachment fetch"); + }, + runtimeManager: manager, + threadStorageRootPath: "/tmp/bb-thread-storage", + }, + ); + + expect(result).toEqual({ appliedAs: "steer" }); + expect(runtime.waitForActiveTurn).toHaveBeenCalledWith("thread-1", { + timeoutMs: 5_000, + }); + expect(runtime.steerTurn).toHaveBeenCalledWith( + expect.objectContaining({ + clientRequestId: "creq_turn_submit", + expectedTurnId: "turn-starting", + threadId: "thread-1", + }), + ); + expect(runtime.runTurn).not.toHaveBeenCalled(); + }); + + it("rebases auto input onto the daemon's newer active turn", async () => { + const runtime = createRuntime(); + const manager = new RuntimeManager({ + createRuntime: () => runtime, + provisionWorkspace: async () => createWorkspace(), + }); + await manager.ensureEnvironment({ + environmentId: "env-1", + workspacePath: WORKSPACE_PATH, + }); + runtime.setActiveTurn("thread-1", "turn-new"); + + const result = await dispatchCommand( + createTurnSubmitCommand({ + mode: "auto", + expectedTurnId: "turn-old", + }), + { + dataDir: "/tmp/bb-data", + logger: silentLogger, + eventSink: { emit: vi.fn(), flush: vi.fn(async () => undefined) }, + fetchProjectAttachment: async () => { + throw new Error("Unexpected project attachment fetch"); + }, + runtimeManager: manager, + threadStorageRootPath: "/tmp/bb-thread-storage", + }, + ); + + expect(result).toEqual({ appliedAs: "steer" }); + expect(runtime.steerTurn).toHaveBeenCalledWith( + expect.objectContaining({ expectedTurnId: "turn-new" }), + ); + expect(runtime.runTurn).not.toHaveBeenCalled(); + }); + + it("starts auto input immediately when the prior turn already completed", async () => { + const runtime = createRuntime(); + const manager = new RuntimeManager({ + createRuntime: () => runtime, + provisionWorkspace: async () => createWorkspace(), + }); + await manager.ensureEnvironment({ + environmentId: "env-1", + workspacePath: WORKSPACE_PATH, + }); + runtime.setIdle("thread-1"); + + const result = await dispatchCommand( + createTurnSubmitCommand({ mode: "auto", expectedTurnId: null }), + { + dataDir: "/tmp/bb-data", + logger: silentLogger, + eventSink: { emit: vi.fn(), flush: vi.fn(async () => undefined) }, + fetchProjectAttachment: async () => { + throw new Error("Unexpected project attachment fetch"); + }, + runtimeManager: manager, + threadStorageRootPath: "/tmp/bb-thread-storage", + }, + ); + + expect(result).toEqual({ appliedAs: "new-turn" }); + expect(runtime.waitForActiveTurn).not.toHaveBeenCalled(); + expect(runtime.runTurn).toHaveBeenCalledOnce(); + }); + + it("rejects auto input when a pending turn still has no id after the wait", async () => { + const runtime = createRuntime(); + const manager = new RuntimeManager({ + createRuntime: () => runtime, + provisionWorkspace: async () => createWorkspace(), + }); + await manager.ensureEnvironment({ + environmentId: "env-1", + workspacePath: WORKSPACE_PATH, + }); + runtime.setIdle("thread-1"); + vi.mocked(runtime.getLiveThreadIds).mockReturnValue(["thread-1"]); + vi.mocked(runtime.waitForActiveTurn).mockResolvedValueOnce(null); + + await expect( + dispatchCommand( + createTurnSubmitCommand({ mode: "auto", expectedTurnId: null }), + { + dataDir: "/tmp/bb-data", + logger: silentLogger, + eventSink: { emit: vi.fn(), flush: vi.fn(async () => undefined) }, + fetchProjectAttachment: async () => { + throw new Error("Unexpected project attachment fetch"); + }, + runtimeManager: manager, + threadStorageRootPath: "/tmp/bb-thread-storage", + }, + ), + ).rejects.toThrow( + "Refusing to start a competing turn while thread-1 is still starting", + ); + expect(runtime.runTurn).not.toHaveBeenCalled(); + expect(runtime.steerTurn).not.toHaveBeenCalled(); + }); + it("flushes buffered events before reporting thread.stop success", async () => { const runtime = createRuntime(); const manager = new RuntimeManager({ @@ -337,7 +549,7 @@ describe("dispatchCommand", () => { }); runtime.setActiveTurn("thread-1", "turn-1"); - const flushDeferred = createDeferred(); + const flushDeferred = createDeferredPromise(); const flush = vi.fn(async () => flushDeferred.promise); const command: CommandOf<"thread.stop"> = { type: "thread.stop", @@ -506,7 +718,7 @@ describe("dispatchCommand", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -582,7 +794,7 @@ describe("dispatchCommand", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -953,7 +1165,7 @@ describe("dispatchCommand", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1022,14 +1234,20 @@ describe("dispatchCommand", () => { expect(runtime.renameThread).not.toHaveBeenCalled(); }); - it("blocks codex thread.start when the CLI is below the minimum version", async () => { + it("blocks any installation-managed provider whose bridge reports an unsupported version", async () => { const runtime = createRuntime(); const manager = new RuntimeManager({ createRuntime: () => runtime, provisionWorkspace: async () => createWorkspace(), }); const command: CommandOf<"thread.start"> = { - bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + bridgeLaunch: { + ...DISPATCH_TEST_BRIDGE_LAUNCH, + capabilities: { + ...DISPATCH_TEST_BRIDGE_LAUNCH.capabilities, + experimental_providerInstallation: true, + }, + }, type: "thread.start", environmentId: "env-1", threadId: "thread-1", @@ -1038,14 +1256,14 @@ describe("dispatchCommand", () => { workspaceProvisionType: "unmanaged", }, projectId: "proj_1", - providerId: "codex", - requestId: "creq_unsupported_codex", + providerId: "example-agent", + requestId: "creq_unsupported_provider", input: [{ type: "text", text: "hello", mentions: [] }], options: { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1058,21 +1276,20 @@ describe("dispatchCommand", () => { }; const unsupportedCodexStatus: ProviderCliStatus = { - displayName: "Codex", - executableName: "codex", - executablePath: "/usr/local/bin/codex", + displayName: "Example Agent", + executableName: "example-agent", + executablePath: "/usr/local/bin/example-agent", installed: true, installSource: "npmGlobal", currentVersion: "0.135.0", latestVersion: null, minimumSupportedVersion: "0.136.0", - npmPackageName: "@openai/codex", + npmPackageName: "example-agent", npmGlobalPackageVersion: "0.135.0", installAction: { kind: "update", label: "Update", - commandKind: "exec", - command: "codex update", + command: "example-agent update", }, needsUpdate: false, versionUnsupported: true, @@ -1089,7 +1306,7 @@ describe("dispatchCommand", () => { fetchProjectAttachment: async () => { throw new Error("Unexpected project attachment fetch"); }, - getProviderCliStatusForProvider: async () => unsupportedCodexStatus, + providerInstallationStatus: async () => unsupportedCodexStatus, runtimeManager: manager, threadStorageRootPath: "/tmp/bb-thread-storage", }), @@ -1100,7 +1317,7 @@ describe("dispatchCommand", () => { expect(runtime.startThread).not.toHaveBeenCalled(); }); - it("does not check Codex CLI status for non-Codex thread.start", async () => { + it("skips version checks when the provider declaration does not support installation", async () => { const runtime = createRuntime(); const manager = new RuntimeManager({ createRuntime: () => runtime, @@ -1116,14 +1333,14 @@ describe("dispatchCommand", () => { workspaceProvisionType: "unmanaged", }, projectId: "proj_1", - providerId: "claude-code", - requestId: "creq_non_codex", + providerId: "codex", + requestId: "creq_unmanaged_provider", input: [{ type: "text", text: "hello", mentions: [] }], options: { model: "claude-sonnet-4-6", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1134,8 +1351,8 @@ describe("dispatchCommand", () => { injectedSkillSources: [], instructionMode: "append", }; - const getProviderCliStatusForProvider = vi.fn(async () => { - throw new Error("Codex CLI status should not be checked"); + const providerInstallationStatus = vi.fn(async () => { + throw new Error("Provider installation status should not be checked"); }); const result = await dispatchCommand(command, { @@ -1148,13 +1365,13 @@ describe("dispatchCommand", () => { fetchProjectAttachment: async () => { throw new Error("Unexpected project attachment fetch"); }, - getProviderCliStatusForProvider, + providerInstallationStatus, runtimeManager: manager, threadStorageRootPath: "/tmp/bb-thread-storage", }); expect(result).toEqual({ providerThreadId: "provider-thread-1" }); - expect(getProviderCliStatusForProvider).not.toHaveBeenCalled(); + expect(providerInstallationStatus).not.toHaveBeenCalled(); expect(runtime.startThread).toHaveBeenCalledOnce(); }); @@ -1165,7 +1382,13 @@ describe("dispatchCommand", () => { provisionWorkspace: async () => createWorkspace(), }); const command: CommandOf<"thread.rewind.prepare"> = { - bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + bridgeLaunch: { + ...DISPATCH_TEST_BRIDGE_LAUNCH, + capabilities: { + ...DISPATCH_TEST_BRIDGE_LAUNCH.capabilities, + experimental_providerInstallation: true, + }, + }, type: "thread.rewind.prepare", environmentId: "env-1", threadId: "thread-1", @@ -1182,7 +1405,7 @@ describe("dispatchCommand", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1209,6 +1432,7 @@ describe("dispatchCommand", () => { versionUnsupported: false, }; + const providerInstallationStatus = vi.fn(async () => supportedCodexStatus); await expect( dispatchCommand(command, { dataDir: "/tmp/bb-data", @@ -1220,11 +1444,14 @@ describe("dispatchCommand", () => { fetchProjectAttachment: async () => { throw new Error("Unexpected project attachment fetch"); }, - getProviderCliStatusForProvider: async () => supportedCodexStatus, + providerInstallationStatus, runtimeManager: manager, threadStorageRootPath: "/tmp/bb-thread-storage", }), ).resolves.toEqual({ providerThreadId: "provider-thread-rewind-1" }); + expect(providerInstallationStatus).toHaveBeenCalledWith( + expect.objectContaining({ requirement: "thread_rewind" }), + ); expect(runtime.prepareThreadRewind).toHaveBeenCalledWith( expect.objectContaining({ leaseId: "lease-1", @@ -1246,10 +1473,12 @@ describe("dispatchCommand", () => { fetchProjectAttachment: async () => { throw new Error("Unexpected project attachment fetch"); }, - getProviderCliStatusForProvider: async () => ({ + providerInstallationStatus: async () => ({ ...supportedCodexStatus, currentVersion: "0.140.0", + minimumSupportedVersion: "0.143.0", npmGlobalPackageVersion: "0.140.0", + versionUnsupported: true, }), runtimeManager: manager, threadStorageRootPath: "/tmp/bb-thread-storage", @@ -1286,7 +1515,7 @@ describe("dispatchCommand", () => { }); }); - it("invalidates the provider maintenance runtime after a successful Codex CLI update", async () => { + it("invalidates the provider maintenance runtime after a verified provider update", async () => { const dataDir = await makeTempDir("bb-command-dispatch-provider-cli-"); const staleRuntime = createRuntime(); const freshRuntime = createRuntime(); @@ -1314,13 +1543,14 @@ describe("dispatchCommand", () => { success: true, }, ]; - const streamProviderCliInstall = vi.fn(() => + const streamProviderInstallation = vi.fn(() => createProviderCliInstallEventStream(events), ); - const command: CommandOf<"provider_cli.install"> = { - type: "provider_cli.install", - provider: "codex", - actionKind: "update", + const command: CommandOf<"provider.installation.run"> = { + type: "provider.installation.run", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + providerId: "codex", + action: "update", }; const result = await dispatchOnlineRpcCommand(command, { @@ -1334,15 +1564,42 @@ describe("dispatchCommand", () => { throw new Error("Unexpected project attachment fetch"); }, runtimeManager: manager, - streamProviderCliInstall, + providerInstallationRun: async () => ({ + available: true, + command: { + command: "codex", + args: ["update"], + displayCommand: "codex update", + }, + verification: { kind: "version_changed", previousVersion: "0.1.0" }, + }), + providerInstallationStatus: async () => ({ + executableName: "codex", + executablePath: "/usr/local/bin/codex", + installed: true, + installSource: "external", + currentVersion: "0.2.0", + latestVersion: "0.2.0", + minimumSupportedVersion: null, + npmPackageName: null, + npmGlobalPackageVersion: null, + installAction: null, + needsUpdate: false, + versionUnsupported: false, + }), + streamProviderInstallation, threadStorageRootPath: "/tmp/bb-thread-storage", }); expect(result).toEqual({ events }); - expect(streamProviderCliInstall).toHaveBeenCalledWith( + expect(streamProviderInstallation).toHaveBeenCalledWith( expect.objectContaining({ - actionKind: "update", - provider: "codex", + providerId: "codex", + plan: { + command: "codex", + args: ["update"], + displayCommand: "codex update", + }, }), ); expect(staleRuntime.shutdown).toHaveBeenCalledOnce(); @@ -1352,14 +1609,14 @@ describe("dispatchCommand", () => { expect(freshRuntime.shutdown).not.toHaveBeenCalled(); }); - it("keeps the provider maintenance runtime after failed or non-Codex CLI installs", async () => { + it("keeps the provider maintenance runtime after a failed provider update", async () => { const cases: Array<{ - actionKind: CommandOf<"provider_cli.install">["actionKind"]; + action: CommandOf<"provider.installation.run">["action"]; events: ProviderCliInstallEvent[]; - provider: CommandOf<"provider_cli.install">["provider"]; + provider: CommandOf<"provider.installation.run">["providerId"]; }> = [ { - actionKind: "update", + action: "update", provider: "codex", events: [ { @@ -1371,19 +1628,6 @@ describe("dispatchCommand", () => { }, ], }, - { - actionKind: "update", - provider: "claudeCode", - events: [ - { - type: "completed", - provider: "claudeCode", - exitCode: 0, - signal: null, - success: true, - }, - ], - }, ]; for (const testCase of cases) { @@ -1396,32 +1640,15 @@ describe("dispatchCommand", () => { provisionWorkspace: async () => createWorkspace(), }); await manager.ensureProviderMaintenanceRuntime({ dataDir }); - const streamProviderCliInstall = vi.fn(() => + const streamProviderInstallation = vi.fn(() => createProviderCliInstallEventStream(testCase.events), ); - const getProviderCliStatusForProvider = - testCase.provider === "claudeCode" - ? vi - .fn() - .mockResolvedValueOnce( - claudeCodeStatus({ - currentVersion: "2.1.220", - latestVersion: "2.1.227", - }), - ) - .mockResolvedValueOnce( - claudeCodeStatus({ - currentVersion: "2.1.227", - latestVersion: "2.1.227", - }), - ) - : undefined; - const result = await dispatchOnlineRpcCommand( { - type: "provider_cli.install", - provider: testCase.provider, - actionKind: testCase.actionKind, + type: "provider.installation.run", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + providerId: testCase.provider, + action: testCase.action, }, { dataDir, @@ -1433,11 +1660,20 @@ describe("dispatchCommand", () => { fetchProjectAttachment: async () => { throw new Error("Unexpected project attachment fetch"); }, - ...(getProviderCliStatusForProvider === undefined - ? {} - : { getProviderCliStatusForProvider }), + providerInstallationRun: async () => ({ + available: true, + command: { + command: testCase.provider, + args: [testCase.action], + displayCommand: `${testCase.provider} ${testCase.action}`, + }, + verification: { + kind: "version_changed", + previousVersion: "2.1.220", + }, + }), runtimeManager: manager, - streamProviderCliInstall, + streamProviderInstallation, threadStorageRootPath: "/tmp/bb-thread-storage", }, ); @@ -1459,26 +1695,19 @@ describe("dispatchCommand", () => { dataDir, provisionWorkspace: async () => createWorkspace(), }); - const getProviderCliStatusForProvider = vi - .fn() - .mockResolvedValueOnce( - claudeCodeStatus({ - currentVersion: "2.1.220", - latestVersion: "2.1.227", - }), - ) - .mockResolvedValueOnce( - claudeCodeStatus({ - currentVersion: "2.1.220", - latestVersion: "2.1.227", - }), - ); + const providerInstallationStatus = vi.fn().mockResolvedValueOnce( + claudeCodeStatus({ + currentVersion: "2.1.220", + latestVersion: "2.1.227", + }), + ); const result = await dispatchOnlineRpcCommand( { - type: "provider_cli.install", - provider: "claudeCode", - actionKind: "update", + type: "provider.installation.run", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + providerId: "claude-code", + action: "update", }, { dataDir, @@ -1490,24 +1719,33 @@ describe("dispatchCommand", () => { fetchProjectAttachment: async () => { throw new Error("Unexpected project attachment fetch"); }, - getProviderCliStatusForProvider, + providerInstallationStatus, + providerInstallationRun: async () => ({ + available: true, + command: { + command: "claude", + args: ["update"], + displayCommand: "claude update", + }, + verification: { kind: "version_at_least", version: "2.1.227" }, + }), runtimeManager: manager, - streamProviderCliInstall: () => + streamProviderInstallation: () => createProviderCliInstallEventStream([ { type: "started", - provider: "claudeCode", + provider: "claude-code", command: "claude update", }, { type: "output", - provider: "claudeCode", + provider: "claude-code", stream: "stdout", text: "Successfully updated from 2.1.220 to version 2.1.227\n", }, { type: "completed", - provider: "claudeCode", + provider: "claude-code", exitCode: 0, signal: null, success: true, @@ -1517,20 +1755,20 @@ describe("dispatchCommand", () => { }, ); - expect(getProviderCliStatusForProvider).toHaveBeenCalledTimes(2); + expect(providerInstallationStatus).toHaveBeenCalledOnce(); expect(result.events).toEqual([ expect.objectContaining({ type: "started" }), expect.objectContaining({ type: "output" }), expect.objectContaining({ type: "error", - provider: "claudeCode", + provider: "claude-code", message: expect.stringContaining( - "still reports 2.1.220 (expected 2.1.227)", + "could not verify the installed result", ), }), { type: "completed", - provider: "claudeCode", + provider: "claude-code", exitCode: 0, signal: null, success: false, @@ -1538,28 +1776,22 @@ describe("dispatchCommand", () => { ]); }); - it("reports a successful Claude update as unverified when the pre-update version check fails", async () => { + it("does not spawn when the provider withdraws a stale installation action", async () => { const dataDir = await makeTempDir("bb-command-dispatch-provider-cli-"); const manager = new RuntimeManager({ createRuntime, dataDir, provisionWorkspace: async () => createWorkspace(), }); - const getProviderCliStatusForProvider = vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce( - claudeCodeStatus({ - currentVersion: "2.1.220", - latestVersion: "2.1.227", - }), - ); + const providerInstallationStatus = vi.fn(); + const streamProviderInstallation = vi.fn(); const result = await dispatchOnlineRpcCommand( { - type: "provider_cli.install", - provider: "claudeCode", - actionKind: "update", + type: "provider.installation.run", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + providerId: "claude-code", + action: "update", }, { dataDir, @@ -1571,43 +1803,24 @@ describe("dispatchCommand", () => { fetchProjectAttachment: async () => { throw new Error("Unexpected project attachment fetch"); }, - getProviderCliStatusForProvider, + providerInstallationStatus, + providerInstallationRun: async () => ({ + available: false, + message: "Claude Code update is no longer available on this host.", + }), runtimeManager: manager, - streamProviderCliInstall: () => - createProviderCliInstallEventStream([ - { - type: "started", - provider: "claudeCode", - command: "claude update", - }, - { - type: "completed", - provider: "claudeCode", - exitCode: 0, - signal: null, - success: true, - }, - ]), + streamProviderInstallation, threadStorageRootPath: "/tmp/bb-thread-storage", }, ); - expect(getProviderCliStatusForProvider).toHaveBeenCalledTimes(2); + expect(providerInstallationStatus).not.toHaveBeenCalled(); + expect(streamProviderInstallation).not.toHaveBeenCalled(); expect(result.events).toEqual([ - expect.objectContaining({ type: "started" }), - expect.objectContaining({ - type: "error", - provider: "claudeCode", - message: expect.stringContaining( - "bb could not read /Users/me/.local/bin/claude's version before the update", - ), - }), { - type: "completed", - provider: "claudeCode", - exitCode: 0, - signal: null, - success: false, + type: "error", + provider: "claude-code", + message: "Claude Code update is no longer available on this host.", }, ]); }); @@ -1624,9 +1837,7 @@ describe("dispatchCommand", () => { }), }); - expect(verification.getProviderCliStatusForProvider).toHaveBeenCalledTimes( - 2, - ); + expect(verification.providerInstallationStatus).toHaveBeenCalledOnce(); expect(verification.result).toEqual({ events: verification.events }); }); @@ -1646,14 +1857,14 @@ describe("dispatchCommand", () => { expect.objectContaining({ type: "started" }), expect.objectContaining({ type: "error", - provider: "claudeCode", + provider: "claude-code", message: expect.stringContaining( - "still reports 2.1.69 (expected a version newer than 2.1.69)", + "could not verify the installed result", ), }), { type: "completed", - provider: "claudeCode", + provider: "claude-code", exitCode: 0, signal: null, success: false, @@ -1687,7 +1898,7 @@ describe("dispatchCommand", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1709,6 +1920,8 @@ describe("dispatchCommand", () => { fetchProjectAttachment: async () => { throw new Error("Unexpected project attachment fetch"); }, + providerInstallationStatus: async () => + supportedCodexInstallationStatus(), runtimeManager: fixture.manager, threadStorageRootPath: "/tmp/bb-thread-storage", }); @@ -1742,7 +1955,7 @@ describe("dispatchCommand", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1791,54 +2004,57 @@ describe("dispatchCommand", () => { ); }); - it("detects known ACP agents on the resolved user shell PATH, not the daemon's process PATH", async () => { - // Regression: known_acp_agents.status must query `which` with the user's - // resolved login-shell PATH (like provider_cli.status), otherwise ACP CLIs - // installed only on the login PATH — e.g. Hermes' `hermes` under - // ~/.local/bin — are invisible to a daemon launched by launchd/systemd with - // a stripped PATH. - const binDir = await makeTempDir("bb-acp-shell-path-"); - const executableName = `bb-acp-probe-${process.pid}`; - const executablePath = path.join(binDir, executableName); - await fs.writeFile(executablePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - + it("routes provider health and usage to the targeted bridge runtime", async () => { const runtime = createRuntime(); const manager = new RuntimeManager({ createRuntime: () => runtime, provisionWorkspace: async () => createWorkspace(), }); - // The probe executable exists ONLY on the shell PATH the manager reports, - // never on process.env.PATH, so a detection that ignores the shell env - // fails to find it. System bin dirs stay on PATH so `which` itself resolves; - // only binDir (the stand-in for ~/.local/bin) is exclusive to the shell env. - manager.replaceManagedShellEnv({ PATH: `${binDir}:/usr/bin:/bin` }); - - const result = await dispatchOnlineRpcCommand( - { - type: "known_acp_agents.status", - agents: [{ id: "acp-probe", executableName }], - }, - { - dataDir: "/tmp/bb-data", - logger: silentLogger, - eventSink: { emit: vi.fn(), flush: vi.fn(async () => undefined) }, - fetchProjectAttachment: async () => { - throw new Error("Unexpected project attachment fetch"); - }, - runtimeManager: manager, - threadStorageRootPath: "/tmp/bb-thread-storage", + const providerHealth = vi.fn(async () => ({ supported: false as const })); + const providerUsage = vi.fn(async () => ({ supported: false as const })); + const options = { + dataDir: "/tmp/bb-test-data", + logger: silentLogger, + eventSink: { emit: vi.fn(), flush: vi.fn(async () => undefined) }, + fetchProjectAttachment: async () => { + throw new Error("Unexpected project attachment fetch"); }, - ); + providerHealth, + providerUsage, + runtimeManager: manager, + threadStorageRootPath: "/tmp/bb-thread-storage", + }; - expect(result).toEqual({ - agents: [ + await expect( + dispatchOnlineRpcCommand( { - id: "acp-probe", - executableName, - installed: true, - executablePath, + type: "provider.health", + providerId: "pi", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + cwd: "/tmp/workspace", }, - ], + options, + ), + ).resolves.toEqual({ supported: false }); + await expect( + dispatchOnlineRpcCommand( + { + type: "provider.usage", + providerId: "pi", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + }, + options, + ), + ).resolves.toEqual({ supported: false }); + + expect(providerHealth).toHaveBeenCalledWith({ + providerId: "pi", + cwd: "/tmp/workspace", + bridgeLaunch: DISPATCH_TEST_RUNTIME_BRIDGE_LAUNCH, + }); + expect(providerUsage).toHaveBeenCalledWith({ + providerId: "pi", + bridgeLaunch: DISPATCH_TEST_RUNTIME_BRIDGE_LAUNCH, }); }); }); diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts index b48d641565..356714100e 100644 --- a/apps/host-daemon/src/command-dispatch.ts +++ b/apps/host-daemon/src/command-dispatch.ts @@ -1,7 +1,6 @@ import { providerCliInstallEventSchema, type ProviderCliInstallEvent, - type ProviderCliStatus, HostDaemonCommand, HostDaemonCommandResult, HostDaemonOnlineRpcCommand, @@ -12,6 +11,10 @@ import { import semver from "semver"; import { defaultListModels, + defaultProviderHealth, + defaultProviderInstallationRun, + defaultProviderInstallationStatus, + defaultProviderUsage, ExpectedCommandDispatchError, resolveRuntimeBridgeLaunch, type CommandOf, @@ -21,7 +24,10 @@ import { cancelEnvironmentProvision, provisionEnvironment, } from "./command-handlers/environment.js"; -import { listHostBranches } from "./command-handlers/host-branches.js"; +import { + listHostBranchOptions, + listHostBranches, +} from "./command-handlers/host-branches.js"; import { installGlobalSkills, readGlobalSkillsStatus, @@ -53,15 +59,14 @@ import { completeCodexInference, transcribeCodexVoice, } from "./codex-chatgpt-client.js"; -import { discoverRepos } from "./command-handlers/discover-repos.js"; -import { getProviderUsage } from "./provider-usage.js"; import { - getKnownAcpAgentsStatus, - getProviderCliStatus, - getProviderCliStatusForProvider as inspectProviderCliStatusForProvider, - ProviderCliInstallInProgressError, - streamProviderCliInstall, -} from "./provider-cli-health.js"; + ProviderInstallationInProgressError, + streamProviderInstallation, +} from "./provider-installation.js"; +import type { + ExperimentalProviderInstallationStatus, + ExperimentalProviderInstallationVerification, +} from "@bb/provider-bridge-protocol"; import { discardThreadRewind, ensureThreadRuntime, @@ -81,13 +86,13 @@ import { resolveWorkspaceForCommand, workspaceResolutionFailureFromError, } from "./workspace-resolution.js"; +import { userExecutableProcessOptions } from "./user-executable-env.js"; const THREAD_STOP_ACTIVE_TURN_WAIT_MS = 5_000; export { CommandDispatchError, getErrorCode, - noopEventSink, type CommandDispatchOptions, } from "./command-dispatch-support.js"; @@ -163,162 +168,133 @@ async function readProviderCliInstallEvents( return events; } -async function tryGetProviderCliStatusForProvider( - provider: CommandOf<"provider_cli.install">["provider"], - options: CommandDispatchOptions, - env: NodeJS.ProcessEnv, -): Promise { - try { - if (options.getProviderCliStatusForProvider !== undefined) { - return await options.getProviderCliStatusForProvider(provider); - } - return await inspectProviderCliStatusForProvider(provider, { env }); - } catch { - return null; - } -} - -function verifyClaudeCodeUpdateEvents(args: { - before: ProviderCliStatus | null; - after: ProviderCliStatus | null; - events: ProviderCliInstallEvent[]; -}): ProviderCliInstallEvent[] { - const completedIndex = args.events.findIndex( - (event) => event.type === "completed" && event.success, - ); - if (completedIndex === -1) { - return args.events; - } - const executable = - args.after?.executablePath ?? - args.before?.executablePath ?? - args.before?.executableName ?? - "claude"; - if (args.before === null) { - return failClaudeCodeUpdateVerification({ - ...args, - completedIndex, - message: `Claude Code's update command exited successfully, but bb could not read ${executable}'s version before the update. bb cannot confirm that the active executable changed. Run \`claude --version\` and \`claude doctor\` on this machine, then use the command output to update the installation they report.`, - }); - } - - const expectedVersion = args.before.latestVersion; - const previousVersion = args.before.currentVersion; - const actualVersion = args.after?.currentVersion ?? null; - - const validExpectedVersion = - expectedVersion === null ? null : semver.valid(expectedVersion); - const validPreviousVersion = - previousVersion === null ? null : semver.valid(previousVersion); - const validActualVersion = - actualVersion === null ? null : semver.valid(actualVersion); - const hasKnownTarget = validExpectedVersion !== null; - const canVerifyAdvancement = - expectedVersion === null && validPreviousVersion !== null; - if (!hasKnownTarget && !canVerifyAdvancement) { - return failClaudeCodeUpdateVerification({ - ...args, - completedIndex, - message: `Claude Code's update command exited successfully, but bb could not compare ${executable}'s version before and after the update. Run \`claude --version\` and \`claude doctor\` on this machine, then use the command output to update the installation they report.`, - }); - } - const updateVerified = - validActualVersion !== null && - (validExpectedVersion !== null - ? semver.gte(validActualVersion, validExpectedVersion) - : validPreviousVersion !== null && - semver.gt(validActualVersion, validPreviousVersion)); - if (updateVerified) { - return args.events; - } - - const expectation = hasKnownTarget - ? `expected ${validExpectedVersion}` - : `expected a version newer than ${validPreviousVersion}`; - const message = `Claude Code's update command exited successfully, but ${executable} still reports ${actualVersion ?? "an unknown version"} (${expectation}). The executable may be pinned by PATH or managed by another installer. Run \`claude doctor\` on this machine and update the installation it reports.`; - return failClaudeCodeUpdateVerification({ - ...args, - completedIndex, - message, - }); -} - -function failClaudeCodeUpdateVerification(args: { - completedIndex: number; +function failProviderInstallationVerification(args: { + providerId: string; events: ProviderCliInstallEvent[]; message: string; }): ProviderCliInstallEvent[] { const verifiedEvents = [...args.events]; - const completedEvent = verifiedEvents[args.completedIndex]; + const completedIndex = verifiedEvents.findIndex( + (event) => event.type === "completed" && event.success, + ); + const completedEvent = verifiedEvents[completedIndex]; if (completedEvent?.type !== "completed") { return args.events; } - verifiedEvents[args.completedIndex] = { ...completedEvent, success: false }; - verifiedEvents.splice(args.completedIndex, 0, { + verifiedEvents[completedIndex] = { ...completedEvent, success: false }; + verifiedEvents.splice(completedIndex, 0, { type: "error", - provider: "claudeCode", + provider: args.providerId, message: args.message, }); return verifiedEvents; } -async function installProviderCliOnHost( - command: CommandOf<"provider_cli.install">, +function installationVerificationPassed( + verification: ExperimentalProviderInstallationVerification, + status: ExperimentalProviderInstallationStatus, +): boolean { + switch (verification.kind) { + case "installed": + return status.installed; + case "version_at_least": { + const actual = + status.currentVersion === null + ? null + : semver.valid(status.currentVersion); + const expected = semver.valid(verification.version); + return ( + actual !== null && expected !== null && semver.gte(actual, expected) + ); + } + case "version_changed": { + const actual = status.currentVersion; + if (actual === null) return false; + const parsedActual = semver.valid(actual); + const parsedPrevious = semver.valid(verification.previousVersion); + return parsedActual !== null && parsedPrevious !== null + ? semver.gt(parsedActual, parsedPrevious) + : actual !== verification.previousVersion; + } + } + return false; +} + +async function runProviderInstallationOnHost( + command: CommandOf<"provider.installation.run">, options: CommandDispatchOptions, -): Promise> { +): Promise> { try { const env = providerCliEnvFromShellEnv( options.runtimeManager.getShellEnv(), ); - const claudeCodeStatusBefore = - command.provider === "claudeCode" && command.actionKind === "update" - ? await tryGetProviderCliStatusForProvider( - command.provider, - options, - env, - ) - : null; - const streamInstall = - options.streamProviderCliInstall ?? streamProviderCliInstall; + const bridgeLaunch = await resolveRuntimeBridgeLaunch( + command.bridgeLaunch, + options, + ); + const maintenanceArgs = { + providerId: command.providerId, + ...(command.cwd !== undefined ? { cwd: command.cwd } : {}), + ...(command.acpLaunchSpec !== undefined + ? { acpLaunchSpec: command.acpLaunchSpec } + : {}), + bridgeLaunch, + }; + const run = await ( + options.providerInstallationRun ?? defaultProviderInstallationRun + )({ ...maintenanceArgs, action: command.action }); + if (!run.available) { + return { + events: [ + { + type: "error", + provider: command.providerId, + message: run.message, + }, + ], + }; + } + const stream = + options.streamProviderInstallation ?? streamProviderInstallation; let events = await readProviderCliInstallEvents( - streamInstall({ - provider: command.provider, - actionKind: command.actionKind, + stream({ + providerId: command.providerId, + plan: run.command, env, }), ); - if ( - command.provider === "claudeCode" && - command.actionKind === "update" && - events.some((event) => event.type === "completed" && event.success) - ) { - const claudeCodeStatusAfter = await tryGetProviderCliStatusForProvider( - command.provider, - options, - env, - ); - events = verifyClaudeCodeUpdateEvents({ - before: claudeCodeStatusBefore, - after: claudeCodeStatusAfter, - events, - }); + if (events.some((event) => event.type === "completed" && event.success)) { + try { + const status = await ( + options.providerInstallationStatus ?? + defaultProviderInstallationStatus + )(maintenanceArgs); + if (!installationVerificationPassed(run.verification, status)) { + events = failProviderInstallationVerification({ + providerId: command.providerId, + events, + message: `${command.providerId} ${command.action} exited successfully, but the provider could not verify the installed result.`, + }); + } + } catch { + events = failProviderInstallationVerification({ + providerId: command.providerId, + events, + message: `${command.providerId} ${command.action} exited successfully, but its installation status could not be verified.`, + }); + } } - if ( - shouldInvalidateProviderMaintenanceRuntimeAfterProviderCliInstall({ - command, - events, - }) - ) { + if (events.some((event) => event.type === "completed" && event.success)) { await options.runtimeManager.invalidateProviderMaintenanceRuntime(); } return { events }; } catch (error) { - if (error instanceof ProviderCliInstallInProgressError) { + if (error instanceof ProviderInstallationInProgressError) { return { events: [ { type: "error", - provider: command.provider, + provider: command.providerId, message: error.message, }, ], @@ -328,23 +304,6 @@ async function installProviderCliOnHost( } } -function shouldInvalidateProviderMaintenanceRuntimeAfterProviderCliInstall(args: { - command: CommandOf<"provider_cli.install">; - events: readonly ProviderCliInstallEvent[]; -}): boolean { - return ( - // Codex model listing goes through the resident provider-maintenance - // app-server, so a Codex CLI update can leave a stale model catalog alive. - args.command.provider === "codex" && - args.events.some( - (event) => - event.type === "completed" && - event.provider === args.command.provider && - event.success, - ) - ); -} - const commandHandlers: CommandHandlerMap = { "thread.rewind.discard": async (command, options) => { const release = @@ -518,20 +477,20 @@ const commandHandlers: CommandHandlerMap = { return {}; }, "thread.unarchive": async (command, options) => { - const runtime = - await options.runtimeManager.ensureProviderMaintenanceRuntime({ - dataDir: options.dataDir, - }); const bridgeLaunch = await resolveRuntimeBridgeLaunch( command.bridgeLaunch, options, ); - await runtime.unarchiveThread({ - threadId: command.threadId, - providerId: command.providerId, - providerThreadId: command.providerThreadId, - bridgeLaunch, - }); + await options.runtimeManager.withProviderMaintenanceRuntime( + { dataDir: options.dataDir }, + (runtime) => + runtime.unarchiveThread({ + threadId: command.threadId, + providerId: command.providerId, + providerThreadId: command.providerThreadId, + bridgeLaunch, + }), + ); return {}; }, "interactive.resolve": resolveInteractiveRequest, @@ -543,6 +502,7 @@ const commandHandlers: CommandHandlerMap = { dataDir: options.dataDir, projectSlug: command.projectSlug, remoteUrl: command.remoteUrl, + ...userExecutableProcessOptions(options.runtimeManager.getShellEnv()), ...(command.targetPath !== undefined ? { targetPath: command.targetPath } : {}), @@ -596,18 +556,30 @@ const commandHandlers: CommandHandlerMap = { runtimeManager: options.runtimeManager, workspaceContext: command.workspaceContext, }); + const cliOptions = userExecutableProcessOptions( + options.runtimeManager.getShellEnv(), + ); switch (command.operation) { case "ready": - await entry.workspace.runPullRequestAction({ operation: "ready" }); + await entry.workspace.runPullRequestAction( + { operation: "ready" }, + cliOptions, + ); break; case "draft": - await entry.workspace.runPullRequestAction({ operation: "draft" }); + await entry.workspace.runPullRequestAction( + { operation: "draft" }, + cliOptions, + ); break; case "merge": - await entry.workspace.runPullRequestAction({ - operation: "merge", - method: command.method, - }); + await entry.workspace.runPullRequestAction( + { + operation: "merge", + method: command.method, + }, + cliOptions, + ); break; default: { const _exhaustive: never = command; @@ -632,7 +604,11 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { "host.remove_path": removeHostPath, "host.browse_directory": browseHostDirectory, "host.paths_exist": checkHostPathsExist, - "project.inspect": async (command) => inspectProjectPath(command.path), + "project.inspect": async (command, options) => + inspectProjectPath( + command.path, + userExecutableProcessOptions(options.runtimeManager.getShellEnv()), + ), "project.clone_default_path": async (command, options) => ({ path: resolveProjectCloneDefaultPath(options.dataDir, command.projectSlug), }), @@ -653,6 +629,7 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { "host.install_global_skills": installGlobalSkills, "host.global_skills_status": async (command) => readGlobalSkillsStatus(command, {}), + "host.list_branch_options": listHostBranchOptions, "host.list_branches": listHostBranches, "host.file_metadata": readHostFileMetadata, "host.read_file": readHostFile, @@ -672,24 +649,54 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { bridgeLaunch, }); }, - "known_acp_agents.status": async (command, options) => - getKnownAcpAgentsStatus({ - agents: command.agents, - env: providerCliEnvFromShellEnv(options.runtimeManager.getShellEnv()), - }), - "provider.usage": async () => getProviderUsage(), - "provider_cli.status": async (_command, options) => - getProviderCliStatus({ - env: providerCliEnvFromShellEnv(options.runtimeManager.getShellEnv()), - }), - "provider_cli.install": installProviderCliOnHost, - "workspace.discover_repos": async (command, options) => - discoverRepos({ - maxDepth: command.maxDepth, - sinceDays: command.sinceDays, - limit: command.limit, - env: options.runtimeManager.getShellEnv(), - }), + "provider.health": async (command, options) => { + const bridgeLaunch = await resolveRuntimeBridgeLaunch( + command.bridgeLaunch, + options, + ); + return (options.providerHealth ?? defaultProviderHealth)({ + providerId: command.providerId, + ...(command.cwd !== undefined ? { cwd: command.cwd } : {}), + ...(command.acpLaunchSpec !== undefined + ? { acpLaunchSpec: command.acpLaunchSpec } + : {}), + bridgeLaunch, + }); + }, + "provider.usage": async (command, options) => { + const bridgeLaunch = await resolveRuntimeBridgeLaunch( + command.bridgeLaunch, + options, + ); + return (options.providerUsage ?? defaultProviderUsage)({ + providerId: command.providerId, + ...(command.cwd !== undefined ? { cwd: command.cwd } : {}), + ...(command.acpLaunchSpec !== undefined + ? { acpLaunchSpec: command.acpLaunchSpec } + : {}), + bridgeLaunch, + }); + }, + "provider.installation.status": async (command, options) => { + const bridgeLaunch = await resolveRuntimeBridgeLaunch( + command.bridgeLaunch, + options, + ); + return ( + options.providerInstallationStatus ?? defaultProviderInstallationStatus + )({ + providerId: command.providerId, + ...(command.cwd !== undefined ? { cwd: command.cwd } : {}), + ...(command.acpLaunchSpec !== undefined + ? { acpLaunchSpec: command.acpLaunchSpec } + : {}), + ...(command.requirement !== undefined + ? { requirement: command.requirement } + : {}), + bridgeLaunch, + }); + }, + "provider.installation.run": runProviderInstallationOnHost, "workspace.status": async (command, options) => { const resolution = await resolveWorkspaceForCommand({ dataDir: options.dataDir, @@ -831,7 +838,9 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { ? { outcome: "absent" } : { outcome: "unavailable", message: resolution.failure.message }; } - const lookup = await resolution.entry.workspace.getPullRequest(); + const lookup = await resolution.entry.workspace.getPullRequest( + userExecutableProcessOptions(options.runtimeManager.getShellEnv()), + ); switch (lookup.outcome) { case "found": return { outcome: "available", pullRequest: lookup.pullRequest }; diff --git a/apps/host-daemon/src/command-handlers/discover-repos.test.ts b/apps/host-daemon/src/command-handlers/discover-repos.test.ts deleted file mode 100644 index 817451ddd2..0000000000 --- a/apps/host-daemon/src/command-handlers/discover-repos.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { discoverRepos } from "./discover-repos.js"; - -/** - * The walk's whole value is what it refuses to enter, so these cover the - * skip rules rather than the happy path. - */ -describe("discoverRepos", () => { - let home: string; - - const makeRepo = async (relativePath: string) => { - const gitDir = join(home, relativePath, ".git"); - await mkdir(gitDir, { recursive: true }); - await writeFile(join(gitDir, "HEAD"), "ref: refs/heads/main\n"); - }; - - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), "bb-discover-")); - }); - - afterEach(async () => { - await rm(home, { recursive: true, force: true }); - }); - - const run = () => - discoverRepos({ - maxDepth: 5, - sinceDays: 3650, - limit: 50, - home, - env: { PATH: "/nonexistent" }, - }); - - it("stops descending at a repo root so nested checkouts are not listed", async () => { - await makeRepo("projects/app"); - await makeRepo("projects/app/vendor/inner"); - - const { repos } = await run(); - - expect(repos.map((repo) => repo.name)).toEqual(["app"]); - }); - - it("skips dot-directories, which hold tool internals rather than projects", async () => { - await makeRepo("projects/app"); - await makeRepo(".nvm/versions/thing"); - await makeRepo(".bb-dev/worktrees/copy"); - - const { repos } = await run(); - - expect(repos.map((repo) => repo.name)).toEqual(["app"]); - }); - - it("skips heavy build directories on the way down", async () => { - await makeRepo("projects/app"); - await makeRepo("code/node_modules/pkg"); - - const { repos } = await run(); - - expect(repos.map((repo) => repo.name)).toEqual(["app"]); - }); - - it("drops repos older than the recency window", async () => { - await makeRepo("projects/app"); - const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - - try { - const { repos } = await discoverRepos({ - maxDepth: 5, - sinceDays: 30, - limit: 50, - home, - env: { PATH: "/nonexistent" }, - // A "now" far in the future puts the fixture outside the window. - now: Date.now() + 400 * 86_400_000, - }); - - expect(repos).toEqual([]); - const timerDelays = setTimeoutSpy.mock.calls.flatMap(([, delay]) => - typeof delay === "number" ? [delay] : [], - ); - expect(timerDelays.length).toBeGreaterThan(0); - expect(Math.max(...timerDelays)).toBeLessThanOrEqual(3_000); - } finally { - setTimeoutSpy.mockRestore(); - } - }); - - it("detects linked worktrees, whose .git is a file rather than a directory", async () => { - // `git worktree add` and submodules both write a `.git` file pointing at - // the real git dir. Treating only directories as repo markers walked - // straight into them and returned nothing. - await mkdir(join(home, "projects/linked"), { recursive: true }); - await writeFile( - join(home, "projects/linked/.git"), - "gitdir: /home/user/projects/app/.git/worktrees/linked\n", - ); - - const { repos } = await run(); - - expect(repos.map((repo) => repo.name)).toEqual(["linked"]); - }); - - it("still returns repos when no agent history is available", async () => { - await makeRepo("projects/app"); - - const { repos, truncated } = await run(); - - expect(truncated).toBe(false); - expect(repos[0]?.agentSeen).toBe(false); - expect(repos[0]?.agentSeenAt).toBeNull(); - }); -}); diff --git a/apps/host-daemon/src/command-handlers/discover-repos.ts b/apps/host-daemon/src/command-handlers/discover-repos.ts deleted file mode 100644 index 339a225460..0000000000 --- a/apps/host-daemon/src/command-handlers/discover-repos.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { spawn } from "node:child_process"; -import { readFile, stat } from "node:fs/promises"; -import { opendir } from "node:fs/promises"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; -import type { - DiscoverReposResult, - DiscoveredRepo, -} from "@bb/host-daemon-contract"; - -/** - * Find candidate projects on this host: git repositories under the user's home - * directory, ranked by how likely the user wants them in bb. - * - * The walk is cheap because of one rule: stop descending the moment a directory - * contains `.git`. Everything below a repo root belongs to that repo, so we - * never enter `node_modules`, `target`, `dist`, or any other build tree inside - * a project. Measured on a real developer home directory this visits ~77 - * directories in ~5ms, versus ~5,100 for a naive `find -name .git -prune`. - * - * Cold page cache — not CPU — is the real cost, so the walk is time-boxed and - * reports `truncated` rather than blocking onboarding on a slow or - * network-mounted home directory. - */ - -/** - * Races a filesystem call against the walk deadline. `opendir`/`stat` on a dead - * network mount can block indefinitely with no abort signal, so a per-call - * ceiling is the only thing that keeps discovery bounded. - */ -async function withDeadline( - operation: Promise, - deadline: number, - /** Releases a result that arrives after the deadline, so nothing leaks. */ - disposeLate?: (value: T) => void, -): Promise { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - void operation.then((value) => disposeLate?.(value)).catch(() => {}); - return null; - } - let timer: NodeJS.Timeout | undefined; - let timedOut = false; - try { - const result = await Promise.race([ - operation.then((value) => { - // The race already resolved null; this value has no owner. - if (timedOut) disposeLate?.(value); - return value; - }), - new Promise((resolve) => { - timer = setTimeout(() => { - timedOut = true; - resolve(null); - }, remaining); - timer.unref?.(); - }), - ]); - return timedOut ? null : result; - } catch { - return null; - } finally { - if (timer) clearTimeout(timer); - } -} - -/** Directories that never contain a user project and are expensive to enter. */ -const SKIP_DIRECTORIES = new Set([ - "node_modules", - "Library", - "Applications", - "target", - "vendor", - "dist", - "build", - "out", - "venv", - "__pycache__", - "Pictures", - "Music", - "Movies", -]); - -const WALK_BUDGET_MS = 3_000; -/** - * Directories opened at once. Unbounded recursion over a wide home directory - * can start thousands of `opendir` calls together and reach the process file - * limit, which would disrupt active agent work on the same daemon. - */ -const WALK_CONCURRENCY = 32; -const AGENT_HISTORY_BUDGET_MS = 2_000; - -interface FoundRepo { - path: string; - lastActivityMs: number; -} - -/** - * Walk `root` breadth-first to `maxDepth`, stopping at each repo root. - * - * Dot-directories are skipped deliberately, and not only for speed: on a real - * machine the repos they hide were inside `.nvm`, `.codex/.tmp`, and `.bb-dev` - * — tool internals, never user projects. - */ -async function walkForRepos( - root: string, - maxDepth: number, - deadline: number, -): Promise<{ repos: FoundRepo[]; truncated: boolean }> { - const repos: FoundRepo[] = []; - let truncated = false; - - const walk = async (dir: string, depth: number): Promise => { - if (depth > maxDepth) return; - if (Date.now() > deadline) { - truncated = true; - return; - } - - // Unreadable or hung directory (permissions, broken mount) is not an error - // here — it just contributes nothing. - const handle = await withDeadline(opendir(dir), deadline, (late) => { - void late.close().catch(() => {}); - }); - if (handle === null) { - if (Date.now() > deadline) truncated = true; - return; - } - - const children: string[] = []; - let isRepo = false; - try { - for await (const entry of handle) { - // Linked worktrees and submodules carry `.git` as a file pointing at - // the real git dir, so the name check has to come before the - // directory check or those repos are never detected. - if (entry.name === ".git") { - isRepo = true; - continue; - } - if (!entry.isDirectory()) continue; - if (entry.name.startsWith(".")) continue; - if (SKIP_DIRECTORIES.has(entry.name)) continue; - children.push(entry.name); - } - } catch { - return; - } - - if (isRepo) { - // Stop here. Nested repos below a repo root are submodules or vendored - // copies, not separate projects the user thinks about. - // `.git/HEAD` is the best activity signal, but a linked worktree or - // submodule has `.git` as a file, so fall back to stat-ing the marker - // itself. Leaving mtime 0 would drop those repos at the recency filter. - const head = await withDeadline( - stat(join(dir, ".git", "HEAD")), - deadline, - ); - const marker = - head ?? (await withDeadline(stat(join(dir, ".git")), deadline)); - const lastActivityMs = marker?.mtimeMs ?? 0; - repos.push({ path: dir, lastActivityMs }); - return; - } - - for (let index = 0; index < children.length; index += WALK_CONCURRENCY) { - if (Date.now() > deadline) { - truncated = true; - return; - } - await Promise.all( - children - .slice(index, index + WALK_CONCURRENCY) - .map((child) => walk(join(dir, child), depth + 1)), - ); - } - }; - - await walk(root, 0); - return { repos, truncated }; -} - -/** `git config --get remote.origin.url`, read straight from the config file. */ -async function readOriginUrl(repoPath: string): Promise { - let config: string; - try { - config = await readFile(join(repoPath, ".git", "config"), "utf8"); - } catch { - return null; - } - const section = config.split(/\[remote "origin"\]/u)[1]; - if (section === undefined) return null; - const match = /^\s*url\s*=\s*(.+)$/mu.exec(section.split("[")[0] ?? ""); - return match?.[1]?.trim() ?? null; -} - -/** - * Normalize a remote URL so `git@github.com:o/r.git` and - * `https://github.com/o/r` collapse to the same key. Only used to join agent - * history to repos, never shown to the user. - */ -function normalizeOrigin(url: string | null): string | null { - if (!url) return null; - return url - .trim() - .replace(/\.git$/u, "") - .replace(/^git@([^:]+):/u, "https://$1/") - .replace(/^ssh:\/\/git@/u, "https://") - .replace(/\/+$/u, "") - .toLowerCase(); -} - -/** - * Directories where Claude Code has been run. `~/.claude.json` holds a flat - * `projects` map keyed by absolute path, with no recency. - */ -async function readClaudeHistory(home: string): Promise> { - const seen = new Map(); - let raw: string; - try { - raw = await readFile(join(home, ".claude.json"), "utf8"); - } catch { - return seen; - } - try { - const parsed: unknown = JSON.parse(raw); - const projects = - typeof parsed === "object" && parsed !== null && "projects" in parsed - ? (parsed as { projects: unknown }).projects - : null; - if (typeof projects !== "object" || projects === null) return seen; - for (const key of Object.keys(projects)) { - // No timestamp available; 1 is "seen, time unknown" and still outranks - // never-seen repos without competing with Codex's real timestamps. - seen.set(key, 1); - } - } catch { - // Malformed file is a missing hint, not a failure. - } - return seen; -} - -interface CodexHistory { - byPath: Map; - byOrigin: Map; -} - -/** - * Directories where Codex has been run, via the supported app-server API - * (`thread/list`) rather than its private SQLite file. `useStateDbOnly` skips - * the JSONL rollout scan; measured at ~190ms for 142 threads including spawn. - * - * `gitInfo.originUrl` matters here: a user running Codex through bb accumulates - * many ephemeral worktree paths for a single repo, and the origin collapses - * them into one signal. - */ -async function readCodexHistory( - env: NodeJS.ProcessEnv, - budgetMs: number, -): Promise { - const byPath = new Map(); - const byOrigin = new Map(); - - const rows = await new Promise((resolve) => { - let child: ReturnType; - try { - child = spawn("codex", ["app-server"], { - env, - stdio: ["pipe", "pipe", "ignore"], - }); - } catch { - resolve([]); - return; - } - - const collected: unknown[] = []; - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - clearTimeout(timer); - child.kill(); - resolve(collected); - }; - const timer = setTimeout(finish, budgetMs); - - child.on("error", finish); - child.on("exit", finish); - - const send = (message: unknown) => { - try { - child.stdin?.write(`${JSON.stringify(message)}\n`); - } catch { - finish(); - } - }; - - let buffer = ""; - child.stdout?.on("data", (chunk: Buffer) => { - buffer += chunk.toString(); - let newline = buffer.indexOf("\n"); - while (newline >= 0) { - const line = buffer.slice(0, newline); - buffer = buffer.slice(newline + 1); - newline = buffer.indexOf("\n"); - if (!line.trim()) continue; - let message: { id?: number; result?: { data?: unknown[] } }; - try { - message = JSON.parse(line) as typeof message; - } catch { - continue; - } - if (message.id === 1) { - send({ - jsonrpc: "2.0", - id: 2, - method: "thread/list", - params: { - useStateDbOnly: true, - limit: 200, - sortDirection: "desc", - }, - }); - } else if (message.id === 2) { - collected.push(...(message.result?.data ?? [])); - finish(); - } - } - }); - - send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - clientInfo: { name: "bb", title: "bb", version: "0.0.0" }, - }, - }); - }); - - for (const row of rows) { - if (typeof row !== "object" || row === null) continue; - const thread = row as { - cwd?: unknown; - updatedAt?: unknown; - gitInfo?: { originUrl?: unknown } | null; - }; - // Codex reports seconds; everything else here is milliseconds. - const at = - typeof thread.updatedAt === "number" ? thread.updatedAt * 1000 : 1; - if (typeof thread.cwd === "string" && thread.cwd.length > 0) { - byPath.set(thread.cwd, Math.max(byPath.get(thread.cwd) ?? 0, at)); - } - const origin = normalizeOrigin( - typeof thread.gitInfo?.originUrl === "string" - ? thread.gitInfo.originUrl - : null, - ); - if (origin) { - byOrigin.set(origin, Math.max(byOrigin.get(origin) ?? 0, at)); - } - } - - return { byPath, byOrigin }; -} - -export interface DiscoverReposArgs { - maxDepth: number; - sinceDays: number; - limit: number; - /** Injectable root for tests. */ - home?: string; - env?: NodeJS.ProcessEnv; - /** Injectable recency reference for tests; operation budgets use wall time. */ - now?: number; -} - -export async function discoverRepos( - args: DiscoverReposArgs, -): Promise { - const home = args.home ?? homedir(); - const now = args.now ?? Date.now(); - const env = args.env ?? process.env; - - const { repos, truncated } = await walkForRepos( - home, - args.maxDepth, - Date.now() + WALK_BUDGET_MS, - ); - - // Ranking hints are best-effort in every direction: a missing file, an - // uninstalled `codex`, a spawn failure, or a protocol that does not know - // `thread/list` must never fail discovery. - const [claudeSeen, codexSeen] = await Promise.all([ - readClaudeHistory(home).catch(() => new Map()), - readCodexHistory(env, AGENT_HISTORY_BUDGET_MS).catch(() => ({ - byPath: new Map(), - byOrigin: new Map(), - })), - ]); - - const cutoff = now - args.sinceDays * 86_400_000; - - /** - * Strongest agent signal for a repo. Claude Code reports no timestamp, so a - * hit there scores `SEEN_NO_TIME` — enough to outrank never-seen repos - * without competing with Codex's real timestamps. - */ - const SEEN_NO_TIME = 1; - const agentScore = (repoPath: string, origin: string | null): number => - Math.max( - claudeSeen.get(repoPath) ?? 0, - codexSeen.byPath.get(repoPath) ?? 0, - origin ? (codexSeen.byOrigin.get(origin) ?? 0) : 0, - ); - - const enriched = await Promise.all( - repos.map(async (repo) => { - const originUrl = await readOriginUrl(repo.path); - const score = agentScore(repo.path, normalizeOrigin(originUrl)); - const entry: DiscoveredRepo = { - path: repo.path, - name: basename(repo.path), - lastActivityAt: new Date(repo.lastActivityMs).toISOString(), - originUrl, - agentSeen: score > 0, - agentSeenAt: - score > SEEN_NO_TIME ? new Date(score).toISOString() : null, - }; - return { entry, score }; - }), - ); - - // Recency filter, then rank: repos an agent has already worked in come first - // (the strongest signal that the user wants them in bb), then local activity. - const recent = enriched.filter( - ({ entry }) => Date.parse(entry.lastActivityAt) >= cutoff, - ); - - recent.sort((left, right) => { - if (left.score > 0 !== right.score > 0) return left.score > 0 ? -1 : 1; - return ( - Date.parse(right.entry.lastActivityAt) - - Date.parse(left.entry.lastActivityAt) - ); - }); - - return { - repos: recent.slice(0, args.limit).map(({ entry }) => entry), - truncated, - }; -} diff --git a/apps/host-daemon/src/command-handlers/environment.ts b/apps/host-daemon/src/command-handlers/environment.ts index 00ddc2997a..2a4d55298a 100644 --- a/apps/host-daemon/src/command-handlers/environment.ts +++ b/apps/host-daemon/src/command-handlers/environment.ts @@ -177,7 +177,7 @@ function buildOnProgress(args: BuildOnProgressArgs): ProvisionProgressEmitter { }; } -export function toProvisionWorkspaceOptions( +function toProvisionWorkspaceOptions( command: EnvironmentProvisionCommand, options: Pick, onProgress?: ProvisionProgressCallback, diff --git a/apps/host-daemon/src/command-handlers/file-list.ts b/apps/host-daemon/src/command-handlers/file-list.ts index 1f1c96bde8..43120eee98 100644 --- a/apps/host-daemon/src/command-handlers/file-list.ts +++ b/apps/host-daemon/src/command-handlers/file-list.ts @@ -6,45 +6,45 @@ import type { HostPathEntryKind, } from "@bb/host-daemon-contract"; -export interface FinalizeListedFilesArgs { +interface FinalizeListedFilesArgs { filePaths: string[]; limit: number; query?: string; } -export interface FinalizedFileList { +interface FinalizedFileList { files: FileListEntry[]; truncated: boolean; } -export interface FileListEntry { +interface FileListEntry { path: string; name: string; } -export interface ListedPath { +interface ListedPath { kind: HostPathEntryKind; path: string; name: string; } -export interface PathListInclusion { +interface PathListInclusion { includeFiles: boolean; includeDirectories: boolean; } -export interface FinalizeListedPathsArgs extends PathListInclusion { +interface FinalizeListedPathsArgs extends PathListInclusion { paths: ListedPath[]; limit: number; query?: string; } -export interface FinalizedPathList { +interface FinalizedPathList { paths: HostPathEntry[]; truncated: boolean; } -export interface ListPathsRecursivelyArgs extends PathListInclusion { +interface ListPathsRecursivelyArgs extends PathListInclusion { dir: string; root: string; } diff --git a/apps/host-daemon/src/command-handlers/file-read.ts b/apps/host-daemon/src/command-handlers/file-read.ts index 7f14f5b41d..2fd3b3df72 100644 --- a/apps/host-daemon/src/command-handlers/file-read.ts +++ b/apps/host-daemon/src/command-handlers/file-read.ts @@ -1,27 +1,27 @@ import { isUtf8 } from "node:buffer"; -import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import mimeTypes from "mime-types"; import type { HostReadFileRelativeDotfilePolicy } from "@bb/host-daemon-contract"; -import { readGitBlob, WorkspaceError } from "@bb/host-workspace"; +import { + readGitBlob, + WorkspaceError, + type GitProcessOptions, +} from "@bb/host-workspace"; import { CommandDispatchError, ExpectedCommandDispatchError, } from "../command-dispatch-support.js"; import { isFsErrorWithCode } from "../fs-errors.js"; +import { sha256Hex } from "../sha256-hex.js"; import { resolveNonSymlinkDirectoryPath } from "./root-path.js"; -export const IMAGE_FILE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024; +const IMAGE_FILE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024; export const NON_IMAGE_FILE_SIZE_LIMIT_BYTES = 25 * 1024 * 1024; type FileContentEncoding = "base64" | "utf8"; -export function sha256Hex(contents: Buffer): string { - return createHash("sha256").update(contents).digest("hex"); -} - -export interface ReadFileForTransportResult { +interface ReadFileForTransportResult { content: string; contentEncoding: FileContentEncoding; mimeType?: string; @@ -31,19 +31,19 @@ export interface ReadFileForTransportResult { sizeBytes: number; } -export interface ReadFileMetadataForTransportResult { +interface ReadFileMetadataForTransportResult { modifiedAtMs: number; path: string; sizeBytes: number; } -export interface ReadFileForTransportArgs { +interface ReadFileForTransportArgs { resolvedPath: string; resultPath: string; rootPath?: string; } -export interface ReadRootRelativeFileForTransportArgs { +interface ReadRootRelativeFileForTransportArgs { rootPath: string; relativePath: string; dotfiles: HostReadFileRelativeDotfilePolicy; @@ -64,7 +64,7 @@ interface ValidatedRootRelativePath { resultPath: string; } -export interface ReadFileFromGitRefArgs { +interface ReadFileFromGitRefArgs extends GitProcessOptions { /** Repo root — `git -C ` runs from here. Must be absolute. */ rootPath: string; /** Path under rootPath the caller asked about. Must be absolute, must be within rootPath. */ @@ -87,7 +87,10 @@ function getFileSizeLimitBytes(mimeType?: string): number { : NON_IMAGE_FILE_SIZE_LIMIT_BYTES; } -function isPathWithinRoot(candidatePath: string, rootPath: string): boolean { +export function isPathWithinRoot( + candidatePath: string, + rootPath: string, +): boolean { const relativePath = path.relative(rootPath, candidatePath); return ( relativePath === "" || @@ -109,7 +112,7 @@ function getContentEncoding( return "base64"; } -function createMissingTargetError( +export function createMissingTargetError( resultPath: string, ): ExpectedCommandDispatchError { return new ExpectedCommandDispatchError( @@ -266,6 +269,7 @@ export async function readFileFromGitRef( args.ref, gitRelativePath, fileSizeLimitBytes, + args, ); } catch (error) { if (error instanceof WorkspaceError && error.code === "blob_too_large") { diff --git a/apps/host-daemon/src/command-handlers/file-write.ts b/apps/host-daemon/src/command-handlers/file-write.ts index fb2006eca3..360068c4ff 100644 --- a/apps/host-daemon/src/command-handlers/file-write.ts +++ b/apps/host-daemon/src/command-handlers/file-write.ts @@ -3,13 +3,15 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract"; -import { - CommandDispatchError, - ExpectedCommandDispatchError, -} from "../command-dispatch-support.js"; +import { CommandDispatchError } from "../command-dispatch-support.js"; import type { CommandOf } from "../command-dispatch-support.js"; import { isFsErrorWithCode } from "../fs-errors.js"; -import { NON_IMAGE_FILE_SIZE_LIMIT_BYTES, sha256Hex } from "./file-read.js"; +import { sha256Hex } from "../sha256-hex.js"; +import { + createMissingTargetError, + isPathWithinRoot, + NON_IMAGE_FILE_SIZE_LIMIT_BYTES, +} from "./file-read.js"; import { resolveNonSymlinkDirectoryPath } from "./root-path.js"; const guardedWriteTails = new Map>(); @@ -36,33 +38,13 @@ async function serializeGuardedWrite( } } -export interface ResolvedWriteTarget { +interface ResolvedWriteTarget { /** Real (symlink-resolved) path to write, existing or not. */ writePath: string; /** True when the write target's direct parent directory is missing. */ parentMissing: boolean; } -export function isPathWithinRoot( - candidatePath: string, - rootPath: string, -): boolean { - const relativePath = path.relative(rootPath, candidatePath); - return ( - relativePath === "" || - (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) - ); -} - -function createMissingTargetError( - resultPath: string, -): ExpectedCommandDispatchError { - return new ExpectedCommandDispatchError( - "ENOENT", - `Path does not exist: ${resultPath}`, - ); -} - /** * Resolve the write target through symlinks even though it may not exist yet: * realpath the nearest existing ancestor and re-append the missing segments. diff --git a/apps/host-daemon/src/command-handlers/host-branches.ts b/apps/host-daemon/src/command-handlers/host-branches.ts index 0f45e65acc..0aafdf73ad 100644 --- a/apps/host-daemon/src/command-handlers/host-branches.ts +++ b/apps/host-daemon/src/command-handlers/host-branches.ts @@ -1,19 +1,29 @@ import path from "node:path"; -import type { GitBranchRefClassification } from "@bb/domain"; +import type { + GitBranchRefClassification, + WorkspaceGitOperation, +} from "@bb/domain"; import { detectGitRepo, + detectGitRepoKind, fetchRemoteBranches, getCheckoutRef, getGitCommonDir, getWorkspaceGitOperation, hasUncommittedChanges, + listBranchRefsWithDefaults, listBranches, listRemoteBranches, readDefaultBranchRefs, + type GitProcessOptions, } from "@bb/host-workspace"; import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract"; import { CommandDispatchError } from "../command-dispatch-support.js"; -import type { CommandOf } from "../command-dispatch-support.js"; +import type { + CommandDispatchOptions, + CommandOf, +} from "../command-dispatch-support.js"; +import { userExecutableProcessOptions } from "../user-executable-env.js"; interface LimitBranchListArgs { branches: readonly string[]; @@ -37,8 +47,16 @@ interface ClassifySelectedBranchArgs { selectedBranch?: string; } +interface ReadBranchOptionsArgs extends GitProcessOptions { + path: string; + limit: number; + query?: string; + selectedBranch?: string; +} + const REMOTE_BRANCH_FETCH_THROTTLE_MS = 30_000; const REMOTE_BRANCH_FETCH_TIMEOUT_MS = 5_000; +const NO_GIT_OPERATION: WorkspaceGitOperation = { kind: "none" }; const remoteBranchFetchStateByCommonDir = new Map< string, @@ -91,8 +109,11 @@ function classifySelectedBranch({ return { name: selectedBranch, kind: "missing" }; } -async function refreshRemoteBranches(cwd: string): Promise { - const commonDir = await getGitCommonDir(cwd); +async function refreshRemoteBranches( + cwd: string, + options: GitProcessOptions, +): Promise { + const commonDir = await getGitCommonDir(cwd, options); const now = Date.now(); const existingState = remoteBranchFetchStateByCommonDir.get(commonDir); if ( @@ -112,6 +133,7 @@ async function refreshRemoteBranches(cwd: string): Promise { const inFlight = fetchRemoteBranches(cwd, { timeoutMs: REMOTE_BRANCH_FETCH_TIMEOUT_MS, + ...options, }) .catch(() => undefined) .then(() => undefined) @@ -130,14 +152,96 @@ async function refreshRemoteBranches(cwd: string): Promise { await inFlight; } +async function readBranchOptions({ + path: cwd, + limit, + query, + selectedBranch: requestedBranch, + ...gitProcessOptions +}: ReadBranchOptionsArgs): Promise< + HostDaemonOnlineRpcResult<"host.list_branch_options"> +> { + const { branches, defaultBranch, originDefaultBranch, remoteBranches } = + await listBranchRefsWithDefaults(cwd, gitProcessOptions); + const limitedBranches = limitBranchList({ + branches: pinBranch({ branches, branch: defaultBranch }), + limit, + query, + }); + const limitedRemoteBranches = limitBranchList({ + branches: pinBranch({ + branches: remoteBranches, + branch: originDefaultBranch, + }), + limit, + query, + }); + return { + branches: limitedBranches.branches, + branchesTruncated: limitedBranches.truncated, + remoteBranches: limitedRemoteBranches.branches, + remoteBranchesTruncated: limitedRemoteBranches.truncated, + selectedBranch: classifySelectedBranch({ + branches, + remoteBranches, + selectedBranch: requestedBranch, + }), + }; +} + +export async function listHostBranchOptions( + command: CommandOf<"host.list_branch_options">, + options?: Pick, +): Promise> { + if (!path.isAbsolute(command.path)) { + throw new CommandDispatchError("invalid_path", "Path must be absolute"); + } + + const gitProcessOptions = userExecutableProcessOptions( + options?.runtimeManager.getShellEnv() ?? {}, + ); + if (!(await detectGitRepo(command.path, gitProcessOptions))) { + return { + branches: [], + branchesTruncated: false, + remoteBranches: [], + remoteBranchesTruncated: false, + selectedBranch: classifySelectedBranch({ + branches: [], + remoteBranches: [], + selectedBranch: command.selectedBranch, + }), + }; + } + + if (command.remoteRefresh === "background") { + // Return cached refs immediately. A successful fetch updates shared Git + // refs, whose workspace watcher event invalidates the observed picker + // query so the refreshed options arrive without blocking this response. + void refreshRemoteBranches(command.path, gitProcessOptions).catch( + () => undefined, + ); + } + + return readBranchOptions({ ...command, ...gitProcessOptions }); +} + export async function listHostBranches( command: CommandOf<"host.list_branches">, + options?: Pick, ): Promise> { if (!path.isAbsolute(command.path)) { throw new CommandDispatchError("invalid_path", "Path must be absolute"); } - if (!(await detectGitRepo(command.path))) { + // A project source can be a bare repository whose checkouts are sibling + // worktrees (`/.bare` + `/.git` gitdir file). It has refs and + // can seed new worktrees, but has no work tree to be dirty or mid-operation. + const gitProcessOptions = userExecutableProcessOptions( + options?.runtimeManager.getShellEnv() ?? {}, + ); + const repoKind = await detectGitRepoKind(command.path, gitProcessOptions); + if (repoKind === "none") { return { branches: [], branchesTruncated: false, @@ -157,16 +261,20 @@ export async function listHostBranches( }; } - await refreshRemoteBranches(command.path); + await refreshRemoteBranches(command.path, gitProcessOptions); const [branches, remoteBranches, checkout, defaultRefs, dirty, operation] = await Promise.all([ - listBranches(command.path), - listRemoteBranches(command.path), - getCheckoutRef(command.path), - readDefaultBranchRefs(command.path), - hasUncommittedChanges(command.path), - getWorkspaceGitOperation(command.path), + listBranches(command.path, gitProcessOptions), + listRemoteBranches(command.path, gitProcessOptions), + getCheckoutRef(command.path, gitProcessOptions), + readDefaultBranchRefs(command.path, gitProcessOptions), + repoKind === "work-tree" + ? hasUncommittedChanges(command.path, gitProcessOptions) + : false, + repoKind === "work-tree" + ? getWorkspaceGitOperation(command.path, gitProcessOptions) + : NO_GIT_OPERATION, ]); const defaultBranch = defaultRefs.defaultBranch; const originDefaultBranch = defaultRefs.originDefaultBranch; diff --git a/apps/host-daemon/src/command-handlers/host-files.ts b/apps/host-daemon/src/command-handlers/host-files.ts index 1efcb9c7bf..5476539b9b 100644 --- a/apps/host-daemon/src/command-handlers/host-files.ts +++ b/apps/host-daemon/src/command-handlers/host-files.ts @@ -6,9 +6,13 @@ import type { HostDaemonOnlineRpcResult, HostPathEntryKind, } from "@bb/host-daemon-contract"; -import { CommandDispatchError } from "../command-dispatch-support.js"; -import type { CommandOf } from "../command-dispatch-support.js"; +import { + CommandDispatchError, + type CommandDispatchOptions, + type CommandOf, +} from "../command-dispatch-support.js"; import { isFsErrorWithCode } from "../fs-errors.js"; +import { userExecutableProcessOptions } from "../user-executable-env.js"; import { finalizeListedFiles, finalizeListedPaths, @@ -194,6 +198,7 @@ export async function checkHostPathsExist( export async function readHostFile( command: CommandOf<"host.read_file">, + options?: Pick, ): Promise> { assertAbsoluteHostDiskPathCommand(command); @@ -210,6 +215,9 @@ export async function readHostFile( resolvedPath: command.path, resultPath: command.path, ref: command.ref, + ...userExecutableProcessOptions( + options?.runtimeManager.getShellEnv() ?? {}, + ), }); } diff --git a/apps/host-daemon/src/command-handlers/install-global-skills.ts b/apps/host-daemon/src/command-handlers/install-global-skills.ts index d0b577cb4d..0d9c6dc750 100644 --- a/apps/host-daemon/src/command-handlers/install-global-skills.ts +++ b/apps/host-daemon/src/command-handlers/install-global-skills.ts @@ -23,13 +23,13 @@ const GLOBAL_SKILL_ROOT_SEGMENTS: readonly (readonly string[])[] = [ [".claude", "skills"], ]; -export interface InstallGlobalSkillsOptions { +interface InstallGlobalSkillsOptions { dataDir: string; fetchSkillTree?: FetchSkillTree; homeDir?: string; } -export interface GlobalSkillsStatusOptions { +interface GlobalSkillsStatusOptions { /** Defaults to this host's home directory; injected by tests. */ homeDir?: string; } diff --git a/apps/host-daemon/src/command-handlers/list-commands.ts b/apps/host-daemon/src/command-handlers/list-commands.ts index 24fd42f958..815515aac5 100644 --- a/apps/host-daemon/src/command-handlers/list-commands.ts +++ b/apps/host-daemon/src/command-handlers/list-commands.ts @@ -20,6 +20,7 @@ import { } from "../command-dispatch-support.js"; import { discoverProviderCommands, + isPathWithinDirectory, type CommandScanRoot, } from "../command-discovery.js"; @@ -564,17 +565,6 @@ function originForClaudePluginScope( return scope === "project" || scope === "local" ? "project" : "user"; } -function isPathWithinDirectory( - directoryPath: string, - candidatePath: string, -): boolean { - const relativePath = path.relative(directoryPath, candidatePath); - return ( - relativePath === "" || - (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) - ); -} - function shouldIncludeInstalledClaudePlugin( args: ResolveClaudePluginRootsArgs, plugin: ClaudeInstalledPluginReference, diff --git a/apps/host-daemon/src/command-handlers/path-mutations.ts b/apps/host-daemon/src/command-handlers/path-mutations.ts index 4f0f37cfb8..5875d32ee6 100644 --- a/apps/host-daemon/src/command-handlers/path-mutations.ts +++ b/apps/host-daemon/src/command-handlers/path-mutations.ts @@ -4,7 +4,8 @@ import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract"; import { CommandDispatchError } from "../command-dispatch-support.js"; import type { CommandOf } from "../command-dispatch-support.js"; import { resolveNonSymlinkDirectoryPath } from "./root-path.js"; -import { isPathWithinRoot, resolveWriteTarget } from "./file-write.js"; +import { isPathWithinRoot } from "./file-read.js"; +import { resolveWriteTarget } from "./file-write.js"; function assertAbsolute(value: string, field: string): void { if (!path.isAbsolute(value)) { @@ -12,14 +13,6 @@ function assertAbsolute(value: string, field: string): void { } } -function isWithin(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); - return ( - relative === "" || - (!relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - async function requireRoot( rootPath: string | undefined, ): Promise { @@ -47,7 +40,7 @@ async function requireExistingWithin( fs.realpath(targetPath), requireRoot(rootPath), ]); - if (root !== null && !isWithin(target, root)) { + if (root !== null && !isPathWithinRoot(target, root)) { throw new CommandDispatchError( "invalid_path", `Path "${targetPath}" escapes root`, @@ -66,7 +59,7 @@ async function requireDestinationWithin( requireRoot(rootPath), ]); const target = path.join(parent, path.basename(destinationPath)); - if (root !== null && !isWithin(target, root)) { + if (root !== null && !isPathWithinRoot(target, root)) { throw new CommandDispatchError( "invalid_path", `Path "${destinationPath}" escapes root`, diff --git a/apps/host-daemon/src/command-handlers/project.ts b/apps/host-daemon/src/command-handlers/project.ts index ec7bd32368..ddefdb0ba4 100644 --- a/apps/host-daemon/src/command-handlers/project.ts +++ b/apps/host-daemon/src/command-handlers/project.ts @@ -1,6 +1,10 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { runGit, WorkspaceError } from "@bb/host-workspace"; +import { + runGit, + WorkspaceError, + type GitProcessOptions, +} from "@bb/host-workspace"; import { ExpectedCommandDispatchError } from "../command-dispatch-support.js"; const PROJECT_CLONE_TIMEOUT_MS = 20 * 60 * 1000; @@ -43,13 +47,17 @@ async function requireEmptyOrMissingTarget(targetPath: string): Promise { } } -export async function inspectProjectPath(projectPath: string): Promise<{ +export async function inspectProjectPath( + projectPath: string, + options: GitProcessOptions = {}, +): Promise<{ path: string; gitRemoteUrl: string | null; }> { const resolvedPath = path.resolve(projectPath); const result = await runGit(["remote", "get-url", "origin"], { cwd: resolvedPath, + ...options, allowFailure: true, }); const gitRemoteUrl = result.exitCode === 0 ? result.stdout.trim() : ""; @@ -64,6 +72,7 @@ export async function cloneProject(args: { projectSlug: string; remoteUrl: string; targetPath?: string; + shellPath?: string; }): Promise<{ path: string; gitRemoteUrl: string | null }> { const targetPath = path.resolve( args.targetPath ?? @@ -74,6 +83,7 @@ export async function cloneProject(args: { try { await runGit(["clone", args.remoteUrl, targetPath], { cwd: path.dirname(targetPath), + ...(args.shellPath !== undefined ? { shellPath: args.shellPath } : {}), timeoutMs: PROJECT_CLONE_TIMEOUT_MS, }); } catch (error) { @@ -82,5 +92,8 @@ export async function cloneProject(args: { } throw error; } - return inspectProjectPath(targetPath); + return inspectProjectPath( + targetPath, + args.shellPath === undefined ? {} : { shellPath: args.shellPath }, + ); } diff --git a/apps/host-daemon/src/command-handlers/root-path.ts b/apps/host-daemon/src/command-handlers/root-path.ts index 4ec717de7b..fa3e60ed34 100644 --- a/apps/host-daemon/src/command-handlers/root-path.ts +++ b/apps/host-daemon/src/command-handlers/root-path.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import { CommandDispatchError } from "../command-dispatch-support.js"; -export interface ResolveNonSymlinkDirectoryPathArgs { +interface ResolveNonSymlinkDirectoryPathArgs { description: string; path: string; } diff --git a/apps/host-daemon/src/command-handlers/thread.ts b/apps/host-daemon/src/command-handlers/thread.ts index 6a0ea62cde..1e7eafe071 100644 --- a/apps/host-daemon/src/command-handlers/thread.ts +++ b/apps/host-daemon/src/command-handlers/thread.ts @@ -1,11 +1,11 @@ import fs from "node:fs/promises"; -import semver from "semver"; import type { PromptInput } from "@bb/domain"; import type { HostDaemonCommandResult } from "@bb/host-daemon-contract"; import { resolveContainedPath } from "@bb/process-utils"; import type { RuntimeEntry } from "../runtime-manager.js"; import { CommandDispatchError, + defaultProviderInstallationStatus, ExpectedCommandDispatchError, resolveRuntimeBridgeLaunch, type CommandDispatchOptions, @@ -16,13 +16,20 @@ import { stagePromptAttachments, } from "./prompt-attachments.js"; import { requireResolvedWorkspaceForCommand } from "../workspace-resolution.js"; -import { getProviderCliStatusForProvider } from "../provider-cli-health.js"; type TurnSubmitCommand = CommandOf<"turn.submit">; type ExistingThreadRuntimeCommand = | TurnSubmitCommand | CommandOf<"thread.goal.clear">; +// The server marks a thread active before the provider's turn/started reaches +// it. An auto/steer submit created in that gap carries no expected turn id even +// though the preceding command is already opening one. The daemon serializes +// turn submissions, so give the runtime-owned turn state a bounded chance to +// catch up. If it is still pending after that bound, fail closed rather than +// launch a competing turn. +const TURN_SUBMIT_ACTIVE_TURN_WAIT_MS = 5_000; + interface ResumeThreadRuntimeIfMissingArgs { command: ExistingThreadRuntimeCommand; entry: RuntimeEntry; @@ -50,8 +57,6 @@ interface RequireSupportedProviderCliArgs { options: CommandDispatchOptions; } -const CODEX_REWIND_MINIMUM_SUPPORTED_VERSION = "0.143.0"; - function requireConfinedPath(rootPath: string, candidatePath: string): string { const resolved = resolveContainedPath({ rootPath, @@ -96,38 +101,37 @@ async function requireSupportedProviderCliForThreadStart({ command, options, }: RequireSupportedProviderCliArgs): Promise { - if (command.providerId !== "codex") { + if (!command.bridgeLaunch.capabilities.experimental_providerInstallation) { return; } - const status = - (await options.getProviderCliStatusForProvider?.(command.providerId)) ?? - (await getProviderCliStatusForProvider("codex", { - env: options.runtimeManager.getShellEnv(), - })); - const minimumVersion = - command.type === "thread.rewind.prepare" - ? CODEX_REWIND_MINIMUM_SUPPORTED_VERSION - : status.minimumSupportedVersion; - const versionUnsupported = - command.type === "thread.rewind.prepare" - ? status.currentVersion === null || - !semver.gte( - status.currentVersion, - CODEX_REWIND_MINIMUM_SUPPORTED_VERSION, - ) - : status.versionUnsupported; - if (!versionUnsupported) { + const bridgeLaunch = await resolveRuntimeBridgeLaunch( + command.bridgeLaunch, + options, + ); + const status = await ( + options.providerInstallationStatus ?? defaultProviderInstallationStatus + )({ + providerId: command.providerId, + bridgeLaunch, + ...(command.acpLaunchSpec === undefined + ? {} + : { acpLaunchSpec: command.acpLaunchSpec }), + ...(command.type === "thread.rewind.prepare" + ? { requirement: "thread_rewind" as const } + : {}), + }); + if (!status.versionUnsupported) { return; } const currentVersion = status.currentVersion ? ` ${status.currentVersion}` : ""; - const requiredVersion = minimumVersion ?? "a newer version"; + const requiredVersion = status.minimumSupportedVersion ?? "a newer version"; throw new ExpectedCommandDispatchError( "provider_cli_unsupported_version", - `Codex${currentVersion} is too old for this operation. Update Codex to ${requiredVersion} or newer.`, + `Provider "${command.providerId}"${currentVersion} is too old for this operation. Update it to ${requiredVersion} or newer.`, ); } @@ -396,6 +400,57 @@ async function steerSubmittedTurn( ); } +async function resolveSubmittedTurnTarget( + command: TurnSubmitCommand, + entry: RuntimeEntry, +): Promise { + if (command.target.mode === "start") { + return null; + } + // Explicit steer preserves its vouched target. Auto mode intentionally + // rebases onto the daemon's live turn when the server snapshot is stale. + if ( + command.target.mode === "steer" && + command.target.expectedTurnId !== null + ) { + return command.target.expectedTurnId; + } + const activeTurnId = entry.runtime.getActiveTurnId(command.threadId); + if (activeTurnId !== null) { + return activeTurnId; + } + if (command.target.expectedTurnId !== null) { + return command.target.expectedTurnId; + } + // With no active id, a live thread means the runtime has accepted a start + // whose turn/started event is still pending. If that prior turn already + // completed, it is no longer live and this input can start immediately. + if (!entry.runtime.getLiveThreadIds().includes(command.threadId)) { + return null; + } + const awaitedTurnId = await entry.runtime.waitForActiveTurn( + command.threadId, + { + timeoutMs: TURN_SUBMIT_ACTIVE_TURN_WAIT_MS, + }, + ); + if (awaitedTurnId !== null) { + return awaitedTurnId; + } + // The timeout and provider event can race. Re-read both facts before + // deciding whether the previous start completed or remains unresolved. + const refreshedTurnId = entry.runtime.getActiveTurnId(command.threadId); + if (refreshedTurnId !== null) { + return refreshedTurnId; + } + if (entry.runtime.getLiveThreadIds().includes(command.threadId)) { + throw new Error( + `Refusing to start a competing turn while ${command.threadId} is still starting`, + ); + } + return null; +} + export async function submitTurn( command: TurnSubmitCommand, entry: RuntimeEntry, @@ -420,27 +475,23 @@ export async function submitTurn( entry, options, }); + const resolvedTurnId = await resolveSubmittedTurnTarget( + stagedCommand, + entry, + ); switch (command.target.mode) { case "start": return await runSubmittedTurn(stagedCommand, entry); case "auto": - return command.target.expectedTurnId - ? await steerSubmittedTurn( - stagedCommand, - entry, - command.target.expectedTurnId, - ) + return resolvedTurnId + ? await steerSubmittedTurn(stagedCommand, entry, resolvedTurnId) : await runSubmittedTurn(stagedCommand, entry); case "steer": - if (!command.target.expectedTurnId) { + if (!resolvedTurnId) { // The server saw no active turn, but the user's intent is still "send". return await runSubmittedTurn(stagedCommand, entry); } - return await steerSubmittedTurn( - stagedCommand, - entry, - command.target.expectedTurnId, - ); + return await steerSubmittedTurn(stagedCommand, entry, resolvedTurnId); } } catch (error) { await cleanupAfterPostStagingFailure(staged.cleanup); diff --git a/apps/host-daemon/src/command-router.ts b/apps/host-daemon/src/command-router.ts index 9ce77b1064..a40cdffa00 100644 --- a/apps/host-daemon/src/command-router.ts +++ b/apps/host-daemon/src/command-router.ts @@ -25,6 +25,7 @@ import { type CommandDispatchOptions, } from "./command-dispatch.js"; import { isExpectedOnlineRpcFailureError } from "./command-dispatch-support.js"; +import { roundDurationMs } from "./event-loop-stall-monitor.js"; import type { HostDaemonLogger } from "./logger.js"; import { RuntimeManager } from "./runtime-manager.js"; import type { PluginHostManager } from "./plugin-host-manager.js"; @@ -109,6 +110,10 @@ export interface CommandRouterOptions { terminalManager?: CommandDispatchOptions["terminalManager"]; eventSink: CommandDispatchOptions["eventSink"]; listModels?: CommandDispatchOptions["listModels"]; + providerHealth?: CommandDispatchOptions["providerHealth"]; + providerUsage?: CommandDispatchOptions["providerUsage"]; + providerInstallationStatus?: CommandDispatchOptions["providerInstallationStatus"]; + providerInstallationRun?: CommandDispatchOptions["providerInstallationRun"]; resolveInteractiveRequest?: CommandDispatchOptions["resolveInteractiveRequest"]; pluginHostManager?: PluginHostManager; ensureConnectTunnelIdentity?: CommandDispatchOptions["ensureConnectTunnelIdentity"]; @@ -119,10 +124,6 @@ export interface CommandRouterOptions { const HOST_COMMAND_LIFECYCLE_LOG_THRESHOLD_MS = 1_000; const CODEX_PROVIDER_ID = "codex"; -function roundDurationMs(durationMs: number): number { - return Math.round(durationMs * 10) / 10; -} - function elapsedMs(startedAtMs: number): number { return performance.now() - startedAtMs; } @@ -225,7 +226,7 @@ export class CommandRouter { } return this.options.pluginHostManager.dispose(command); } - const environmentLaneMode = this.getEnvironmentLaneMode(command); + const environmentLaneMode = hostDaemonEnvironmentLaneForCommand(command); const result = environmentLaneMode && "environmentId" in command ? this.runInEnvironmentLane( @@ -243,7 +244,7 @@ export class CommandRouter { private executeLiveDaemonCommand( command: HostDaemonCommand, ): Promise { - const environmentLaneMode = this.getEnvironmentLaneMode(command); + const environmentLaneMode = hostDaemonEnvironmentLaneForCommand(command); const providerLane = this.resolveProviderLane(command); const task = this.runAfterThreadUnarchiveBarrier(command, () => this.runInThreadTurnLane(command, () => @@ -342,6 +343,10 @@ export class CommandRouter { dataDir: this.options.dataDir, eventSink: this.options.eventSink, listModels: this.options.listModels, + providerHealth: this.options.providerHealth, + providerUsage: this.options.providerUsage, + providerInstallationStatus: this.options.providerInstallationStatus, + providerInstallationRun: this.options.providerInstallationRun, resolveInteractiveRequest: this.options.resolveInteractiveRequest, ensureConnectTunnelIdentity: this.options.ensureConnectTunnelIdentity, threadStorageRootPath: this.options.threadStorageRootPath, @@ -726,10 +731,4 @@ export class CommandRouter { return null; } } - - private getEnvironmentLaneMode( - command: HostDaemonCommand | HostDaemonOnlineRpcCommand, - ): EnvironmentLaneMode | null { - return hostDaemonEnvironmentLaneForCommand(command); - } } diff --git a/apps/host-daemon/src/connect-tunnel/index.ts b/apps/host-daemon/src/connect-tunnel/index.ts index 0bcedf7739..7ae8b5653b 100644 --- a/apps/host-daemon/src/connect-tunnel/index.ts +++ b/apps/host-daemon/src/connect-tunnel/index.ts @@ -18,7 +18,7 @@ import { import { connectPublicProtocol } from "@bb/connect-client"; import type { HostDaemonLogger } from "../logger.js"; -export type ConnectTunnelState = "connected" | "reconnecting" | "offline"; +type ConnectTunnelState = "connected" | "reconnecting" | "offline"; export interface ConnectTunnelStatus { state: ConnectTunnelState; @@ -38,7 +38,7 @@ export type ConnectTunnelFetch = ( init?: RequestInit, ) => Promise; -export interface ConnectTunnelClientOptions { +interface ConnectTunnelClientOptions { serverUrl: string; hostName: string; machineCredential?: string; @@ -55,7 +55,7 @@ interface TrustedConnectGate { baseDomain: string; } -export class ConnectTunnelCredentialRejectedError extends Error { +class ConnectTunnelCredentialRejectedError extends Error { readonly code = "credential_rejected"; constructor(message: string) { diff --git a/apps/host-daemon/src/daemon.ts b/apps/host-daemon/src/daemon.ts index 24ddf7a364..917993d04c 100644 --- a/apps/host-daemon/src/daemon.ts +++ b/apps/host-daemon/src/daemon.ts @@ -1,18 +1,18 @@ import type { HostDaemonLogger } from "./logger.js"; import { normalizeCaughtError } from "./error-utils.js"; -export interface HostDaemonIdentity { +interface HostDaemonIdentity { hostId: string; hostName: string; instanceId: string; } -export interface SignalSource { +interface SignalSource { on(event: NodeJS.Signals, listener: () => void): void; off(event: NodeJS.Signals, listener: () => void): void; } -export interface CreateDaemonOptions { +interface CreateDaemonOptions { identity: HostDaemonIdentity; logger: HostDaemonLogger; releaseLock: () => Promise; @@ -42,16 +42,7 @@ const TERMINATION_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; * after a self-update only happens once the process really exits, so a hung * shutdown step or an undrained event loop must not keep the daemon alive. */ -export const DEFAULT_SHUTDOWN_EXIT_GRACE_MS = 15_000; - -function listActiveResources(): string[] { - const getActiveResourcesInfo = ( - process as NodeJS.Process & { - getActiveResourcesInfo?: () => string[]; - } - ).getActiveResourcesInfo; - return getActiveResourcesInfo ? getActiveResourcesInfo() : []; -} +const DEFAULT_SHUTDOWN_EXIT_GRACE_MS = 15_000; export function createDaemon(options: CreateDaemonOptions): HostDaemon { let started = false; @@ -85,7 +76,7 @@ export function createDaemon(options: CreateDaemonOptions): HostDaemon { options.shutdownExitGraceMs ?? DEFAULT_SHUTDOWN_EXIT_GRACE_MS; const timer = setTimeout(() => { options.logger.error( - { reason, graceMs, activeResources: listActiveResources() }, + { reason, graceMs, activeResources: process.getActiveResourcesInfo() }, "Host daemon shutdown did not end the process; forcing exit so the service manager can restart it.", ); forceExit(0); diff --git a/apps/host-daemon/src/enroll.ts b/apps/host-daemon/src/enroll.ts index 6245ad30ad..55e98649b3 100644 --- a/apps/host-daemon/src/enroll.ts +++ b/apps/host-daemon/src/enroll.ts @@ -14,7 +14,7 @@ interface EnrollHostArgs { token: string; } -export interface EnrollHostResult { +interface EnrollHostResult { hostId: string; hostKey: string; } diff --git a/apps/host-daemon/src/event-loop-stall-monitor.test.ts b/apps/host-daemon/src/event-loop-stall-monitor.test.ts new file mode 100644 index 0000000000..37fa5fc1b1 --- /dev/null +++ b/apps/host-daemon/src/event-loop-stall-monitor.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const perfHooksMock = vi.hoisted(() => ({ + histogram: { + disable: vi.fn(), + enable: vi.fn(), + max: 600_000_000_000, + mean: 1_000_000, + percentile: vi.fn(() => 1_000_000), + reset: vi.fn(), + }, +})); + +vi.mock("node:perf_hooks", () => ({ + monitorEventLoopDelay: vi.fn(() => perfHooksMock.histogram), +})); + +import { startEventLoopStallMonitor } from "./event-loop-stall-monitor.js"; + +describe("host event-loop stall monitor", () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("suppresses histogram delays accumulated while the system was suspended", () => { + vi.useFakeTimers(); + let now = 0; + const logger = { warn: vi.fn() }; + const monitor = startEventLoopStallMonitor({ logger, now: () => now }); + + now = 300_000; + vi.advanceTimersByTime(5_000); + + expect(logger.warn).not.toHaveBeenCalled(); + expect(perfHooksMock.histogram.reset).toHaveBeenCalledOnce(); + monitor.stop(); + }); + + it("still reports a sub-minute event-loop stall", () => { + vi.useFakeTimers(); + perfHooksMock.histogram.max = 600_000_000; + let now = 0; + const logger = { warn: vi.fn() }; + const monitor = startEventLoopStallMonitor({ logger, now: () => now }); + + now = 5_000; + vi.advanceTimersByTime(5_000); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ maxDelayMs: 600 }), + "Host daemon event loop stalled", + ); + monitor.stop(); + }); +}); diff --git a/apps/host-daemon/src/event-loop-stall-monitor.ts b/apps/host-daemon/src/event-loop-stall-monitor.ts index 9651576fbc..982e49c5b1 100644 --- a/apps/host-daemon/src/event-loop-stall-monitor.ts +++ b/apps/host-daemon/src/event-loop-stall-monitor.ts @@ -1,8 +1,11 @@ import { monitorEventLoopDelay } from "node:perf_hooks"; import type { HostDaemonLogger } from "./logger.js"; +import { isLikelySystemSuspensionDelay } from "./system-suspension.js"; interface EventLoopStallMonitorOptions { logger: Pick; + /** Injectable monotonic-enough wall clock for tests. */ + now?: () => number; } interface EventLoopStallMonitor { @@ -18,7 +21,7 @@ function nanosecondsToMilliseconds(durationNs: number): number { return durationNs / NANOSECONDS_PER_MILLISECOND; } -function roundDurationMs(durationMs: number): number { +export function roundDurationMs(durationMs: number): number { return Math.round(durationMs * 10) / 10; } @@ -28,12 +31,20 @@ export function startEventLoopStallMonitor( const thresholdMs = DEFAULT_EVENT_LOOP_STALL_LOG_THRESHOLD_MS; const intervalMs = DEFAULT_EVENT_LOOP_STALL_MONITOR_INTERVAL_MS; const resolutionMs = DEFAULT_EVENT_LOOP_STALL_MONITOR_RESOLUTION_MS; + const now = options.now ?? (() => Date.now()); const histogram = monitorEventLoopDelay({ resolution: resolutionMs }); histogram.enable(); + let lastSampleAt = now(); const timer = setInterval(() => { + const sampledAt = now(); + const sampleGapMs = sampledAt - lastSampleAt; + lastSampleAt = sampledAt; const maxDelayMs = nanosecondsToMilliseconds(histogram.max); - if (maxDelayMs >= thresholdMs) { + if ( + !isLikelySystemSuspensionDelay({ gapMs: sampleGapMs, intervalMs }) && + maxDelayMs >= thresholdMs + ) { options.logger.warn( { intervalMs, diff --git a/apps/host-daemon/src/event-sink.test.ts b/apps/host-daemon/src/event-sink.test.ts index 9bafbb7787..5d03acc5bb 100644 --- a/apps/host-daemon/src/event-sink.test.ts +++ b/apps/host-daemon/src/event-sink.test.ts @@ -27,7 +27,6 @@ function createLogger(): CreateEventSinkOptions["logger"] { function acceptingPostEvents() { return vi.fn(async (events) => ({ - kind: "accepted", acceptedEvents: events.map((event, eventIndex) => ({ eventIndex, sequence: eventIndex + 1, @@ -90,7 +89,6 @@ describe("event sink", () => { .fn() .mockRejectedValueOnce(new Error("response lost")) .mockImplementation(async (events) => ({ - kind: "accepted", acceptedEvents: events.map((event, eventIndex) => ({ eventIndex, sequence: eventIndex + 1, @@ -117,17 +115,18 @@ describe("event sink", () => { it("drops rejected events with a warning without throwing", async () => { const logger = createLogger(); - const postEvents = vi.fn(async () => ({ - kind: "accepted", - acceptedEvents: [], - rejectedEvents: [ - { - eventIndex: 0, - reason: "thread_not_owned_by_host", - threadId: "thr_1", - }, - ], - })); + const postEvents = vi.fn( + async () => ({ + acceptedEvents: [], + rejectedEvents: [ + { + eventIndex: 0, + reason: "thread_not_owned_by_host", + threadId: "thr_1", + }, + ], + }), + ); const sink = createEventSink({ isSessionOpen: () => true, logger, @@ -144,11 +143,13 @@ describe("event sink", () => { expect(postEvents).toHaveBeenCalledTimes(1); }); - it("warns once when the queue grows large while undelivered", () => { + it("warns once when a large queue remains undelivered", () => { const logger = createLogger(); + let now = 0; const sink = createEventSink({ isSessionOpen: () => false, logger, + now: () => now, postEvents: acceptingPostEvents(), }); @@ -157,12 +158,36 @@ describe("event sink", () => { } expect(logger.warn).not.toHaveBeenCalled(); - // Crossing the depth threshold fires the tripwire once... + // A fresh event burst is throughput, not evidence of a stalled delivery. sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); + expect(logger.warn).not.toHaveBeenCalled(); + + // Remaining above the depth threshold for five seconds fires once. + now = 5_000; sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ queueDepth: 512 }), + expect.objectContaining({ queueAgeMs: 5_000, queueDepth: 513 }), + expect.any(String), + ); + }); + + it("warns when even a small queue is stalled for thirty seconds", () => { + const logger = createLogger(); + let now = 0; + const sink = createEventSink({ + isSessionOpen: () => false, + logger, + now: () => now, + postEvents: acceptingPostEvents(), + }); + + sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); + now = 30_000; + sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ queueAgeMs: 30_000, queueDepth: 2 }), expect.any(String), ); }); @@ -177,7 +202,6 @@ describe("event sink", () => { ); } return { - kind: "accepted", acceptedEvents: events.map((event, eventIndex) => ({ eventIndex, sequence: eventIndex + 1, @@ -221,7 +245,6 @@ describe("event sink", () => { } delivered.push(...events.map((event) => event.threadId)); return { - kind: "accepted", acceptedEvents: events.map((event, eventIndex) => ({ eventIndex, sequence: eventIndex + 1, @@ -277,7 +300,6 @@ describe("event sink", () => { }), ) .mockImplementation(async (events) => ({ - kind: "accepted", acceptedEvents: events.map((event, eventIndex) => ({ eventIndex, sequence: eventIndex + 1, @@ -319,7 +341,6 @@ describe("event sink", () => { }), ) .mockImplementation(async (events) => ({ - kind: "accepted", acceptedEvents: events.map((event, eventIndex) => ({ eventIndex, sequence: eventIndex + 1, diff --git a/apps/host-daemon/src/event-sink.ts b/apps/host-daemon/src/event-sink.ts index 4280556341..1e5707db5d 100644 --- a/apps/host-daemon/src/event-sink.ts +++ b/apps/host-daemon/src/event-sink.ts @@ -14,6 +14,7 @@ const DEFAULT_DEBOUNCE_MS = 100; // growing. These only warn — they never drop, fault, or bound the queue. If // they fire in practice, that is the signal to add real backpressure. const QUEUE_DEPTH_WARN_THRESHOLD = 512; +const QUEUE_DEPTH_WARN_MIN_AGE_MS = 5_000; const QUEUE_AGE_WARN_THRESHOLD_MS = 30_000; export interface EventSinkInput { @@ -24,19 +25,19 @@ export interface EventSinkInput { export interface EventPostResult { acceptedEvents: HostDaemonEventBatchResponse["acceptedEvents"]; rejectedEvents: HostDaemonEventBatchResponse["rejectedEvents"]; - kind: "accepted"; } export interface CreateEventSinkOptions { isSessionOpen: () => boolean; logger: Pick; + /** Injectable wall clock for queue-age tests. */ + now?: () => number; postEvents: (events: HostDaemonEventEnvelope[]) => Promise; } export interface EventSink { emit(event: EventSinkInput): void; flush(): Promise; - flushRequired(): Promise; dispose(): Promise; } @@ -68,7 +69,7 @@ function isWaitingForApprovalItemEvent(event: ThreadEvent): boolean { return event.item.approvalStatus === "waiting_for_approval"; } -export function shouldFlushThreadEventImmediately(event: ThreadEvent): boolean { +function shouldFlushThreadEventImmediately(event: ThreadEvent): boolean { if (event.type === "turn/started" || event.type === "item/completed") { return true; } @@ -116,6 +117,7 @@ function summarizeRejectedEvents( } export function createEventSink(options: CreateEventSinkOptions): EventSink { + const now = options.now ?? (() => Date.now()); const queue: HostDaemonEventEnvelope[] = []; let flushTimer: ReturnType | null = null; let flushPromise: Promise | null = null; @@ -130,10 +132,11 @@ export function createEventSink(options: CreateEventSinkOptions): EventSink { return; } const queueDepth = queue.length; - const queueAgeMs = Date.now() - backedUpSinceMs; + const queueAgeMs = now() - backedUpSinceMs; if ( - queueDepth < QUEUE_DEPTH_WARN_THRESHOLD && - queueAgeMs < QUEUE_AGE_WARN_THRESHOLD_MS + queueAgeMs < QUEUE_AGE_WARN_THRESHOLD_MS && + (queueDepth < QUEUE_DEPTH_WARN_THRESHOLD || + queueAgeMs < QUEUE_DEPTH_WARN_MIN_AGE_MS) ) { return; } @@ -281,7 +284,7 @@ export function createEventSink(options: CreateEventSinkOptions): EventSink { throw new EventSinkDisposedError(); } if (backedUpSinceMs === null) { - backedUpSinceMs = Date.now(); + backedUpSinceMs = now(); } queue.push({ threadId: input.threadId, @@ -293,7 +296,6 @@ export function createEventSink(options: CreateEventSinkOptions): EventSink { ); }, flush, - flushRequired: flush, async dispose(): Promise { disposed = true; clearScheduledFlush(); diff --git a/apps/host-daemon/src/host-daemon-health-monitor.ts b/apps/host-daemon/src/host-daemon-health-monitor.ts index 09f1440f77..671f92eb9c 100644 --- a/apps/host-daemon/src/host-daemon-health-monitor.ts +++ b/apps/host-daemon/src/host-daemon-health-monitor.ts @@ -23,7 +23,7 @@ type HostDaemonHealthMonitorIntervalFn = ( intervalMs: number, ) => HostDaemonHealthMonitorTimer; -export interface HostDaemonWatchCounts { +interface HostDaemonWatchCounts { workspaceWatches: number; threadStorageTargets: number; } @@ -70,7 +70,7 @@ function countInotifyInstances(fds: string[]): number { return count; } -export function defaultReadResourceUsage(): HostDaemonResourceUsage { +function defaultReadResourceUsage(): HostDaemonResourceUsage { const rssBytes = process.memoryUsage().rss; let openFds: number | null = null; let inotifyInstances: number | null = null; diff --git a/apps/host-daemon/src/index.ts b/apps/host-daemon/src/index.ts index 790e365c80..50ff178a3b 100644 --- a/apps/host-daemon/src/index.ts +++ b/apps/host-daemon/src/index.ts @@ -34,13 +34,7 @@ function resolveEntrypointBridgeBundleDir(): string | undefined { } function resolveDiagnosticsLogsDir(): string { - const hostDaemonStartConfig = loadHostDaemonStartConfig({ - enableLocalApi: true, - }); - - if (hostDaemonStartConfig.dataDir === undefined) { - throw new Error("Host daemon data directory is required"); - } + const hostDaemonStartConfig = loadHostDaemonStartConfig({}); return join(hostDaemonStartConfig.dataDir, "logs"); } diff --git a/apps/host-daemon/src/injected-skills.ts b/apps/host-daemon/src/injected-skills.ts index f9e1a8bba5..3376e252df 100644 --- a/apps/host-daemon/src/injected-skills.ts +++ b/apps/host-daemon/src/injected-skills.ts @@ -6,6 +6,7 @@ import { resolveDataDirSkillsRootPath } from "@bb/config/skill-storage-paths"; import type { AgentRuntimeSkillRoot } from "@bb/agent-runtime"; import type { HostDaemonInjectedSkillSource } from "@bb/host-daemon-contract"; import type { HostDaemonSkillTree } from "@bb/host-daemon-contract"; +import { isFsErrorWithCode } from "./fs-errors.js"; import type { FetchSkillTree } from "./skill-trees.js"; const STAGING_ROOT_SEGMENTS = ["runtime", "global-skills"] as const; @@ -29,25 +30,25 @@ export interface InjectedSkillsLogger { warn(context: object, message: string): void; } -export interface StageInjectedSkillSourcesArgs { +interface StageInjectedSkillSourcesArgs { dataDir: string; fetchSkillTree?: FetchSkillTree; injectedSkillSources: readonly HostDaemonInjectedSkillSource[]; logger?: InjectedSkillsLogger; } -export interface CleanupInjectedSkillStagingDirsArgs { +interface CleanupInjectedSkillStagingDirsArgs { dataDir: string; keepCatalogHashes: readonly string[]; logger?: InjectedSkillsLogger; } -export interface StagedInjectedSkills { +interface StagedInjectedSkills { catalogHash: string; skillRoots: readonly AgentRuntimeSkillRoot[]; } -export interface CopyInjectedSkillSourceArgs { +interface CopyInjectedSkillSourceArgs { destinationPath: string; name: string; sourceRootPath: string; @@ -202,10 +203,6 @@ function createNoopLogger(): InjectedSkillsLogger { }; } -function isFsErrorWithCode(error: Error, code: string): boolean { - return "code" in error && error.code === code; -} - function resolveStagingRootPath(dataDir: string): string { return path.join(dataDir, ...STAGING_ROOT_SEGMENTS); } @@ -493,7 +490,7 @@ async function writeStageRoot(args: WriteStageRootArgs): Promise { await fs.access(path.join(stageRootPath, "catalog.json")); return stageRootPath; } catch (error) { - if (!(error instanceof Error) || !isFsErrorWithCode(error, "ENOENT")) { + if (!isFsErrorWithCode(error, "ENOENT")) { throw error; } } @@ -537,9 +534,8 @@ async function writeStageRoot(args: WriteStageRootArgs): Promise { await fs.rename(tempRootPath, stageRootPath); } catch (error) { if ( - error instanceof Error && - (isFsErrorWithCode(error, "EEXIST") || - isFsErrorWithCode(error, "ENOTEMPTY")) + isFsErrorWithCode(error, "EEXIST") || + isFsErrorWithCode(error, "ENOTEMPTY") ) { await fs.rm(tempRootPath, { recursive: true, force: true }); return stageRootPath; @@ -714,7 +710,7 @@ async function gcSkillStore(dataDir: string): Promise { try { entries = await fs.readdir(storeRootPath, { withFileTypes: true }); } catch (error) { - if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) return; + if (isFsErrorWithCode(error, "ENOENT")) return; throw error; } const completeTrees: { name: string; usedAt: number }[] = []; @@ -730,7 +726,7 @@ async function gcSkillStore(dataDir: string): Promise { ); completeTrees.push({ name: entry.name, usedAt: stat.mtimeMs }); } catch (error) { - if (!(error instanceof Error) || !isFsErrorWithCode(error, "ENOENT")) { + if (!isFsErrorWithCode(error, "ENOENT")) { throw error; } } @@ -801,9 +797,8 @@ async function writeFetchedTreeToStore(args: { await fs.rename(tempRootPath, treeRootPath); } catch (error) { if ( - error instanceof Error && - (isFsErrorWithCode(error, "EEXIST") || - isFsErrorWithCode(error, "ENOTEMPTY")) + isFsErrorWithCode(error, "EEXIST") || + isFsErrorWithCode(error, "ENOTEMPTY") ) { await fs.rm(tempRootPath, { recursive: true, force: true }); } else { @@ -835,7 +830,7 @@ export async function ensureStoredSkillTree(args: { await gcSkillStore(args.dataDir); return path.join(treeRootPath, STORE_CONTENT_DIR); } catch (error) { - if (!(error instanceof Error) || !isFsErrorWithCode(error, "ENOENT")) { + if (!isFsErrorWithCode(error, "ENOENT")) { throw error; } } @@ -967,7 +962,7 @@ export async function cleanupInjectedSkillStagingDirs( try { entries = await fs.readdir(stagingRootPath, { withFileTypes: true }); } catch (error) { - if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) { + if (isFsErrorWithCode(error, "ENOENT")) { return; } throw error; @@ -985,7 +980,7 @@ export async function cleanupInjectedSkillStagingDirs( try { mtimeMs = (await fs.stat(entryPath)).mtimeMs; } catch (error) { - if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) { + if (isFsErrorWithCode(error, "ENOENT")) { return; } throw error; diff --git a/apps/host-daemon/src/interactive-request-registry.test.ts b/apps/host-daemon/src/interactive-request-registry.test.ts index 917966f052..660e3a7e8c 100644 --- a/apps/host-daemon/src/interactive-request-registry.test.ts +++ b/apps/host-daemon/src/interactive-request-registry.test.ts @@ -4,17 +4,12 @@ import type { PendingInteractionResolution, } from "@bb/domain"; import type { HostDaemonInteractiveRequestResponse } from "@bb/host-daemon-contract"; +import { createDeferredPromise } from "@bb/test-helpers"; import { InteractiveRequestRegistry, InteractiveRequestRegistryError, } from "./interactive-request-registry.js"; -interface Deferred { - promise: Promise; - reject: (error: Error) => void; - resolve: (value: TValue) => void; -} - interface CreateRegistryArgs { registerRequest: ( request: PendingInteractionCreate, @@ -25,20 +20,6 @@ interface CreateCommandApprovalRequestArgs { providerRequestId?: string; } -function createDeferred(): Deferred { - let resolveValue: (value: TValue) => void = () => {}; - let rejectValue: (error: Error) => void = () => {}; - const promise = new Promise((resolve, reject) => { - resolveValue = resolve; - rejectValue = reject; - }); - return { - promise, - reject: rejectValue, - resolve: resolveValue, - }; -} - function createCommandApprovalRequest( args: CreateCommandApprovalRequestArgs = {}, ): PendingInteractionCreate { @@ -104,7 +85,8 @@ describe("InteractiveRequestRegistry", () => { it("deduplicates registration retries for the same live provider request", async () => { const request = createCommandApprovalRequest(); - const registration = createDeferred(); + const registration = + createDeferredPromise(); const registrations: PendingInteractionCreate[] = []; const registry = createRegistry({ registerRequest: async (registeredRequest) => { diff --git a/apps/host-daemon/src/interactive-request-registry.ts b/apps/host-daemon/src/interactive-request-registry.ts index e0f95e2dbe..c353bc710f 100644 --- a/apps/host-daemon/src/interactive-request-registry.ts +++ b/apps/host-daemon/src/interactive-request-registry.ts @@ -16,12 +16,12 @@ export interface InteractiveResolveCommandInput { threadId: string; } -export interface InteractiveRequestRegistrationFailure { +interface InteractiveRequestRegistrationFailure { error: Error; request: PendingInteractionCreate; } -export interface InteractiveRequestRegistryOptions { +interface InteractiveRequestRegistryOptions { onRegistrationFailure?: ( failure: InteractiveRequestRegistrationFailure, ) => void; @@ -30,7 +30,7 @@ export interface InteractiveRequestRegistryOptions { ) => Promise; } -export interface InterruptInteractiveThreadsArgs { +interface InterruptInteractiveThreadsArgs { providerId: string; reason: string; threadIds: readonly string[]; diff --git a/apps/host-daemon/src/local-api-config.test.ts b/apps/host-daemon/src/local-api-config.test.ts deleted file mode 100644 index b1ed974516..0000000000 --- a/apps/host-daemon/src/local-api-config.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - DEFAULT_HOST_DAEMON_LOCAL_BIND_HOST, - DEFAULT_HOST_DAEMON_LOCAL_HEALTH_PATH, - DEFAULT_HOST_DAEMON_LOCAL_HEALTH_VALUE, -} from "@bb/host-daemon-contract"; -import { resolveHostDaemonLocalApiConfig } from "./local-api-config.js"; - -describe("host daemon local API config", () => { - it("uses persistent defaults for persistent hosts", () => { - expect( - resolveHostDaemonLocalApiConfig({ - hostDaemonPort: 3999, - hostType: "persistent", - localApi: undefined, - }), - ).toEqual({ - bindHost: DEFAULT_HOST_DAEMON_LOCAL_BIND_HOST, - healthPath: DEFAULT_HOST_DAEMON_LOCAL_HEALTH_PATH, - healthValue: DEFAULT_HOST_DAEMON_LOCAL_HEALTH_VALUE, - mode: "full", - port: 3999, - }); - }); - - it("allows explicit overrides on top of the host-type preset", () => { - expect( - resolveHostDaemonLocalApiConfig({ - hostDaemonPort: 3999, - hostType: "persistent", - localApi: { - bindHost: "127.0.0.1", - healthPath: "/ready", - healthValue: "healthy", - mode: "health-only", - port: 9123, - }, - }), - ).toEqual({ - bindHost: "127.0.0.1", - healthPath: "/ready", - healthValue: "healthy", - mode: "health-only", - port: 9123, - }); - }); -}); diff --git a/apps/host-daemon/src/local-api-config.ts b/apps/host-daemon/src/local-api-config.ts index 825a5e5b05..4b90545f06 100644 --- a/apps/host-daemon/src/local-api-config.ts +++ b/apps/host-daemon/src/local-api-config.ts @@ -1,55 +1,23 @@ -import type { HostType } from "@bb/domain"; import { DEFAULT_HOST_DAEMON_LOCAL_BIND_HOST, DEFAULT_HOST_DAEMON_LOCAL_HEALTH_PATH, DEFAULT_HOST_DAEMON_LOCAL_HEALTH_VALUE, } from "@bb/host-daemon-contract"; -export type HostDaemonLocalApiMode = "full" | "health-only"; - export interface HostDaemonLocalApiConfig { bindHost: string; healthPath: string; healthValue: string; - mode: HostDaemonLocalApiMode; port: number; } -export interface HostDaemonLocalApiOverrides { - bindHost?: string; - healthPath?: string; - healthValue?: string; - mode?: HostDaemonLocalApiMode; - port?: number; -} - -export interface ResolveHostDaemonLocalApiConfigArgs { +export function resolveHostDaemonLocalApiConfig(args: { hostDaemonPort: number; - hostType: HostType; - localApi: HostDaemonLocalApiOverrides | undefined; -} - -function getHostDaemonLocalApiDefaults( - args: ResolveHostDaemonLocalApiConfigArgs, -): HostDaemonLocalApiConfig { +}): HostDaemonLocalApiConfig { return { bindHost: DEFAULT_HOST_DAEMON_LOCAL_BIND_HOST, healthPath: DEFAULT_HOST_DAEMON_LOCAL_HEALTH_PATH, healthValue: DEFAULT_HOST_DAEMON_LOCAL_HEALTH_VALUE, - mode: "full", port: args.hostDaemonPort, }; } - -export function resolveHostDaemonLocalApiConfig( - args: ResolveHostDaemonLocalApiConfigArgs, -): HostDaemonLocalApiConfig { - const defaults = getHostDaemonLocalApiDefaults(args); - return { - bindHost: args.localApi?.bindHost ?? defaults.bindHost, - healthPath: args.localApi?.healthPath ?? defaults.healthPath, - healthValue: args.localApi?.healthValue ?? defaults.healthValue, - mode: args.localApi?.mode ?? defaults.mode, - port: args.localApi?.port ?? defaults.port, - }; -} diff --git a/apps/host-daemon/src/local-api.test.ts b/apps/host-daemon/src/local-api.test.ts index d7d31992f2..142f8d1143 100644 --- a/apps/host-daemon/src/local-api.test.ts +++ b/apps/host-daemon/src/local-api.test.ts @@ -24,7 +24,6 @@ describe("local API server", () => { bindHost: "localhost", healthPath: "/health", healthValue: "ok", - mode: "full", port: 0, ...overrides, }; @@ -98,6 +97,25 @@ describe("local API server", () => { expect(openInTarget).toHaveBeenCalledTimes(2); }); + it("allows the exact remote server origin the daemon is enrolled with", async () => { + server = await startLocalApiServer({ + hostId: "host-remote", + localApiConfig: createLocalApiConfig(), + serverUrl: "https://remote-bb.example.test/projects/proj_1", + serverPort: 0, + getConnected: () => true, + }); + + const response = await fetch(`http://localhost:${server.port}/status`, { + headers: { Origin: "https://remote-bb.example.test" }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe( + "https://remote-bb.example.test", + ); + }); + // A rebound page sends a matching Origin and Host pair, which the self-origin // branch previously accepted as the daemon's own origin. This API binds // loopback, so a genuine caller always addresses it by a loopback name or a @@ -351,12 +369,7 @@ describe("local API server", () => { expect(response.status).toBe(200); expect(openInTarget).toHaveBeenCalledWith({ - context: { - kind: "remote-ssh", - serverOrigin: "https://remote-bb.example.test", - hostId: "host_remote", - sshAuthority: "devbox", - }, + context: { kind: "remote-ssh", sshAuthority: "devbox" }, columnNumber: 4, lineNumber: 10, path: "/home/me/project/src/file.ts", @@ -457,30 +470,4 @@ describe("local API server", () => { targetId: "vscode", }); }); - - it("supports health-only mode", async () => { - server = await startLocalApiServer({ - hostId: "host-1", - localApiConfig: createLocalApiConfig({ - bindHost: "127.0.0.1", - healthPath: "/ready", - healthValue: "bb-host-daemon", - mode: "health-only", - }), - serverUrl: "http://server.test", - serverPort: 3334, - devAppPort: 5173, - getConnected: () => true, - }); - - const healthResponse = await fetch(`http://127.0.0.1:${server.port}/ready`); - expect(healthResponse.status).toBe(200); - expect(await healthResponse.text()).toBe("bb-host-daemon"); - - const client = createHostDaemonLocalClient( - `http://127.0.0.1:${server.port}`, - ); - const statusResponse = await client.status.$get(); - expect(statusResponse.status).toBe(404); - }); }); diff --git a/apps/host-daemon/src/local-api.ts b/apps/host-daemon/src/local-api.ts index 66585eab4b..bdff37d9ca 100644 --- a/apps/host-daemon/src/local-api.ts +++ b/apps/host-daemon/src/local-api.ts @@ -24,8 +24,9 @@ import { type WorkspaceOpenTargetsQuery, } from "@bb/host-daemon-contract"; import { - listWorkspaceOpenTargets, - openPathInTarget, + createWorkspaceOpenTargetRuntime, + listWorkspaceOpenTargetsWithRuntime, + openPathInTargetWithRuntime, type OpenPathInTargetArgs, WorkspaceOpenTargetError, } from "@bb/local-open-targets"; @@ -35,13 +36,12 @@ import { HTTPException } from "hono/http-exception"; import { isFsErrorWithCode } from "./fs-errors.js"; import type { HostDaemonLocalApiConfig } from "./local-api-config.js"; import { resolveHostPlatform } from "./host-platform.js"; +import { userExecutableProcessOptions } from "./user-executable-env.js"; -export type WorkspaceOpenTargetListHandler = ( +type WorkspaceOpenTargetListHandler = ( query: WorkspaceOpenTargetsQuery, ) => Promise; -export type OpenInTargetHandler = ( - request: OpenPathInTargetArgs, -) => Promise; +type OpenInTargetHandler = (request: OpenPathInTargetArgs) => Promise; /** * Browser-reachable local HTTP API for colocated setups. @@ -52,7 +52,7 @@ export type OpenInTargetHandler = ( * through the server and connected work host daemon instead of adding them to a * client. */ -export interface StartLocalApiServerOptions { +interface StartLocalApiServerOptions { dataDir?: string; hostId: string; localApiConfig: HostDaemonLocalApiConfig; @@ -69,6 +69,7 @@ export interface StartLocalApiServerOptions { getConnected: () => boolean; listWorkspaceOpenTargets?: WorkspaceOpenTargetListHandler; openInTarget?: OpenInTargetHandler; + shellEnv?: () => NodeJS.ProcessEnv; } export interface LocalApiServer { @@ -105,10 +106,6 @@ function isSelfEvidentLocalHostname(hostname: string): boolean { return /^\d{1,3}(?:\.\d{1,3}){3}$/u.test(hostname); } -function isNoEntryError(error: unknown): boolean { - return error instanceof Error && "code" in error && error.code === "ENOENT"; -} - function createClientConfigLoader( dataDir: string | undefined, nowMs: () => number = Date.now, @@ -142,7 +139,7 @@ async function readClientConfig(dataDir: string): Promise { JSON.parse(await fs.readFile(formatClientConfigPath(dataDir), "utf8")), ); } catch (error) { - if (!isNoEntryError(error)) { + if (!isFsErrorWithCode(error, "ENOENT")) { throw error; } return EMPTY_CLIENT_CONFIG; @@ -187,7 +184,7 @@ async function resolveOpenPathInTargetArgs({ if (sshAuthority === null) { throw new WorkspaceOpenTargetError({ code: "remote_mapping_missing", - message: `No SSH target configured for host ${request.context.hostId} on ${serverOrigin}. Run: bb-app client ssh-target set ${serverOrigin} `, + message: `No SSH target configured for host ${request.context.hostId} on ${serverOrigin}. Run: bb-app client ssh-target set ${serverOrigin} --host-id ${request.context.hostId}`, }); } @@ -195,8 +192,6 @@ async function resolveOpenPathInTargetArgs({ columnNumber: request.columnNumber, context: { kind: "remote-ssh", - serverOrigin, - hostId: request.context.hostId, sshAuthority, }, lineNumber: request.lineNumber, @@ -205,6 +200,12 @@ async function resolveOpenPathInTargetArgs({ }; } +function workspaceOpenTargetRuntime(options: StartLocalApiServerOptions) { + return createWorkspaceOpenTargetRuntime({ + ...userExecutableProcessOptions(options.shellEnv?.() ?? {}), + }); +} + export async function startLocalApiServer( options: StartLocalApiServerOptions, ): Promise { @@ -224,6 +225,15 @@ export async function startLocalApiServer( value: options.devAppPort, }); const allowedCorsOrigins = new Set(buildLocalAppOrigins(originArgs)); + // A daemon enrolled with a remote bb already trusts that server for command + // traffic. Trust its exact web origin for loopback editor-helper calls too, + // so an enrolled browser machine needs no duplicate BB_APP_URL setting. + try { + allowedCorsOrigins.add(new URL(options.serverUrl).origin); + } catch { + // startHostDaemon validates ordinary server URLs. Keep this boundary + // defensive for injected test/custom callers instead of failing startup. + } const isAllowedAppOrigin = async ( origin: string, requestUrl: string, @@ -281,12 +291,6 @@ export async function startLocalApiServer( } await next(); }); - app.use("*", async (c, next) => { - if (options.localApiConfig.mode === "health-only") { - return c.notFound(); - } - await next(); - }); const { get, post } = typedRoutes(app); const platform = resolveHostPlatform(); @@ -308,14 +312,26 @@ export async function startLocalApiServer( async (c, query) => c.json({ targets: await ( - options.listWorkspaceOpenTargets ?? listWorkspaceOpenTargets + options.listWorkspaceOpenTargets ?? + ((query) => + listWorkspaceOpenTargetsWithRuntime( + workspaceOpenTargetRuntime(options), + query, + )) )(query), }), ); post("/open-in-target", openInTargetRequestSchema, async (c, payload) => { try { - await (options.openInTarget ?? openPathInTarget)( + await ( + options.openInTarget ?? + ((args) => + openPathInTargetWithRuntime( + args, + workspaceOpenTargetRuntime(options), + )) + )( await resolveOpenPathInTargetArgs({ configLoader: clientConfigLoader, request: payload, diff --git a/apps/host-daemon/src/lock.ts b/apps/host-daemon/src/lock.ts index 6cb918467b..4eee60adf1 100644 --- a/apps/host-daemon/src/lock.ts +++ b/apps/host-daemon/src/lock.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import lockfile from "proper-lockfile"; +import { isFsErrorWithCode } from "./fs-errors.js"; export const DAEMON_LOCK_FILE_NAME = "daemon.lock"; @@ -17,12 +18,12 @@ const DAEMON_LOCK_ACQUIRE_RETRIES = 13; // service-manager restart. const DAEMON_LOCK_REACQUIRE_MAX_CYCLES = 20; -export interface DaemonLockLogger { +interface DaemonLockLogger { warn(fields: Record, message: string): void; error(fields: Record, message: string): void; } -export interface AcquireDaemonLockOptions { +interface AcquireDaemonLockOptions { /** Lock is treated as stale once its mtime is older than this many ms. */ staleMs?: number; /** @@ -48,13 +49,6 @@ const consoleLockLogger: DaemonLockLogger = { error: (fields, message) => console.error(message, fields), }; -function isErrorWithCode(error: unknown, code: string): boolean { - return ( - error instanceof Error && - (error as NodeJS.ErrnoException).code === code - ); -} - export async function acquireDaemonLock( dataDir: string, options: AcquireDaemonLockOptions = {}, @@ -121,7 +115,7 @@ export async function acquireDaemonLock( if (released) { return; } - if (isErrorWithCode(acquireError, "ELOCKED")) { + if (isFsErrorWithCode(acquireError, "ELOCKED")) { logger.error( { err: acquireError }, "Daemon lock is held by another live daemon; yielding the data dir", @@ -198,7 +192,7 @@ export async function acquireDaemonLock( await release?.(); } catch (error) { // A compromised lock is already dropped by proper-lockfile. - if (!isErrorWithCode(error, "ERELEASED")) { + if (!isFsErrorWithCode(error, "ERELEASED")) { throw error; } } diff --git a/apps/host-daemon/src/machine-auth-proxy.ts b/apps/host-daemon/src/machine-auth-proxy.ts index c966634cfb..4af9d40ee4 100644 --- a/apps/host-daemon/src/machine-auth-proxy.ts +++ b/apps/host-daemon/src/machine-auth-proxy.ts @@ -10,7 +10,7 @@ import type { Duplex } from "node:stream"; const LOOPBACK_HOST = "127.0.0.1"; const MACHINE_HEADER = "x-bb-connect-machine"; -export interface StartMachineAuthProxyOptions { +interface StartMachineAuthProxyOptions { machineCredential: string; serverUrl: string; port?: number; @@ -43,7 +43,7 @@ type RejectedSocketStatus = keyof typeof REJECTED_SOCKET_MESSAGES; * and a `no-cors` request still acts even though its response stays hidden, so * a browsed page must never borrow that credential. */ -export function isBrowserRequest(headers: IncomingHttpHeaders): boolean { +function isBrowserRequest(headers: IncomingHttpHeaders): boolean { return BROWSER_REQUEST_HEADERS.some((name) => headers[name] !== undefined); } @@ -81,7 +81,7 @@ function parseHostAuthority( * `http://rebind.example` is not a potentially trustworthy URL, so Chromium * sends no `Sec-Fetch-*`, and a `no-cors` GET sends no `Origin` either. */ -export function isProxyLoopbackAuthority( +function isProxyLoopbackAuthority( host: string | undefined, boundPort: number, ): boolean { diff --git a/apps/host-daemon/src/node-artifact-cache.ts b/apps/host-daemon/src/node-artifact-cache.ts index d311928ac0..abf1af4f11 100644 --- a/apps/host-daemon/src/node-artifact-cache.ts +++ b/apps/host-daemon/src/node-artifact-cache.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { mkdir, readFile, @@ -12,6 +12,7 @@ import { import { join } from "node:path"; import { HOST_ARTIFACT_MAX_BYTES } from "@bb/host-daemon-contract"; import type { HostDaemonLogger } from "./logger.js"; +import { sha256Hex } from "./sha256-hex.js"; /** * The daemon's one content-addressed cache for executable artifacts it is @@ -30,7 +31,7 @@ import type { HostDaemonLogger } from "./logger.js"; const DIGEST_PATTERN = /^[a-f0-9]{64}$/u; -export type FetchNodeArtifact = (args: { +type FetchNodeArtifact = (args: { digest: string; byteLength: number; }) => Promise; @@ -46,11 +47,11 @@ export type FetchNodeArtifact = (args: { * artifacts). Every use touches its digest directory, so age is a real * "nobody has run this in a month" signal rather than a guess. */ -export type NodeArtifactPruneStrategy = +type NodeArtifactPruneStrategy = | { kind: "keep-only-current" } | { kind: "keep-recently-used"; maxAgeMs: number }; -export interface EnsureCachedNodeArtifactArgs { +interface EnsureCachedNodeArtifactArgs { /** Root of one artifact family. Digest directories are its children. */ cacheDir: string; digest: string; @@ -68,10 +69,6 @@ export interface EnsureCachedNodeArtifactArgs { * for the same artifact share one download. */ const pendingPulls = new Map>(); -function sha256Hex(bytes: Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex"); -} - function describeMismatch( digest: string, byteLength: number, diff --git a/apps/host-daemon/src/plugin-host-artifact-cache.ts b/apps/host-daemon/src/plugin-host-artifact-cache.ts index 21b6467d09..4381f7ccd4 100644 --- a/apps/host-daemon/src/plugin-host-artifact-cache.ts +++ b/apps/host-daemon/src/plugin-host-artifact-cache.ts @@ -18,7 +18,7 @@ import type { HostDaemonLogger } from "./logger.js"; * daemon could not tell whose bridge it was about to delete. A launch now names * its plugin, so that workaround is gone.) */ -export const PLUGIN_HOST_ARTIFACT_CACHE_SEGMENT = "plugin-host-artifacts"; +const PLUGIN_HOST_ARTIFACT_CACHE_SEGMENT = "plugin-host-artifacts"; // The downloaded bundle is ESM. Keep the cache filename unambiguous so Node // does not inherit module classification from an unrelated ancestor // package.json (which can also emit MODULE_TYPELESS_PACKAGE_JSON warnings). diff --git a/apps/host-daemon/src/plugin-host-manager.ts b/apps/host-daemon/src/plugin-host-manager.ts index 63fe6314f0..3deb2bdc1c 100644 --- a/apps/host-daemon/src/plugin-host-manager.ts +++ b/apps/host-daemon/src/plugin-host-manager.ts @@ -88,7 +88,7 @@ interface ActiveCallAdmission { inputByteLength: number; } -export interface PluginHostManagerOptions { +interface PluginHostManagerOptions { dataDir: string; logger: Pick; fetchArtifact: (args: { @@ -103,7 +103,6 @@ export interface PluginHostManagerOptions { signal: string; payload: JsonValue; }) => void; - workerEntryPath?: string; /** User shell additions used for executable discovery by host plugins. */ shellEnv?: () => NodeJS.ProcessEnv; /** Native path observation shared by core and host plugins. */ @@ -463,7 +462,7 @@ export class PluginHostManager { let child: ChildProcess; try { child = fork( - this.options.workerEntryPath ?? defaultWorkerEntryPath(), + defaultWorkerEntryPath(), [artifactPath, command.pluginId, command.generation, dataDir, tempDir], { // Same answer every daemon-spawned child gets, plus the user's diff --git a/apps/host-daemon/src/protocol-self-update.ts b/apps/host-daemon/src/protocol-self-update.ts index e199d307fc..4184c3415b 100644 --- a/apps/host-daemon/src/protocol-self-update.ts +++ b/apps/host-daemon/src/protocol-self-update.ts @@ -23,7 +23,7 @@ interface UpdateAttempt { protocolVersion: number; } -export type ProtocolSelfUpdateResult = "failed" | "skipped" | "updated"; +type ProtocolSelfUpdateResult = "failed" | "skipped" | "updated"; export interface ProtocolSelfUpdater { handleProtocolMismatch(options?: { @@ -31,7 +31,7 @@ export interface ProtocolSelfUpdater { }): Promise; } -export interface ProtocolSelfUpdateInstaller { +interface ProtocolSelfUpdateInstaller { (tarballPath: string): Promise; } diff --git a/apps/host-daemon/src/provider-cli-health.test.ts b/apps/host-daemon/src/provider-cli-health.test.ts deleted file mode 100644 index b79504c3ab..0000000000 --- a/apps/host-daemon/src/provider-cli-health.test.ts +++ /dev/null @@ -1,1031 +0,0 @@ -import { PassThrough } from "node:stream"; -import { describe, expect, it } from "vitest"; -import { - CODEX_MINIMUM_SUPPORTED_VERSION, - getKnownAcpAgentsStatus, - getProviderCliStatus, - inspectProviderCli, - isProviderCliInstalled, - ProviderCliInstallInProgressError, - streamProviderCliInstall, - type ProviderCliCommandResult, - type ProviderCliCommandRunner, - type ProviderCliDefinition, - type ProviderCliInstallProcess, - type ProviderCliInstallProcessCloseListener, - type ProviderCliInstallProcessErrorListener, - type ProviderCliInstallProcessSpawner, - type RunProviderCliCommandArgs, - type SpawnProviderCliInstallProcessArgs, -} from "./provider-cli-health.js"; -import { - providerCliInstallEventSchema, - type ProviderCliInstallEvent, -} from "@bb/host-daemon-contract"; - -const CLAUDE_INSTALL_SCRIPT_URL = "https://claude.ai/install.sh"; -const CLAUDE_INSTALL_COMMAND = [ - 'tmp=$(mktemp "${TMPDIR:-/tmp}/provider-cli-install.XXXXXX")', - "trap 'rm -f \"$tmp\"' EXIT", - `curl -fsSL ${CLAUDE_INSTALL_SCRIPT_URL} -o "$tmp"`, - 'bash "$tmp"', -].join(" && "); -const CURSOR_INSTALL_SCRIPT_URL = "https://cursor.com/install"; -const CURSOR_INSTALL_COMMAND = [ - 'tmp=$(mktemp "${TMPDIR:-/tmp}/provider-cli-install.XXXXXX")', - "trap 'rm -f \"$tmp\"' EXIT", - `curl -fsSL ${CURSOR_INSTALL_SCRIPT_URL} -o "$tmp"`, - 'bash "$tmp"', -].join(" && "); - -interface FakeCommandBehavior { - stdout: string; - stderr: string; - exitCode: number | null; - signal: string | null; - errorMessage: string | null; -} - -class FakeProviderCliCommandRunner implements ProviderCliCommandRunner { - readonly calls: RunProviderCliCommandArgs[] = []; - private readonly behaviorsByKey = new Map(); - - setSuccess(command: string, args: readonly string[], stdout: string): void { - this.behaviorsByKey.set(this.keyFor(command, args), { - stdout, - stderr: "", - exitCode: 0, - signal: null, - errorMessage: null, - }); - } - - setExit( - command: string, - args: readonly string[], - exitCode: number, - stderr: string, - ): void { - this.behaviorsByKey.set(this.keyFor(command, args), { - stdout: "", - stderr, - exitCode, - signal: null, - errorMessage: null, - }); - } - - setSpawnError( - command: string, - args: readonly string[], - message: string, - ): void { - this.behaviorsByKey.set(this.keyFor(command, args), { - stdout: "", - stderr: "", - exitCode: null, - signal: null, - errorMessage: message, - }); - } - - async run( - args: RunProviderCliCommandArgs, - ): Promise { - this.calls.push(args); - const behavior = this.behaviorsByKey.get( - this.keyFor(args.command, args.args), - ); - if (!behavior) { - throw new Error(`No fake command behavior for ${this.describe(args)}`); - } - return { - command: args.command, - args: args.args, - stdout: behavior.stdout, - stderr: behavior.stderr, - exitCode: behavior.exitCode, - signal: behavior.signal, - errorMessage: behavior.errorMessage, - }; - } - - commandLines(): string[] { - return this.calls.map((call) => this.describe(call)); - } - - private keyFor(command: string, args: readonly string[]): string { - return [command, ...args].join("\0"); - } - - private describe(args: RunProviderCliCommandArgs): string { - return [args.command, ...args.args].join(" "); - } -} - -class FakeProviderCliInstallProcess implements ProviderCliInstallProcess { - readonly stdout = new PassThrough(); - readonly stderr = new PassThrough(); - readonly killSignals: NodeJS.Signals[] = []; - private readonly errorListeners: ProviderCliInstallProcessErrorListener[] = - []; - private readonly closeListeners: ProviderCliInstallProcessCloseListener[] = - []; - - kill(signal: NodeJS.Signals): boolean { - this.killSignals.push(signal); - return true; - } - - onError(listener: ProviderCliInstallProcessErrorListener): void { - this.errorListeners.push(listener); - } - - onClose(listener: ProviderCliInstallProcessCloseListener): void { - this.closeListeners.push(listener); - } - - emitError(error: Error): void { - for (const listener of this.errorListeners) { - listener(error); - } - } - - emitClose(exitCode: number | null, signal: NodeJS.Signals | null): void { - for (const listener of this.closeListeners) { - listener(exitCode, signal); - } - } -} - -class FakeProviderCliInstallProcessSpawner implements ProviderCliInstallProcessSpawner { - readonly processes: FakeProviderCliInstallProcess[] = []; - readonly spawnRequests: SpawnProviderCliInstallProcessArgs[] = []; - - spawn(args: SpawnProviderCliInstallProcessArgs): ProviderCliInstallProcess { - this.spawnRequests.push(args); - const process = new FakeProviderCliInstallProcess(); - this.processes.push(process); - return process; - } - - lastProcess(): FakeProviderCliInstallProcess { - const process = this.processes.at(-1); - if (!process) { - throw new Error("Expected an install process to be spawned"); - } - return process; - } -} - -const CODEX_DEFINITION: ProviderCliDefinition = { - key: "codex", - displayName: "Codex", - executableName: "codex", - npmPackageName: "@openai/codex", - minimumSupportedVersion: CODEX_MINIMUM_SUPPORTED_VERSION, - installCommand: { - kind: "npmGlobal", - }, - updateCommand: { - commandKind: "exec", - displayCommand: "codex update", - command: "codex", - args: ["update"], - }, -}; - -const CLAUDE_CODE_DEFINITION: ProviderCliDefinition = { - key: "claudeCode", - displayName: "Claude Code", - executableName: "claude", - npmPackageName: "@anthropic-ai/claude-code", - minimumSupportedVersion: null, - installCommand: { - kind: "downloadedShellScript", - scriptUrl: CLAUDE_INSTALL_SCRIPT_URL, - }, - updateCommand: { - commandKind: "exec", - displayCommand: "claude update", - command: "claude", - args: ["update"], - }, -}; - -const CURSOR_DEFINITION: ProviderCliDefinition = { - key: "cursor", - displayName: "Cursor", - executableName: "cursor-agent", - npmPackageName: null, - minimumSupportedVersion: null, - installCommand: { - kind: "downloadedShellScript", - scriptUrl: CURSOR_INSTALL_SCRIPT_URL, - }, - updateCommand: { - commandKind: "exec", - displayCommand: "cursor-agent update", - command: "cursor-agent", - args: ["update"], - }, -}; - -function installNpmStateCommands( - runner: FakeProviderCliCommandRunner, - definition: ProviderCliDefinition, - prefix: string, - packageVersion: string | null, -): void { - const packageName = definition.npmPackageName; - if (packageName === null) { - throw new Error(`${definition.displayName} has no npm package`); - } - runner.setSuccess("npm", ["prefix", "-g"], `${prefix}\n`); - runner.setSuccess( - "npm", - ["list", "-g", packageName, "--depth=0", "--json"], - packageVersion === null - ? JSON.stringify({ dependencies: {} }) - : JSON.stringify({ - dependencies: { - [packageName]: { version: packageVersion }, - }, - }), - ); -} - -function installMissingCodexCommands( - runner: FakeProviderCliCommandRunner, -): void { - runner.setExit("which", ["codex"], 1, "codex not found"); - runner.setSpawnError("codex", ["--version"], "spawn codex ENOENT"); - runner.setSuccess("npm", ["view", "@openai/codex", "version"], "0.133.0\n"); - installNpmStateCommands(runner, CODEX_DEFINITION, "/usr/local", null); -} - -function installMissingClaudeCommands( - runner: FakeProviderCliCommandRunner, -): void { - runner.setExit("which", ["claude"], 1, "claude not found"); - runner.setSpawnError("claude", ["--version"], "spawn claude ENOENT"); - runner.setSuccess( - "npm", - ["view", "@anthropic-ai/claude-code", "dist-tags", "--json"], - JSON.stringify({ latest: "2.1.148", stable: "2.1.140" }), - ); - installNpmStateCommands(runner, CLAUDE_CODE_DEFINITION, "/usr/local", null); - runner.setSpawnError("claude", ["doctor"], "spawn claude ENOENT"); -} - -function installMissingCursorCommands( - runner: FakeProviderCliCommandRunner, -): void { - runner.setExit("which", ["cursor-agent"], 1, "cursor-agent not found"); - runner.setSpawnError( - "cursor-agent", - ["--version"], - "spawn cursor-agent ENOENT", - ); - runner.setSuccess("npm", ["prefix", "-g"], "/usr/local\n"); -} - -function installOutdatedNpmCodexCommands( - runner: FakeProviderCliCommandRunner, -): void { - runner.setSuccess("which", ["codex"], "/usr/local/bin/codex\n"); - runner.setSuccess("codex", ["--version"], "codex 0.132.0\n"); - runner.setSuccess("npm", ["view", "@openai/codex", "version"], "0.133.0\n"); - installNpmStateCommands(runner, CODEX_DEFINITION, "/usr/local", "0.132.0"); -} - -function installUnsupportedCodexWithoutLatestCommands( - runner: FakeProviderCliCommandRunner, -): void { - runner.setSuccess("which", ["codex"], "/usr/local/bin/codex\n"); - runner.setSuccess("codex", ["--version"], "codex 0.135.0\n"); - runner.setExit("npm", ["view", "@openai/codex", "version"], 1, "offline"); - installNpmStateCommands(runner, CODEX_DEFINITION, "/usr/local", "0.135.0"); -} - -function installOutdatedExternalClaudeCommands( - runner: FakeProviderCliCommandRunner, -): void { - runner.setSuccess("which", ["claude"], "/opt/homebrew/bin/claude\n"); - runner.setSuccess("claude", ["--version"], "2.1.147 (Claude Code)\n"); - runner.setSuccess( - "npm", - ["view", "@anthropic-ai/claude-code", "dist-tags", "--json"], - JSON.stringify({ latest: "2.1.148", stable: "2.1.140" }), - ); - installNpmStateCommands( - runner, - CLAUDE_CODE_DEFINITION, - "/Users/me/.npm-global", - "2.1.147", - ); - runner.setSuccess( - "claude", - ["doctor"], - [ - "Running: npm-global (2.1.147)", - "Path: /opt/homebrew/bin/claude", - "Auto-update channel: latest", - ].join("\n"), - ); -} - -function installOutdatedNativeClaudeCommands( - runner: FakeProviderCliCommandRunner, -): void { - runner.setSuccess("which", ["claude"], "/Users/me/.local/bin/claude\n"); - runner.setSuccess("claude", ["--version"], "2.1.147 (Claude Code)\n"); - runner.setSuccess( - "npm", - ["view", "@anthropic-ai/claude-code", "dist-tags", "--json"], - JSON.stringify({ latest: "2.1.148", stable: "2.1.140" }), - ); - installNpmStateCommands( - runner, - CLAUDE_CODE_DEFINITION, - "/Users/me/.npm-global", - null, - ); - runner.setSuccess( - "claude", - ["doctor"], - [ - "Running: native (2.1.147)", - "Path: /Users/me/.local/share/claude/versions/2.1.147", - "Auto-update channel: latest", - ].join("\n"), - ); -} - -function installCurrentClaudeCommands( - runner: FakeProviderCliCommandRunner, -): void { - runner.setSuccess("which", ["claude"], "/opt/homebrew/bin/claude\n"); - runner.setSuccess("claude", ["--version"], "2.1.148 (Claude Code)\n"); - runner.setSuccess( - "npm", - ["view", "@anthropic-ai/claude-code", "dist-tags", "--json"], - JSON.stringify({ latest: "2.1.148", stable: "2.1.140" }), - ); - installNpmStateCommands( - runner, - CLAUDE_CODE_DEFINITION, - "/opt/homebrew", - "2.1.148", - ); - runner.setSuccess( - "claude", - ["doctor"], - [ - "Running: npm-global (2.1.148)", - "Path: /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", - "Auto-update channel: latest", - ].join("\n"), - ); -} - -function installCurrentCursorCommands( - runner: FakeProviderCliCommandRunner, -): void { - runner.setSuccess( - "which", - ["cursor-agent"], - "/Users/me/.local/bin/cursor-agent\n", - ); - runner.setSuccess("cursor-agent", ["--version"], "cursor-agent 1.2.3\n"); - runner.setSuccess("npm", ["prefix", "-g"], "/usr/local\n"); -} - -async function collectInstallEvents( - stream: ReadableStream, -): Promise { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - const events: ProviderCliInstallEvent[] = []; - let buffer = ""; - - while (true) { - const result = await reader.read(); - if (result.done) { - break; - } - buffer += decoder.decode(result.value, { stream: true }); - const lines = buffer.split(/\r?\n/u); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (line.trim().length > 0) { - events.push(providerCliInstallEventSchema.parse(JSON.parse(line))); - } - } - } - - buffer += decoder.decode(); - if (buffer.trim().length > 0) { - events.push(providerCliInstallEventSchema.parse(JSON.parse(buffer))); - } - return events; -} - -describe("provider CLI health", () => { - it("reports a missing CLI with an npm install action", async () => { - const runner = new FakeProviderCliCommandRunner(); - installMissingCodexCommands(runner); - - const status = await inspectProviderCli({ - definition: CODEX_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status).toEqual({ - displayName: "Codex", - executableName: "codex", - executablePath: null, - installed: false, - installSource: "notInstalled", - currentVersion: null, - latestVersion: "0.133.0", - minimumSupportedVersion: CODEX_MINIMUM_SUPPORTED_VERSION, - npmPackageName: "@openai/codex", - npmGlobalPackageVersion: null, - installAction: { - kind: "install", - label: "Install", - commandKind: "exec", - command: "npm install -g @openai/codex@latest", - }, - needsUpdate: false, - versionUnsupported: false, - }); - }); - - it("reports a missing Claude Code CLI with the downloaded shell installer action", async () => { - const runner = new FakeProviderCliCommandRunner(); - installMissingClaudeCommands(runner); - - const status = await inspectProviderCli({ - definition: CLAUDE_CODE_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.installed).toBe(false); - expect(status.installSource).toBe("notInstalled"); - expect(status.installAction).toEqual({ - kind: "install", - label: "Install", - commandKind: "shell", - command: CLAUDE_INSTALL_COMMAND, - }); - }); - - it("reports a missing Cursor agent CLI with the downloaded shell installer action", async () => { - const runner = new FakeProviderCliCommandRunner(); - installMissingCursorCommands(runner); - - const status = await inspectProviderCli({ - definition: CURSOR_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status).toEqual({ - displayName: "Cursor", - executableName: "cursor-agent", - executablePath: null, - installed: false, - installSource: "notInstalled", - currentVersion: null, - latestVersion: null, - minimumSupportedVersion: null, - npmPackageName: null, - npmGlobalPackageVersion: null, - installAction: { - kind: "install", - label: "Install", - commandKind: "shell", - command: CURSOR_INSTALL_COMMAND, - }, - needsUpdate: false, - versionUnsupported: false, - }); - }); - - it("offers a self-update action when the active executable is npm-global", async () => { - const runner = new FakeProviderCliCommandRunner(); - installOutdatedNpmCodexCommands(runner); - - const status = await inspectProviderCli({ - definition: CODEX_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.installed).toBe(true); - expect(status.installSource).toBe("npmGlobal"); - expect(status.currentVersion).toBe("0.132.0"); - expect(status.latestVersion).toBe("0.133.0"); - expect(status.needsUpdate).toBe(true); - expect(status.versionUnsupported).toBe(true); - expect(status.minimumSupportedVersion).toBe( - CODEX_MINIMUM_SUPPORTED_VERSION, - ); - expect(status.installAction).toEqual({ - kind: "update", - label: "Update", - commandKind: "exec", - command: "codex update", - }); - }); - - it("offers a self-update action when Codex is below the minimum version", async () => { - const runner = new FakeProviderCliCommandRunner(); - installUnsupportedCodexWithoutLatestCommands(runner); - - const status = await inspectProviderCli({ - definition: CODEX_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.currentVersion).toBe("0.135.0"); - expect(status.latestVersion).toBeNull(); - expect(status.needsUpdate).toBe(false); - expect(status.versionUnsupported).toBe(true); - expect(status.installAction).toEqual({ - kind: "update", - label: "Update", - commandKind: "exec", - command: "codex update", - }); - }); - - it("does not offer to update an npm install from a different global prefix", async () => { - const runner = new FakeProviderCliCommandRunner(); - installOutdatedExternalClaudeCommands(runner); - - const status = await inspectProviderCli({ - definition: CLAUDE_CODE_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.installed).toBe(true); - expect(status.installSource).toBe("external"); - expect(status.needsUpdate).toBe(true); - expect(status.installAction).toBeNull(); - }); - - it("offers a self-update action for a native Claude Code install", async () => { - const runner = new FakeProviderCliCommandRunner(); - installOutdatedNativeClaudeCommands(runner); - - const status = await inspectProviderCli({ - definition: CLAUDE_CODE_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.installed).toBe(true); - expect(status.installSource).toBe("external"); - expect(status.needsUpdate).toBe(true); - expect(status.installAction).toEqual({ - kind: "update", - label: "Update", - commandKind: "exec", - command: "claude update", - }); - }); - - it("keeps the native update action when an older Claude doctor requires a TTY", async () => { - const runner = new FakeProviderCliCommandRunner(); - runner.setSuccess("which", ["claude"], "/Users/me/.local/bin/claude\n"); - runner.setSuccess("claude", ["--version"], "2.1.69 (Claude Code)\n"); - runner.setSuccess( - "npm", - ["view", "@anthropic-ai/claude-code", "dist-tags", "--json"], - JSON.stringify({ latest: "2.1.228", stable: "2.1.221" }), - ); - installNpmStateCommands( - runner, - CLAUDE_CODE_DEFINITION, - "/Users/me/.npm-global", - null, - ); - runner.setExit( - "claude", - ["doctor"], - 1, - "Raw mode is not supported on the current process.stdin", - ); - - const status = await inspectProviderCli({ - definition: CLAUDE_CODE_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.installSource).toBe("external"); - expect(status.currentVersion).toBe("2.1.69"); - expect(status.latestVersion).toBeNull(); - expect(status.needsUpdate).toBe(true); - expect(status.installAction).toEqual({ - kind: "update", - label: "Update", - commandKind: "exec", - command: "claude update", - }); - }); - - it("does not infer an arbitrary external Claude install is native when doctor fails", async () => { - const runner = new FakeProviderCliCommandRunner(); - runner.setSuccess("which", ["claude"], "/opt/homebrew/bin/claude\n"); - runner.setSuccess("claude", ["--version"], "2.1.69 (Claude Code)\n"); - runner.setSuccess( - "npm", - ["view", "@anthropic-ai/claude-code", "dist-tags", "--json"], - JSON.stringify({ latest: "2.1.228", stable: "2.1.221" }), - ); - installNpmStateCommands( - runner, - CLAUDE_CODE_DEFINITION, - "/Users/me/.npm-global", - null, - ); - runner.setExit("claude", ["doctor"], 1, "doctor failed"); - - const status = await inspectProviderCli({ - definition: CLAUDE_CODE_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.needsUpdate).toBe(true); - expect(status.installAction).toBeNull(); - }); - - it("does not default an unknown Claude release channel to latest", async () => { - const runner = new FakeProviderCliCommandRunner(); - runner.setSuccess( - "which", - ["claude"], - "/Users/me/.npm-global/bin/claude\n", - ); - runner.setSuccess("claude", ["--version"], "2.1.221 (Claude Code)\n"); - runner.setSuccess( - "npm", - ["view", "@anthropic-ai/claude-code", "dist-tags", "--json"], - JSON.stringify({ latest: "2.1.228", stable: "2.1.221" }), - ); - installNpmStateCommands( - runner, - CLAUDE_CODE_DEFINITION, - "/Users/me/.npm-global", - "2.1.221", - ); - runner.setExit("claude", ["doctor"], 1, "doctor failed"); - - const status = await inspectProviderCli({ - definition: CLAUDE_CODE_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.installSource).toBe("npmGlobal"); - expect(status.latestVersion).toBeNull(); - expect(status.needsUpdate).toBe(false); - expect(status.installAction).toBeNull(); - }); - - it("uses Claude Code's stable release channel when checking for updates", async () => { - const runner = new FakeProviderCliCommandRunner(); - runner.setSuccess("which", ["claude"], "/Users/me/.local/bin/claude\n"); - runner.setSuccess("claude", ["--version"], "2.1.220 (Claude Code)\n"); - runner.setSuccess( - "npm", - ["view", "@anthropic-ai/claude-code", "dist-tags", "--json"], - JSON.stringify({ latest: "2.1.227", stable: "2.1.220" }), - ); - installNpmStateCommands( - runner, - CLAUDE_CODE_DEFINITION, - "/Users/me/.npm-global", - null, - ); - runner.setSuccess( - "claude", - ["doctor"], - ["Running: native (2.1.220)", "Auto-update channel: stable"].join("\n"), - ); - - const status = await inspectProviderCli({ - definition: CLAUDE_CODE_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.currentVersion).toBe("2.1.220"); - expect(status.latestVersion).toBe("2.1.220"); - expect(status.needsUpdate).toBe(false); - expect(status.installAction).toBeNull(); - }); - - it("does not report an update when the CLI version matches npm latest", async () => { - const runner = new FakeProviderCliCommandRunner(); - installCurrentClaudeCommands(runner); - - const status = await inspectProviderCli({ - definition: CLAUDE_CODE_DEFINITION, - runner, - nodePlatform: "darwin", - }); - - expect(status.installed).toBe(true); - expect(status.installSource).toBe("npmGlobal"); - expect(status.currentVersion).toBe("2.1.148"); - expect(status.latestVersion).toBe("2.1.148"); - expect(status.needsUpdate).toBe(false); - expect(status.installAction).toBeNull(); - }); - - it("returns all provider keys and queries the confirmed npm packages", async () => { - const runner = new FakeProviderCliCommandRunner(); - installOutdatedNpmCodexCommands(runner); - installCurrentClaudeCommands(runner); - installCurrentCursorCommands(runner); - - const status = await getProviderCliStatus({ - runner, - nodePlatform: "darwin", - }); - - expect(status.codex.needsUpdate).toBe(true); - expect(status.claudeCode.needsUpdate).toBe(false); - expect(status.cursor.installed).toBe(true); - expect(runner.commandLines()).toContain("npm view @openai/codex version"); - expect(runner.commandLines()).toContain( - "npm view @anthropic-ai/claude-code dist-tags --json", - ); - expect(runner.commandLines()).toContain("which cursor-agent"); - expect(runner.commandLines()).not.toContain("npm view cursor version"); - }); - - it("reports known ACP agent executables from PATH without version checks", async () => { - const runner = new FakeProviderCliCommandRunner(); - runner.setSuccess("which", ["opencode"], "/opt/homebrew/bin/opencode\n"); - runner.setExit("which", ["missing-acp"], 1, "missing-acp not found"); - - const status = await getKnownAcpAgentsStatus({ - runner, - agents: [ - { id: "acp-opencode", executableName: "opencode" }, - { id: "acp-missing", executableName: "missing-acp" }, - ], - }); - - expect(status).toEqual({ - agents: [ - { - id: "acp-opencode", - executableName: "opencode", - installed: true, - executablePath: "/opt/homebrew/bin/opencode", - }, - { - id: "acp-missing", - executableName: "missing-acp", - installed: false, - executablePath: null, - }, - ], - }); - expect(runner.commandLines()).toEqual([ - "which opencode", - "which missing-acp", - ]); - }); - - it("checks Cursor installation using its namespaced executable", async () => { - const runner = new FakeProviderCliCommandRunner(); - runner.setExit("which", ["cursor-agent"], 1, "cursor-agent not found"); - - await expect(isProviderCliInstalled("cursor", { runner })).resolves.toBe( - false, - ); - expect(runner.commandLines()).toEqual(["which cursor-agent"]); - }); - - it("streams failed npm installs without hiding the exit status", async () => { - const spawner = new FakeProviderCliInstallProcessSpawner(); - const stream = streamProviderCliInstall({ - provider: "codex", - actionKind: "install", - nodePlatform: "darwin", - installProcessSpawner: spawner, - }); - const eventsPromise = collectInstallEvents(stream); - - spawner.lastProcess().stderr.write("permission denied\n"); - spawner.lastProcess().emitClose(1, null); - - await expect(eventsPromise).resolves.toEqual([ - { - type: "started", - provider: "codex", - command: "npm install -g @openai/codex@latest", - }, - { - type: "output", - provider: "codex", - stream: "stderr", - text: "permission denied\n", - }, - { - type: "completed", - provider: "codex", - exitCode: 1, - signal: null, - success: false, - }, - ]); - expect(spawner.spawnRequests).toEqual([ - { - command: "npm", - args: ["install", "-g", "@openai/codex@latest"], - }, - ]); - }); - - it("streams Claude Code installs from a downloaded script file", async () => { - const spawner = new FakeProviderCliInstallProcessSpawner(); - const stream = streamProviderCliInstall({ - provider: "claudeCode", - actionKind: "install", - nodePlatform: "darwin", - installProcessSpawner: spawner, - }); - const eventsPromise = collectInstallEvents(stream); - - spawner.lastProcess().stdout.write("installing claude\n"); - spawner.lastProcess().emitClose(0, null); - - await expect(eventsPromise).resolves.toEqual([ - { - type: "started", - provider: "claudeCode", - command: CLAUDE_INSTALL_COMMAND, - }, - { - type: "output", - provider: "claudeCode", - stream: "stdout", - text: "installing claude\n", - }, - { - type: "completed", - provider: "claudeCode", - exitCode: 0, - signal: null, - success: true, - }, - ]); - expect(spawner.spawnRequests).toEqual([ - { - command: "sh", - args: ["-c", CLAUDE_INSTALL_COMMAND], - }, - ]); - expect(CLAUDE_INSTALL_COMMAND).not.toContain("| bash"); - expect(CLAUDE_INSTALL_COMMAND).toContain('bash "$tmp"'); - }); - - it("streams Cursor installs from a downloaded script file", async () => { - const spawner = new FakeProviderCliInstallProcessSpawner(); - const stream = streamProviderCliInstall({ - provider: "cursor", - actionKind: "install", - nodePlatform: "darwin", - installProcessSpawner: spawner, - }); - const eventsPromise = collectInstallEvents(stream); - - spawner.lastProcess().stdout.write("installing cursor\n"); - spawner.lastProcess().emitClose(0, null); - - await expect(eventsPromise).resolves.toEqual([ - { - type: "started", - provider: "cursor", - command: CURSOR_INSTALL_COMMAND, - }, - { - type: "output", - provider: "cursor", - stream: "stdout", - text: "installing cursor\n", - }, - { - type: "completed", - provider: "cursor", - exitCode: 0, - signal: null, - success: true, - }, - ]); - expect(spawner.spawnRequests).toEqual([ - { - command: "sh", - args: ["-c", CURSOR_INSTALL_COMMAND], - }, - ]); - expect(CURSOR_INSTALL_COMMAND).not.toContain("| bash"); - expect(CURSOR_INSTALL_COMMAND).toContain('bash "$tmp"'); - }); - - it("streams provider self-updates with the visible update command", async () => { - const spawner = new FakeProviderCliInstallProcessSpawner(); - const stream = streamProviderCliInstall({ - provider: "claudeCode", - actionKind: "update", - nodePlatform: "darwin", - installProcessSpawner: spawner, - }); - const eventsPromise = collectInstallEvents(stream); - - spawner.lastProcess().emitClose(0, null); - - await expect(eventsPromise).resolves.toEqual([ - { - type: "started", - provider: "claudeCode", - command: "claude update", - }, - { - type: "completed", - provider: "claudeCode", - exitCode: 0, - signal: null, - success: true, - }, - ]); - expect(spawner.spawnRequests).toEqual([ - { - command: "claude", - args: ["update"], - }, - ]); - }); - - it("does not enqueue completion after stream cancellation", async () => { - const spawner = new FakeProviderCliInstallProcessSpawner(); - const stream = streamProviderCliInstall({ - provider: "codex", - actionKind: "update", - nodePlatform: "darwin", - installProcessSpawner: spawner, - }); - const reader = stream.getReader(); - - await expect(reader.read()).resolves.toMatchObject({ - done: false, - }); - await reader.cancel(); - - const process = spawner.lastProcess(); - expect(process.killSignals).toEqual(["SIGTERM"]); - expect(() => process.emitClose(0, null)).not.toThrow(); - }); - - it("rejects duplicate provider CLI installs until the active stream ends", async () => { - const spawner = new FakeProviderCliInstallProcessSpawner(); - const firstStream = streamProviderCliInstall({ - provider: "codex", - actionKind: "install", - nodePlatform: "darwin", - installProcessSpawner: spawner, - }); - - expect(() => - streamProviderCliInstall({ - provider: "claudeCode", - actionKind: "update", - nodePlatform: "darwin", - installProcessSpawner: spawner, - }), - ).toThrow(ProviderCliInstallInProgressError); - - await firstStream.cancel(); - const secondStream = streamProviderCliInstall({ - provider: "claudeCode", - actionKind: "update", - nodePlatform: "darwin", - installProcessSpawner: spawner, - }); - await secondStream.cancel(); - }); -}); diff --git a/apps/host-daemon/src/provider-cli-health.ts b/apps/host-daemon/src/provider-cli-health.ts deleted file mode 100644 index 2962e0c137..0000000000 --- a/apps/host-daemon/src/provider-cli-health.ts +++ /dev/null @@ -1,1301 +0,0 @@ -import { isAbsolute, join, relative, resolve } from "node:path"; -import { PassThrough, type Readable } from "node:stream"; -import { spawnPortableOutputProcess } from "@bb/process-utils"; -import { spawn as spawnPty } from "node-pty"; -import semver from "semver"; -import { z } from "zod"; -import { - providerCliInstallEventSchema, - type ProviderCliInstallAction, - type ProviderCliInstallActionKind, - type ProviderCliInstallEvent, - type ProviderCliInstallSource, - type ProviderCliKey, - type ProviderCliStatus, - type ProviderCliStatusResponse, -} from "@bb/host-daemon-contract"; -import type { HostDaemonLogger } from "./logger.js"; -import { ensureNodePtySpawnHelperExecutable } from "./terminals/terminal-manager.js"; - -const COMMAND_CHECK_TIMEOUT_MS = 5_000; -const CLAUDE_DOCTOR_TIMEOUT_MS = 10_000; -const NPM_VIEW_TIMEOUT_MS = 15_000; -const NPM_INSTALL_STATE_TIMEOUT_MS = 5_000; -const CLAUDE_CODE_INSTALL_SCRIPT_URL = "https://claude.ai/install.sh"; -const CURSOR_INSTALL_SCRIPT_URL = "https://cursor.com/install"; -export const CODEX_MINIMUM_SUPPORTED_VERSION = "0.136.0"; -const providerCliNodePtyLogger: HostDaemonLogger = { - debug() {}, - info() {}, - warn() {}, - error() {}, -}; - -const npmGlobalListDependencySchema = z - .object({ - version: z.string().min(1), - }) - .passthrough(); - -const npmGlobalListResponseSchema = z - .object({ - dependencies: z - .record(z.string(), npmGlobalListDependencySchema) - .default({}), - }) - .passthrough(); - -const npmDistTagsSchema = z - .object({ - latest: z.string().min(1), - stable: z.string().min(1).optional(), - }) - .passthrough(); - -type ClaudeCodeInstallMethod = - | "native" - | "npm-global" - | "package-manager" - | "unknown"; - -interface ClaudeCodeDoctorStatus { - installMethod: ClaudeCodeInstallMethod | null; - updateChannel: "latest" | "stable" | null; -} - -interface ClaudeCodeDistTagVersions { - latest: string; - stable: string | null; -} - -export interface ProviderCliDefinition { - key: ProviderCliKey; - displayName: string; - executableName: string; - npmPackageName: string | null; - minimumSupportedVersion: string | null; - installCommand: ProviderCliInstallCommandDefinition; - updateCommand: ProviderCliActionCommand; -} - -export interface ProviderCliCommandResult { - command: string; - args: readonly string[]; - stdout: string; - stderr: string; - exitCode: number | null; - signal: string | null; - errorMessage: string | null; -} - -export interface RunProviderCliCommandArgs { - command: string; - args: readonly string[]; - timeoutMs: number; -} - -export interface ProviderCliCommandRunner { - run(args: RunProviderCliCommandArgs): Promise; -} - -interface InspectProviderCliArgs { - definition: ProviderCliDefinition; - runner: ProviderCliCommandRunner; - nodePlatform: NodeJS.Platform; -} - -interface GetProviderCliStatusArgs { - env?: NodeJS.ProcessEnv; - runner?: ProviderCliCommandRunner; - nodePlatform?: NodeJS.Platform; -} - -interface GetProviderCliStatusForProviderArgs { - env?: NodeJS.ProcessEnv; - runner?: ProviderCliCommandRunner; - nodePlatform?: NodeJS.Platform; -} - -interface IsProviderCliInstalledArgs { - env?: NodeJS.ProcessEnv; - runner?: ProviderCliCommandRunner; -} - -export interface KnownAcpAgentExecutableQuery { - id: string; - executableName: string; -} - -export interface KnownAcpAgentExecutableStatus { - id: string; - executableName: string; - installed: boolean; - executablePath: string | null; -} - -interface InspectExecutableInstallStatusArgs { - executableName: string; - runner: ProviderCliCommandRunner; -} - -interface GetKnownAcpAgentsStatusArgs { - agents: readonly KnownAcpAgentExecutableQuery[]; - env?: NodeJS.ProcessEnv; - runner?: ProviderCliCommandRunner; -} - -export interface SpawnProviderCliInstallProcessArgs { - command: string; - args: string[]; - env?: NodeJS.ProcessEnv; -} - -export type ProviderCliInstallProcessErrorListener = (error: Error) => void; -export type ProviderCliInstallProcessCloseListener = ( - exitCode: number | null, - signal: NodeJS.Signals | null, -) => void; - -export interface ProviderCliInstallProcess { - stdout: Readable; - stderr: Readable; - kill(signal: NodeJS.Signals): boolean; - onError(listener: ProviderCliInstallProcessErrorListener): void; - onClose(listener: ProviderCliInstallProcessCloseListener): void; -} - -export interface ProviderCliInstallProcessSpawner { - spawn(args: SpawnProviderCliInstallProcessArgs): ProviderCliInstallProcess; -} - -interface StreamProviderCliInstallArgs { - provider: ProviderCliKey; - actionKind: ProviderCliInstallActionKind; - env?: NodeJS.ProcessEnv; - nodePlatform?: NodeJS.Platform; - installProcessSpawner?: ProviderCliInstallProcessSpawner; -} - -interface ProviderCliPtyShellCommand { - command: string; - args: string[]; -} - -interface NeedsProviderCliUpdateArgs { - installed: boolean; - currentVersion: string | null; - latestVersion: string | null; -} - -interface IsProviderCliVersionUnsupportedArgs { - installed: boolean; - currentVersion: string | null; - minimumSupportedVersion: string | null; -} - -interface ResolveProviderCliInstallSourceArgs { - installed: boolean; - executablePath: string | null; - npmGlobalPrefix: string | null; - nodePlatform: NodeJS.Platform; -} - -interface BuildInstallActionArgs { - definition: ProviderCliDefinition; - installed: boolean; - executablePath: string | null; - installSource: ProviderCliInstallSource; - needsUpdate: boolean; - versionUnsupported: boolean; - nodePlatform: NodeJS.Platform; - claudeCodeDoctorStatus: ClaudeCodeDoctorStatus | null; -} - -interface CreateCommandResultArgs { - command: string; - commandArgs: readonly string[]; - stdout: string; - stderr: string; - exitCode: number | null; - signal: string | null; - errorMessage: string | null; -} - -interface ProviderCliActionCommand { - commandKind: "exec" | "shell"; - displayCommand: string; - command: string; - args: readonly string[]; -} - -type ProviderCliInstallCommandDefinition = - | Readonly<{ - kind: "npmGlobal"; - }> - | Readonly<{ - kind: "shell"; - command: string; - }> - | Readonly<{ - kind: "downloadedShellScript"; - scriptUrl: string; - }>; - -interface ResolveProviderCliActionCommandArgs { - definition: ProviderCliDefinition; - actionKind: ProviderCliInstallActionKind; - nodePlatform: NodeJS.Platform; -} - -interface ProviderCliInstallSlot { - provider: ProviderCliKey; - released: boolean; -} - -interface ProviderCliInstallStreamState { - closed: boolean; - childProcess: ProviderCliInstallProcess | null; - installSlot: ProviderCliInstallSlot; -} - -interface WriteInstallEventArgs { - controller: ReadableStreamDefaultController; - encoder: TextEncoder; - state: ProviderCliInstallStreamState; - event: ProviderCliInstallEvent; -} - -interface CloseInstallStreamArgs { - controller: ReadableStreamDefaultController; - state: ProviderCliInstallStreamState; -} - -let activeProviderCliInstallProvider: ProviderCliKey | null = null; - -export class ProviderCliInstallInProgressError extends Error { - readonly provider: ProviderCliKey; - - constructor(provider: ProviderCliKey) { - super(`Provider CLI install already running for ${provider}`); - this.name = "ProviderCliInstallInProgressError"; - this.provider = provider; - } -} - -const PROVIDER_CLI_DEFINITIONS = { - codex: { - key: "codex", - displayName: "Codex", - executableName: "codex", - npmPackageName: "@openai/codex", - minimumSupportedVersion: CODEX_MINIMUM_SUPPORTED_VERSION, - installCommand: { - kind: "npmGlobal", - }, - updateCommand: { - commandKind: "exec", - displayCommand: "codex update", - command: "codex", - args: ["update"], - }, - }, - claudeCode: { - key: "claudeCode", - displayName: "Claude Code", - executableName: "claude", - npmPackageName: "@anthropic-ai/claude-code", - minimumSupportedVersion: null, - installCommand: { - kind: "downloadedShellScript", - scriptUrl: CLAUDE_CODE_INSTALL_SCRIPT_URL, - }, - updateCommand: { - commandKind: "exec", - displayCommand: "claude update", - command: "claude", - args: ["update"], - }, - }, - cursor: { - key: "cursor", - displayName: "Cursor", - executableName: "cursor-agent", - npmPackageName: null, - minimumSupportedVersion: null, - installCommand: { - kind: "downloadedShellScript", - scriptUrl: CURSOR_INSTALL_SCRIPT_URL, - }, - updateCommand: { - commandKind: "exec", - displayCommand: "cursor-agent update", - command: "cursor-agent", - args: ["update"], - }, - }, -} satisfies Record; - -function getProviderCliDefinition( - provider: ProviderCliKey, -): ProviderCliDefinition { - return PROVIDER_CLI_DEFINITIONS[provider]; -} - -function npmExecutableName(nodePlatform: NodeJS.Platform): string { - return nodePlatform === "win32" ? "npm.cmd" : "npm"; -} - -function formatCommand(command: string, args: readonly string[]): string { - const parts = [command, ...args]; - return parts - .map((part) => - /^[A-Za-z0-9_./:@+-]+$/u.test(part) - ? part - : `'${part.replace(/'/gu, "'\\''")}'`, - ) - .join(" "); -} - -function isSuccessfulCommand(result: ProviderCliCommandResult): boolean { - return result.errorMessage === null && result.exitCode === 0; -} - -function firstOutputLine(text: string): string | null { - const line = text - .split(/\r?\n/u) - .map((candidate) => candidate.trim()) - .find((candidate) => candidate.length > 0); - return line ?? null; -} - -function extractVersion(text: string): string | null { - const match = - /\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/u.exec( - text, - ); - const candidate = match?.[1]; - if (!candidate) { - return null; - } - return semver.valid(candidate); -} - -function parseNpmGlobalPackageVersion( - text: string, - npmPackageName: string, -): string | null { - const trimmedText = text.trim(); - if (trimmedText.length === 0) { - return null; - } - - try { - const parsed = npmGlobalListResponseSchema.safeParse( - JSON.parse(trimmedText), - ); - if (!parsed.success) { - return null; - } - return parsed.data.dependencies[npmPackageName]?.version ?? null; - } catch { - return null; - } -} - -function parseClaudeCodeDistTagVersions( - text: string, -): ClaudeCodeDistTagVersions | null { - const trimmedText = text.trim(); - if (trimmedText.length === 0) { - return null; - } - - try { - const parsed = npmDistTagsSchema.safeParse(JSON.parse(trimmedText)); - if (!parsed.success) { - return null; - } - const latest = extractVersion(parsed.data.latest); - if (latest === null) { - return null; - } - return { - latest, - stable: - parsed.data.stable === undefined - ? latest - : extractVersion(parsed.data.stable), - }; - } catch { - return null; - } -} - -function parseClaudeCodeDoctorStatus(text: string): ClaudeCodeDoctorStatus { - const runningMatch = /^Running:\s+([^\s(]+)/mu.exec(text); - const channelMatch = /^Auto-update channel:\s+(latest|stable)\s*$/mu.exec( - text, - ); - const rawInstallMethod = runningMatch?.[1]; - const rawUpdateChannel = channelMatch?.[1]; - const installMethod: ClaudeCodeInstallMethod | null = rawInstallMethod - ? rawInstallMethod === "native" || rawInstallMethod === "npm-global" - ? rawInstallMethod - : ["homebrew", "winget", "apt", "dnf", "apk"].includes(rawInstallMethod) - ? "package-manager" - : "unknown" - : null; - return { - installMethod, - updateChannel: - rawUpdateChannel === "latest" || rawUpdateChannel === "stable" - ? rawUpdateChannel - : null, - }; -} - -function resolveClaudeCodeVersionStatus(args: { - installed: boolean; - currentVersion: string | null; - distTags: ClaudeCodeDistTagVersions | null; - updateChannel: ClaudeCodeDoctorStatus["updateChannel"]; -}): { latestVersion: string | null; needsUpdate: boolean } { - const latestVersion = - args.updateChannel === null || args.distTags === null - ? null - : args.distTags[args.updateChannel]; - if (args.updateChannel !== null) { - return { - latestVersion, - needsUpdate: needsProviderCliUpdate({ - installed: args.installed, - currentVersion: args.currentVersion, - latestVersion, - }), - }; - } - - // Without an effective channel, an update is only certain when both possible - // targets are newer. Keep the target version unknown so a stable install is - // never advertised or verified against the latest release by accident. - const definitelyNeedsUpdate = - args.installed && - args.currentVersion !== null && - args.distTags !== null && - args.distTags.stable !== null && - semver.gt(args.distTags.latest, args.currentVersion) && - semver.gt(args.distTags.stable, args.currentVersion); - return { latestVersion: null, needsUpdate: definitelyNeedsUpdate }; -} - -function needsProviderCliUpdate(args: NeedsProviderCliUpdateArgs): boolean { - if (!args.installed || !args.currentVersion || !args.latestVersion) { - return false; - } - return semver.gt(args.latestVersion, args.currentVersion); -} - -function isProviderCliVersionUnsupported({ - installed, - currentVersion, - minimumSupportedVersion, -}: IsProviderCliVersionUnsupportedArgs): boolean { - if (!installed || !currentVersion || !minimumSupportedVersion) { - return false; - } - return semver.lt(currentVersion, minimumSupportedVersion); -} - -function npmInstallCommandArgs(definition: ProviderCliDefinition): string[] { - if (definition.npmPackageName === null) { - throw new Error( - `${definition.displayName} CLI does not define an npm package installer.`, - ); - } - return ["install", "-g", `${definition.npmPackageName}@latest`]; -} - -function npmInstallActionCommand( - definition: ProviderCliDefinition, - nodePlatform: NodeJS.Platform, -): ProviderCliActionCommand { - const command = npmExecutableName(nodePlatform); - const args = npmInstallCommandArgs(definition); - return { - commandKind: "exec", - displayCommand: formatCommand(command, args), - command, - args, - }; -} - -function shellInstallActionCommand(command: string): ProviderCliActionCommand { - return { - commandKind: "shell", - displayCommand: command, - command: "sh", - args: ["-c", command], - }; -} - -function downloadedShellScriptInstallActionCommand( - scriptUrl: string, -): ProviderCliActionCommand { - const command = [ - 'tmp=$(mktemp "${TMPDIR:-/tmp}/provider-cli-install.XXXXXX")', - "trap 'rm -f \"$tmp\"' EXIT", - `curl -fsSL ${formatCommand(scriptUrl, [])} -o "$tmp"`, - 'bash "$tmp"', - ].join(" && "); - return shellInstallActionCommand(command); -} - -function installActionCommand( - definition: ProviderCliDefinition, - nodePlatform: NodeJS.Platform, -): ProviderCliActionCommand { - switch (definition.installCommand.kind) { - case "npmGlobal": - return npmInstallActionCommand(definition, nodePlatform); - case "shell": - return shellInstallActionCommand(definition.installCommand.command); - case "downloadedShellScript": - return downloadedShellScriptInstallActionCommand( - definition.installCommand.scriptUrl, - ); - } -} - -function npmGlobalBinDirectory( - npmGlobalPrefix: string, - nodePlatform: NodeJS.Platform, -): string { - return nodePlatform === "win32" - ? npmGlobalPrefix - : join(npmGlobalPrefix, "bin"); -} - -function isPathInsideDirectory(path: string, directory: string): boolean { - const relativePath = relative(resolve(directory), resolve(path)); - return ( - relativePath === "" || - (!relativePath.startsWith("..") && !isAbsolute(relativePath)) - ); -} - -function resolveProviderCliInstallSource({ - installed, - executablePath, - npmGlobalPrefix, - nodePlatform, -}: ResolveProviderCliInstallSourceArgs): ProviderCliInstallSource { - if (!installed) { - return "notInstalled"; - } - if (!executablePath || !npmGlobalPrefix) { - return "external"; - } - - const npmBinDirectory = npmGlobalBinDirectory(npmGlobalPrefix, nodePlatform); - return isPathInsideDirectory(executablePath, npmBinDirectory) - ? "npmGlobal" - : "external"; -} - -function isDefaultClaudeCodeNativeExecutablePath( - executablePath: string | null, - nodePlatform: NodeJS.Platform, -): boolean { - if (executablePath === null) { - return false; - } - const normalizedPath = executablePath.replace(/\\/gu, "/"); - if (normalizedPath.endsWith("/.local/bin/claude")) { - return true; - } - return ( - nodePlatform === "win32" && - normalizedPath.endsWith("/.local/bin/claude.exe") - ); -} - -function resolveExecutableInstallStatus( - whichResult: ProviderCliCommandResult, - versionResult: ProviderCliCommandResult, -): { - installed: boolean; - executablePath: string | null; -} { - const executablePath = isSuccessfulCommand(whichResult) - ? firstOutputLine(whichResult.stdout) - : null; - return { - executablePath, - installed: executablePath !== null || isSuccessfulCommand(versionResult), - }; -} - -function resolveExecutablePathStatus(whichResult: ProviderCliCommandResult): { - installed: boolean; - executablePath: string | null; -} { - const executablePath = isSuccessfulCommand(whichResult) - ? firstOutputLine(whichResult.stdout) - : null; - return { - executablePath, - installed: executablePath !== null, - }; -} - -function buildInstallAction({ - definition, - installed, - executablePath, - installSource, - needsUpdate, - versionUnsupported, - nodePlatform, - claudeCodeDoctorStatus, -}: BuildInstallActionArgs): ProviderCliInstallAction | null { - if (!installed) { - const command = installActionCommand(definition, nodePlatform); - return { - kind: "install", - label: "Install", - commandKind: command.commandKind, - command: command.displayCommand, - }; - } - const claudeCodeInstallMethod = claudeCodeDoctorStatus?.installMethod ?? null; - const hasNativeClaudeCodeFallback = - definition.key === "claudeCode" && - claudeCodeInstallMethod === null && - installSource === "external" && - isDefaultClaudeCodeNativeExecutablePath(executablePath, nodePlatform); - const canRunUpdate = - definition.key !== "claudeCode" || - claudeCodeInstallMethod === "native" || - hasNativeClaudeCodeFallback || - (installSource === "npmGlobal" && - (claudeCodeInstallMethod === null || - claudeCodeInstallMethod === "npm-global")); - if (needsUpdate && canRunUpdate) { - const command = definition.updateCommand; - return { - kind: "update", - label: "Update", - commandKind: command.commandKind, - command: command.displayCommand, - }; - } - if (versionUnsupported && canRunUpdate) { - const command = definition.updateCommand; - return { - kind: "update", - label: "Update", - commandKind: command.commandKind, - command: command.displayCommand, - }; - } - return null; -} - -function resolveProviderCliActionCommand({ - definition, - actionKind, - nodePlatform, -}: ResolveProviderCliActionCommandArgs): ProviderCliActionCommand { - switch (actionKind) { - case "install": - return installActionCommand(definition, nodePlatform); - case "update": - return definition.updateCommand; - } -} - -function createCommandResult( - args: CreateCommandResultArgs, -): ProviderCliCommandResult { - return { - command: args.command, - args: args.commandArgs, - stdout: args.stdout, - stderr: args.stderr, - exitCode: args.exitCode, - signal: args.signal, - errorMessage: args.errorMessage, - }; -} - -export function createSpawnProviderCliCommandRunner( - env: NodeJS.ProcessEnv = process.env, -): ProviderCliCommandRunner { - return { - run: (args) => runProviderCliCommand(args, env), - }; -} - -export async function runProviderCliCommand( - args: RunProviderCliCommandArgs, - env: NodeJS.ProcessEnv = process.env, -): Promise { - return await new Promise((resolve) => { - let child; - try { - child = spawnPortableOutputProcess({ - command: args.command, - args: [...args.args], - env, - }); - } catch (error) { - resolve( - createCommandResult({ - command: args.command, - commandArgs: args.args, - stdout: "", - stderr: "", - exitCode: null, - signal: null, - errorMessage: error instanceof Error ? error.message : String(error), - }), - ); - return; - } - - let stdout = ""; - let stderr = ""; - let settled = false; - - function settle(result: ProviderCliCommandResult): void { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - resolve(result); - } - - const timeout = setTimeout(() => { - child.kill("SIGTERM"); - }, args.timeoutMs); - - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - - child.on("error", (error) => { - settle( - createCommandResult({ - command: args.command, - commandArgs: args.args, - stdout, - stderr, - exitCode: null, - signal: null, - errorMessage: error.message, - }), - ); - }); - - child.on("close", (exitCode, signal) => { - settle( - createCommandResult({ - command: args.command, - commandArgs: args.args, - stdout, - stderr, - exitCode, - signal, - errorMessage: null, - }), - ); - }); - }); -} - -export async function inspectProviderCli({ - definition, - runner, - nodePlatform, -}: InspectProviderCliArgs): Promise { - const npmCommand = npmExecutableName(nodePlatform); - const npmPackageName = definition.npmPackageName; - const [ - whichResult, - versionResult, - latestResult, - npmPrefixResult, - npmListResult, - claudeDoctorResult, - ] = await Promise.all([ - runner.run({ - command: "which", - args: [definition.executableName], - timeoutMs: COMMAND_CHECK_TIMEOUT_MS, - }), - runner.run({ - command: definition.executableName, - args: ["--version"], - timeoutMs: COMMAND_CHECK_TIMEOUT_MS, - }), - npmPackageName === null - ? Promise.resolve(null) - : runner.run({ - command: npmCommand, - args: - definition.key === "claudeCode" - ? ["view", npmPackageName, "dist-tags", "--json"] - : ["view", npmPackageName, "version"], - timeoutMs: NPM_VIEW_TIMEOUT_MS, - }), - runner.run({ - command: npmCommand, - args: ["prefix", "-g"], - timeoutMs: NPM_INSTALL_STATE_TIMEOUT_MS, - }), - npmPackageName === null - ? Promise.resolve(null) - : runner.run({ - command: npmCommand, - args: ["list", "-g", npmPackageName, "--depth=0", "--json"], - timeoutMs: NPM_INSTALL_STATE_TIMEOUT_MS, - }), - definition.key === "claudeCode" - ? runner.run({ - command: definition.executableName, - args: ["doctor"], - timeoutMs: CLAUDE_DOCTOR_TIMEOUT_MS, - }) - : Promise.resolve(null), - ]); - - const { executablePath, installed } = resolveExecutableInstallStatus( - whichResult, - versionResult, - ); - const currentVersion = isSuccessfulCommand(versionResult) - ? extractVersion(`${versionResult.stdout}\n${versionResult.stderr}`) - : null; - const claudeCodeDoctorStatus = - claudeDoctorResult !== null - ? parseClaudeCodeDoctorStatus( - `${claudeDoctorResult.stdout}\n${claudeDoctorResult.stderr}`, - ) - : null; - const claudeCodeDistTags = - definition.key === "claudeCode" && - latestResult !== null && - isSuccessfulCommand(latestResult) - ? parseClaudeCodeDistTagVersions( - `${latestResult.stdout}\n${latestResult.stderr}`, - ) - : null; - const claudeCodeVersionStatus = - definition.key === "claudeCode" - ? resolveClaudeCodeVersionStatus({ - installed, - currentVersion, - distTags: claudeCodeDistTags, - updateChannel: claudeCodeDoctorStatus?.updateChannel ?? null, - }) - : null; - const latestVersion = - claudeCodeVersionStatus === null - ? latestResult !== null && isSuccessfulCommand(latestResult) - ? extractVersion(`${latestResult.stdout}\n${latestResult.stderr}`) - : null - : claudeCodeVersionStatus.latestVersion; - const npmGlobalPrefix = isSuccessfulCommand(npmPrefixResult) - ? firstOutputLine(npmPrefixResult.stdout) - : null; - const npmGlobalPackageVersion = - npmListResult !== null && npmPackageName !== null - ? parseNpmGlobalPackageVersion( - `${npmListResult.stdout}\n${npmListResult.stderr}`, - npmPackageName, - ) - : null; - const installSource = resolveProviderCliInstallSource({ - installed, - executablePath, - npmGlobalPrefix, - nodePlatform, - }); - const needsUpdate = - claudeCodeVersionStatus?.needsUpdate ?? - needsProviderCliUpdate({ installed, currentVersion, latestVersion }); - const versionUnsupported = isProviderCliVersionUnsupported({ - installed, - currentVersion, - minimumSupportedVersion: definition.minimumSupportedVersion, - }); - const installAction = buildInstallAction({ - definition, - installed, - executablePath, - installSource, - needsUpdate, - versionUnsupported, - nodePlatform, - claudeCodeDoctorStatus, - }); - - return { - displayName: definition.displayName, - executableName: definition.executableName, - executablePath, - installed, - installSource, - currentVersion, - latestVersion, - minimumSupportedVersion: definition.minimumSupportedVersion, - npmPackageName, - npmGlobalPackageVersion, - installAction, - needsUpdate, - versionUnsupported, - }; -} - -export async function getProviderCliStatus( - args: GetProviderCliStatusArgs = {}, -): Promise { - const runner = args.runner ?? createSpawnProviderCliCommandRunner(args.env); - const nodePlatform = args.nodePlatform ?? process.platform; - const [codex, claudeCode, cursor] = await Promise.all([ - inspectProviderCli({ - definition: getProviderCliDefinition("codex"), - runner, - nodePlatform, - }), - inspectProviderCli({ - definition: getProviderCliDefinition("claudeCode"), - runner, - nodePlatform, - }), - inspectProviderCli({ - definition: getProviderCliDefinition("cursor"), - runner, - nodePlatform, - }), - ]); - - return { codex, claudeCode, cursor }; -} - -export async function getProviderCliStatusForProvider( - provider: ProviderCliKey, - args: GetProviderCliStatusForProviderArgs = {}, -): Promise { - const runner = args.runner ?? createSpawnProviderCliCommandRunner(args.env); - const nodePlatform = args.nodePlatform ?? process.platform; - return await inspectProviderCli({ - definition: getProviderCliDefinition(provider), - runner, - nodePlatform, - }); -} - -export async function isProviderCliInstalled( - provider: ProviderCliKey, - args: IsProviderCliInstalledArgs = {}, -): Promise { - const runner = args.runner ?? createSpawnProviderCliCommandRunner(args.env); - const status = await inspectExecutableInstallStatus({ - executableName: getProviderCliDefinition(provider).executableName, - runner, - }); - return status.installed; -} - -export async function inspectExecutableInstallStatus({ - executableName, - runner, -}: InspectExecutableInstallStatusArgs): Promise<{ - installed: boolean; - executablePath: string | null; -}> { - const whichResult = await runner.run({ - command: "which", - args: [executableName], - timeoutMs: COMMAND_CHECK_TIMEOUT_MS, - }); - - return resolveExecutablePathStatus(whichResult); -} - -export async function getKnownAcpAgentsStatus({ - agents, - env, - runner = createSpawnProviderCliCommandRunner(env), -}: GetKnownAcpAgentsStatusArgs): Promise<{ - agents: KnownAcpAgentExecutableStatus[]; -}> { - return { - agents: await Promise.all( - agents.map(async (agent) => { - const status = await inspectExecutableInstallStatus({ - executableName: agent.executableName, - runner, - }); - return { - id: agent.id, - executableName: agent.executableName, - installed: status.installed, - executablePath: status.executablePath, - }; - }), - ), - }; -} - -function providerCliPtyShellCommand( - args: SpawnProviderCliInstallProcessArgs, -): ProviderCliPtyShellCommand { - const commandLine = formatCommand(args.command, args.args); - if (process.platform === "win32") { - return { - command: process.env.ComSpec ?? "cmd.exe", - args: ["/d", "/s", "/c", commandLine], - }; - } - return { - command: "/bin/sh", - args: ["-c", commandLine], - }; -} - -export function createPtyProviderCliInstallProcessSpawner(): ProviderCliInstallProcessSpawner { - return { - spawn(args) { - const ptyCommand = providerCliPtyShellCommand(args); - const stdout = new PassThrough(); - const stderr = new PassThrough(); - ensureNodePtySpawnHelperExecutable(providerCliNodePtyLogger); - const pty = spawnPty(ptyCommand.command, ptyCommand.args, { - cols: 120, - cwd: process.cwd(), - env: args.env ?? process.env, - name: "xterm-256color", - rows: 30, - }); - pty.onData((data) => { - stdout.write(data); - }); - pty.onExit(() => { - stdout.end(); - stderr.end(); - }); - return { - stdout, - stderr, - kill(signal) { - pty.kill(signal); - return true; - }, - onError(listener) { - void listener; - }, - onClose(listener) { - pty.onExit((event) => { - listener(event.exitCode, null); - }); - }, - }; - }, - }; -} - -function reserveProviderCliInstall( - provider: ProviderCliKey, -): ProviderCliInstallSlot { - if (activeProviderCliInstallProvider !== null) { - throw new ProviderCliInstallInProgressError( - activeProviderCliInstallProvider, - ); - } - activeProviderCliInstallProvider = provider; - return { - provider, - released: false, - }; -} - -function releaseProviderCliInstall(slot: ProviderCliInstallSlot): void { - if (slot.released) { - return; - } - slot.released = true; - if (activeProviderCliInstallProvider === slot.provider) { - activeProviderCliInstallProvider = null; - } -} - -function writeInstallEvent({ - controller, - encoder, - state, - event, -}: WriteInstallEventArgs): void { - if (state.closed) { - return; - } - - try { - const parsedEvent = providerCliInstallEventSchema.parse(event); - controller.enqueue(encoder.encode(`${JSON.stringify(parsedEvent)}\n`)); - } catch { - state.closed = true; - releaseProviderCliInstall(state.installSlot); - state.childProcess?.kill("SIGTERM"); - } -} - -function closeInstallStream({ - controller, - state, -}: CloseInstallStreamArgs): void { - if (state.closed) { - return; - } - state.closed = true; - releaseProviderCliInstall(state.installSlot); - controller.close(); -} - -export function streamProviderCliInstall({ - provider, - actionKind, - env, - nodePlatform = process.platform, - installProcessSpawner = createPtyProviderCliInstallProcessSpawner(), -}: StreamProviderCliInstallArgs): ReadableStream { - const definition = getProviderCliDefinition(provider); - const actionCommand = resolveProviderCliActionCommand({ - definition, - actionKind, - nodePlatform, - }); - const command = actionCommand.command; - const commandArgs = [...actionCommand.args]; - const displayCommand = actionCommand.displayCommand; - const installSlot = reserveProviderCliInstall(provider); - const state: ProviderCliInstallStreamState = { - closed: false, - childProcess: null, - installSlot, - }; - - return new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - writeInstallEvent({ - controller, - encoder, - state, - event: { - type: "started", - provider, - command: displayCommand, - }, - }); - - try { - state.childProcess = installProcessSpawner.spawn({ - command, - args: commandArgs, - ...(env ? { env } : {}), - }); - } catch (error) { - writeInstallEvent({ - controller, - encoder, - state, - event: { - type: "error", - provider, - message: error instanceof Error ? error.message : String(error), - }, - }); - closeInstallStream({ controller, state }); - return; - } - - const spawned = state.childProcess; - spawned.stdout.setEncoding("utf8"); - spawned.stdout.on("data", (text: string) => { - writeInstallEvent({ - controller, - encoder, - state, - event: { - type: "output", - provider, - stream: "stdout", - text, - }, - }); - }); - - spawned.stderr.setEncoding("utf8"); - spawned.stderr.on("data", (text: string) => { - writeInstallEvent({ - controller, - encoder, - state, - event: { - type: "output", - provider, - stream: "stderr", - text, - }, - }); - }); - - spawned.onError((error) => { - writeInstallEvent({ - controller, - encoder, - state, - event: { - type: "error", - provider, - message: error.message, - }, - }); - closeInstallStream({ controller, state }); - }); - - spawned.onClose((exitCode, signal) => { - if (state.closed) { - return; - } - writeInstallEvent({ - controller, - encoder, - state, - event: { - type: "completed", - provider, - exitCode, - signal, - success: exitCode === 0, - }, - }); - closeInstallStream({ controller, state }); - }); - }, - cancel() { - state.closed = true; - releaseProviderCliInstall(state.installSlot); - state.childProcess?.kill("SIGTERM"); - }, - }); -} diff --git a/apps/host-daemon/src/provider-installation.test.ts b/apps/host-daemon/src/provider-installation.test.ts new file mode 100644 index 0000000000..04d4d8edcb --- /dev/null +++ b/apps/host-daemon/src/provider-installation.test.ts @@ -0,0 +1,117 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { + ProviderInstallationInProgressError, + streamProviderInstallation, + type ProviderInstallationProcess, + type ProviderInstallationProcessSpawner, +} from "./provider-installation.js"; + +function fakeProcess(): Omit< + ProviderInstallationProcess, + "stdout" | "stderr" +> & { + stdout: PassThrough; + stderr: PassThrough; + close(exitCode: number): void; +} { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let closeListener: ((exitCode: number | null, signal: null) => void) | null = + null; + return { + stdout, + stderr, + kill: vi.fn(() => true), + onError: vi.fn(), + onClose(listener) { + closeListener = listener; + }, + close(exitCode) { + stdout.end(); + stderr.end(); + closeListener?.(exitCode, null); + }, + }; +} + +async function readEvents(stream: ReadableStream) { + const text = await new Response(stream).text(); + return text + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +describe("streamProviderInstallation", () => { + it("executes the provider plan and streams process output", async () => { + const process = fakeProcess(); + const spawner: ProviderInstallationProcessSpawner = { + spawn: vi.fn(() => process), + }; + const stream = streamProviderInstallation({ + providerId: "example-provider", + plan: { + command: "example", + args: ["upgrade"], + displayCommand: "example upgrade", + }, + processSpawner: spawner, + }); + process.stdout.write("working\n"); + process.close(0); + + await expect(readEvents(stream)).resolves.toEqual([ + { + type: "started", + provider: "example-provider", + command: "example upgrade", + }, + { + type: "output", + provider: "example-provider", + stream: "stdout", + text: "working\n", + }, + { + type: "completed", + provider: "example-provider", + exitCode: 0, + signal: null, + success: true, + }, + ]); + expect(spawner.spawn).toHaveBeenCalledWith({ + command: "example", + args: ["upgrade"], + }); + }); + + it("allows only one installation process at a time", async () => { + const firstProcess = fakeProcess(); + const first = streamProviderInstallation({ + providerId: "first", + plan: { command: "one", args: [], displayCommand: "one" }, + processSpawner: { spawn: () => firstProcess }, + }); + expect(() => + streamProviderInstallation({ + providerId: "second", + plan: { command: "two", args: [], displayCommand: "two" }, + processSpawner: { spawn: () => fakeProcess() }, + }), + ).toThrow(ProviderInstallationInProgressError); + firstProcess.close(0); + await readEvents(first); + + const secondProcess = fakeProcess(); + const second = streamProviderInstallation({ + providerId: "second", + plan: { command: "two", args: [], displayCommand: "two" }, + processSpawner: { spawn: () => secondProcess }, + }); + secondProcess.close(0); + await readEvents(second); + }); +}); diff --git a/apps/host-daemon/src/provider-installation.ts b/apps/host-daemon/src/provider-installation.ts new file mode 100644 index 0000000000..19377def7e --- /dev/null +++ b/apps/host-daemon/src/provider-installation.ts @@ -0,0 +1,179 @@ +import { PassThrough, type Readable } from "node:stream"; +import type { ExperimentalProviderInstallationCommand } from "@bb/provider-bridge-protocol"; +import { + providerCliInstallEventSchema, + type ProviderCliInstallEvent, +} from "@bb/host-daemon-contract"; +import { spawn as spawnPty } from "node-pty"; +import type { HostDaemonLogger } from "./logger.js"; +import { ensureNodePtySpawnHelperExecutable } from "./terminals/terminal-manager.js"; + +const nodePtyLogger: HostDaemonLogger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +export interface ProviderInstallationProcess { + stdout: Readable; + stderr: Readable; + kill(signal: NodeJS.Signals): boolean; + onError(listener: (error: Error) => void): void; + onClose( + listener: (exitCode: number | null, signal: NodeJS.Signals | null) => void, + ): void; +} + +export interface ProviderInstallationProcessSpawner { + spawn(args: { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; + }): ProviderInstallationProcess; +} + +let activeProviderId: string | null = null; + +export class ProviderInstallationInProgressError extends Error { + readonly providerId: string; + + constructor(providerId: string) { + super(`Provider installation already running for ${providerId}`); + this.name = "ProviderInstallationInProgressError"; + this.providerId = providerId; + } +} + +function createPtyProviderInstallationProcessSpawner(): ProviderInstallationProcessSpawner { + return { + spawn(args) { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + ensureNodePtySpawnHelperExecutable(nodePtyLogger); + const pty = spawnPty(args.command, args.args, { + cols: 120, + cwd: process.cwd(), + env: args.env ?? process.env, + name: "xterm-256color", + rows: 30, + }); + pty.onData((data) => stdout.write(data)); + pty.onExit(() => { + stdout.end(); + stderr.end(); + }); + return { + stdout, + stderr, + kill(signal) { + pty.kill(signal); + return true; + }, + onError(listener) { + void listener; + }, + onClose(listener) { + pty.onExit((event) => listener(event.exitCode, null)); + }, + }; + }, + }; +} + +export function streamProviderInstallation(args: { + providerId: string; + plan: ExperimentalProviderInstallationCommand; + env?: NodeJS.ProcessEnv; + processSpawner?: ProviderInstallationProcessSpawner; +}): ReadableStream { + if (activeProviderId !== null) { + throw new ProviderInstallationInProgressError(activeProviderId); + } + activeProviderId = args.providerId; + let closed = false; + let child: ProviderInstallationProcess | null = null; + const release = () => { + if (activeProviderId === args.providerId) activeProviderId = null; + }; + + return new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + const write = (event: ProviderCliInstallEvent) => { + if (closed) return; + const parsed = providerCliInstallEventSchema.parse(event); + controller.enqueue(encoder.encode(`${JSON.stringify(parsed)}\n`)); + }; + const close = () => { + if (closed) return; + closed = true; + release(); + controller.close(); + }; + write({ + type: "started", + provider: args.providerId, + command: args.plan.displayCommand, + }); + try { + child = ( + args.processSpawner ?? createPtyProviderInstallationProcessSpawner() + ).spawn({ + command: args.plan.command, + args: [...args.plan.args], + ...(args.env === undefined ? {} : { env: args.env }), + }); + } catch (error) { + write({ + type: "error", + provider: args.providerId, + message: error instanceof Error ? error.message : String(error), + }); + close(); + return; + } + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (text: string) => + write({ + type: "output", + provider: args.providerId, + stream: "stdout", + text, + }), + ); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (text: string) => + write({ + type: "output", + provider: args.providerId, + stream: "stderr", + text, + }), + ); + child.onError((error) => { + write({ + type: "error", + provider: args.providerId, + message: error.message, + }); + close(); + }); + child.onClose((exitCode, signal) => { + write({ + type: "completed", + provider: args.providerId, + exitCode, + signal, + success: exitCode === 0, + }); + close(); + }); + }, + cancel() { + closed = true; + release(); + child?.kill("SIGTERM"); + }, + }); +} diff --git a/apps/host-daemon/src/provider-usage.test.ts b/apps/host-daemon/src/provider-usage.test.ts deleted file mode 100644 index 0ebb13f620..0000000000 --- a/apps/host-daemon/src/provider-usage.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import Database from "better-sqlite3"; -import { describe, expect, it } from "vitest"; -import { __testing } from "./provider-usage.js"; - -const { - normalizeCodexUsage, - normalizeClaudeUsage, - normalizeCursorUsage, - codexPlanLabel, - claudePlanLabel, - readCursorAccountEmailFromDatabase, -} = __testing; - -describe("normalizeCodexUsage", () => { - it("maps primary/secondary windows and plan to the unified shape", () => { - const primaryReset = 1_780_000_000; - const secondaryReset = 1_780_500_000; - const result = normalizeCodexUsage( - { - plan_type: "pro", - rate_limit: { - primary_window: { - used_percent: 12, - reset_at: primaryReset, - limit_window_seconds: 18_000, - }, - secondary_window: { - used_percent: 18, - reset_at: secondaryReset, - limit_window_seconds: 604_800, - }, - }, - // Unknown sibling fields must be ignored, not fatal. - credits: { has_credits: false, unlimited: false, balance: null }, - }, - "codex@example.com", - ); - - expect(result).toEqual({ - status: "ok", - accountEmail: "codex@example.com", - planLabel: "Pro", - windows: [ - { - label: "Current session", - usedPercent: 12, - resetsAt: new Date(primaryReset * 1000).toISOString(), - }, - { - label: "Weekly limit", - usedPercent: 18, - resetsAt: new Date(secondaryReset * 1000).toISOString(), - }, - ], - }); - }); - - it("clamps and rounds percentages and tolerates a missing reset", () => { - const result = normalizeCodexUsage({ - plan_type: "team", - rate_limit: { - primary_window: { used_percent: 150.6 }, - secondary_window: { used_percent: -5 }, - }, - }); - - expect(result).toEqual({ - status: "ok", - accountEmail: null, - planLabel: "Team", - windows: [ - { label: "Current session", usedPercent: 100, resetsAt: null }, - { label: "Weekly limit", usedPercent: 0, resetsAt: null }, - ], - }); - }); - - it("returns ok with no windows when rate limits are absent", () => { - expect(normalizeCodexUsage({ plan_type: "plus" })).toEqual({ - status: "ok", - accountEmail: null, - planLabel: "Plus", - windows: [], - }); - }); - - it("labels a weekly primary window from its duration", () => { - const resetAt = 1_786_380_099; - expect( - normalizeCodexUsage({ - plan_type: "pro", - rate_limit: { - primary_window: { - used_percent: 8, - limit_window_seconds: 604_800, - reset_at: resetAt, - }, - secondary_window: null, - }, - }), - ).toEqual({ - status: "ok", - accountEmail: null, - planLabel: "Pro", - windows: [ - { - label: "Weekly limit", - usedPercent: 8, - resetsAt: new Date(resetAt * 1000).toISOString(), - }, - ], - }); - }); - - it("flags a malformed payload instead of inventing numbers", () => { - const result = normalizeCodexUsage({ - rate_limit: { primary_window: { used_percent: "lots" } }, - }); - expect(result.status).toBe("error"); - }); -}); - -describe("normalizeClaudeUsage", () => { - const credentials = { - accessToken: "token", - rateLimitTier: "default_claude_max_20x", - subscriptionType: "max", - }; - - it("maps session, weekly, and model-scoped windows and derives the plan label", () => { - const result = normalizeClaudeUsage( - { - five_hour: { utilization: 0, resets_at: "2026-06-19T22:00:00.000Z" }, - seven_day: { utilization: 18.4, resets_at: "2026-06-24T14:23:00.000Z" }, - seven_day_sonnet: { utilization: 0, resets_at: null }, - limits: [ - { - kind: "session", - scope: null, - percent: 0, - resets_at: "2026-06-19T22:00:00.000Z", - }, - { - kind: "weekly_scoped", - scope: { - model: { id: null, display_name: "Fable" }, - surface: null, - }, - percent: 48.2, - resets_at: "2026-06-24T14:22:59.000Z", - }, - ], - }, - credentials, - "claude@example.com", - ); - - expect(result).toEqual({ - status: "ok", - accountEmail: "claude@example.com", - planLabel: "Max (20x)", - windows: [ - { - label: "Current session", - usedPercent: 0, - resetsAt: "2026-06-19T22:00:00.000Z", - }, - { - label: "Weekly limit", - usedPercent: 18, - resetsAt: "2026-06-24T14:23:00.000Z", - }, - { - label: "Fable", - usedPercent: 48, - resetsAt: "2026-06-24T14:22:59.000Z", - }, - ], - }); - }); - - it("drops windows the API omits or leaves without a utilization", () => { - const result = normalizeClaudeUsage( - { - five_hour: { utilization: 7, resets_at: null }, - seven_day: { resets_at: "2026-06-24T14:23:00.000Z" }, - limits: [ - { - kind: "weekly_scoped", - scope: { model: null }, - percent: 25, - resets_at: "2026-06-24T14:23:00.000Z", - }, - { - kind: "weekly_scoped", - scope: { model: { display_name: "Fable" } }, - percent: null, - resets_at: "2026-06-24T14:23:00.000Z", - }, - ], - }, - { accessToken: "token" }, - ); - - expect(result).toEqual({ - status: "ok", - accountEmail: null, - planLabel: null, - windows: [{ label: "Current session", usedPercent: 7, resetsAt: null }], - }); - }); - - it("keeps valid usage when one optional scoped row is malformed", () => { - const result = normalizeClaudeUsage( - { - five_hour: { utilization: 7, resets_at: null }, - seven_day: { utilization: 18, resets_at: null }, - limits: [ - { - kind: "weekly_scoped", - scope: { model: { display_name: 42 }, surface: null }, - percent: "lots", - resets_at: null, - }, - { - kind: "weekly_scoped", - scope: { model: { display_name: "Fable" }, surface: null }, - percent: 48, - resets_at: null, - }, - ], - }, - { accessToken: "token" }, - ); - - expect(result).toEqual({ - status: "ok", - accountEmail: null, - planLabel: null, - windows: [ - { label: "Current session", usedPercent: 7, resetsAt: null }, - { label: "Weekly limit", usedPercent: 18, resetsAt: null }, - { label: "Fable", usedPercent: 48, resetsAt: null }, - ], - }); - }); - - it("drops surface-scoped and duplicate model rows", () => { - const result = normalizeClaudeUsage( - { - limits: [ - { - kind: "weekly_scoped", - scope: { - model: { display_name: "Fable" }, - surface: { display_name: "Claude Code" }, - }, - percent: 20, - resets_at: null, - }, - { - kind: "weekly_scoped", - scope: { model: { display_name: "Fable" }, surface: null }, - percent: 48, - resets_at: null, - }, - { - kind: "weekly_scoped", - scope: { model: { display_name: "fable" }, surface: null }, - percent: 52, - resets_at: null, - }, - ], - }, - { accessToken: "token" }, - ); - - expect(result).toEqual({ - status: "ok", - accountEmail: null, - planLabel: null, - windows: [{ label: "Fable", usedPercent: 48, resetsAt: null }], - }); - }); -}); - -describe("normalizeCursorUsage", () => { - it("reads and validates Cursor's cached authenticated email", async () => { - const directory = await fs.mkdtemp( - path.join(os.tmpdir(), "bb-cursor-email-"), - ); - const databasePath = path.join(directory, "state.vscdb"); - const database = new Database(databasePath); - try { - database.exec( - "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)", - ); - database - .prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)") - .run("cursorAuth/cachedEmail", "cursor@example.com"); - - expect(readCursorAccountEmailFromDatabase(databasePath)).toBe( - "cursor@example.com", - ); - - database - .prepare("UPDATE ItemTable SET value = ? WHERE key = ?") - .run("not-an-email", "cursorAuth/cachedEmail"); - expect(readCursorAccountEmailFromDatabase(databasePath)).toBeNull(); - } finally { - database.close(); - await fs.rm(directory, { force: true, recursive: true }); - } - }); - - it("uses Cursor's explicit plan percentage instead of its spend ratio", () => { - const billingCycleEnd = 1_784_391_684_000; - const result = normalizeCursorUsage( - { - billingCycleEnd: String(billingCycleEnd), - planUsage: { - totalSpend: 1_439, - includedSpend: 1_439, - remaining: 561, - limit: 2_000, - totalPercentUsed: 4.171014492753623, - }, - spendLimitUsage: { - individualLimit: 5_000, - individualUsed: 1_250, - individualRemaining: 3_750, - limitType: "user", - }, - }, - { - planInfo: { - planName: "Pro", - includedAmountCents: 2_000, - }, - }, - "cursor@example.com", - ); - - expect(result).toEqual({ - status: "ok", - accountEmail: "cursor@example.com", - planLabel: "Pro", - windows: [ - { - label: "Plan usage", - usedPercent: 4, - resetsAt: new Date(billingCycleEnd).toISOString(), - }, - { - label: "On-demand spend", - usedPercent: 25, - resetsAt: new Date(billingCycleEnd).toISOString(), - cost: { - usedUsdCents: 1_250, - limitUsdCents: 5_000, - }, - }, - ], - }); - }); - - it("uses a zero spend when Cursor omits a zero-valued usage field", () => { - const result = normalizeCursorUsage( - { - planUsage: { limit: 2_000 }, - spendLimitUsage: { individualLimit: 5_000 }, - }, - {}, - ); - - expect(result).toEqual({ - status: "ok", - accountEmail: null, - planLabel: null, - windows: [ - { - label: "Plan usage", - usedPercent: 0, - resetsAt: null, - }, - { - label: "On-demand spend", - usedPercent: 0, - resetsAt: null, - cost: { - usedUsdCents: 0, - limitUsdCents: 5_000, - }, - }, - ], - }); - }); - - it("flags malformed Cursor usage without requiring plan metadata", () => { - expect(normalizeCursorUsage({ planUsage: "a lot" }, {}).status).toBe( - "error", - ); - }); -}); - -describe("plan labels", () => { - it("derives codex plan labels", () => { - expect(codexPlanLabel("pro")).toBe("Pro"); - expect(codexPlanLabel("free_workspace")).toBe("Free_workspace"); - expect(codexPlanLabel(null)).toBeNull(); - }); - - it("derives claude plan labels from the rate-limit tier first", () => { - expect(claudePlanLabel({ accessToken: "t", rateLimitTier: "max_5x" })).toBe( - "Max (5x)", - ); - expect(claudePlanLabel({ accessToken: "t", subscriptionType: "pro" })).toBe( - "Pro", - ); - expect(claudePlanLabel({ accessToken: "t" })).toBeNull(); - }); -}); diff --git a/apps/host-daemon/src/provider-usage.ts b/apps/host-daemon/src/provider-usage.ts deleted file mode 100644 index f0b3edc972..0000000000 --- a/apps/host-daemon/src/provider-usage.ts +++ /dev/null @@ -1,874 +0,0 @@ -import { execFile } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { promisify } from "node:util"; -import Database from "better-sqlite3"; -import type { - ProviderUsage, - ProviderUsageResponse, - ProviderUsageWindow, -} from "@bb/host-daemon-contract"; -import { z } from "zod"; -import { - getChatGptCloudflareCookieHeader, - storeChatGptCloudflareCookies, -} from "./chatgpt-cloudflare-cookies.js"; -import { readCodexAuthCredentials } from "./codex-auth.js"; -import { ExpectedCommandDispatchError } from "./command-dispatch-support.js"; -import { isProviderCliInstalled } from "./provider-cli-health.js"; - -const USAGE_FETCH_TIMEOUT_MS = 15_000; - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function clampPercent(value: number): number { - if (!Number.isFinite(value)) { - return 0; - } - return Math.min(100, Math.max(0, Math.round(value))); -} - -function epochSecondsToIso(seconds: number | null | undefined): string | null { - if (seconds == null || !Number.isFinite(seconds)) { - return null; - } - return new Date(seconds * 1000).toISOString(); -} - -function epochMillisecondsToIso( - milliseconds: number | null | undefined, -): string | null { - if (milliseconds == null || !Number.isFinite(milliseconds)) { - return null; - } - return new Date(milliseconds).toISOString(); -} - -function normalizeIsoTimestamp( - value: string | null | undefined, -): string | null { - if (!value) { - return null; - } - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); -} - -// --------------------------------------------------------------------------- -// Codex (ChatGPT subscription) usage -// --------------------------------------------------------------------------- - -const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; - -const codexUsageWindowSchema = z.object({ - used_percent: z.number(), - reset_at: z.number().nullish(), - limit_window_seconds: z.number().nullish(), -}); - -const codexUsageResponseSchema = z.object({ - plan_type: z.string().nullish(), - rate_limit: z - .object({ - primary_window: codexUsageWindowSchema.nullish(), - secondary_window: codexUsageWindowSchema.nullish(), - }) - .nullish(), -}); - -const CODEX_PLAN_LABELS: Record = { - free: "Free", - go: "Go", - plus: "Plus", - pro: "Pro", - team: "Team", - business: "Business", - education: "Education", - edu: "Education", - enterprise: "Enterprise", -}; - -function codexPlanLabel(planType: string | null | undefined): string | null { - if (!planType) { - return null; - } - return ( - CODEX_PLAN_LABELS[planType] ?? - planType.charAt(0).toUpperCase() + planType.slice(1) - ); -} - -function codexWindow( - window: z.infer | null | undefined, - fallbackLabel: string, -): ProviderUsageWindow | null { - if (!window) { - return null; - } - const label = - window.limit_window_seconds === 604_800 ? "Weekly limit" : fallbackLabel; - return { - label, - usedPercent: clampPercent(window.used_percent), - resetsAt: epochSecondsToIso(window.reset_at), - }; -} - -async function fetchChatGptUsage(headers: Headers): Promise { - const doFetch = async (): Promise => { - const requestHeaders = new Headers(headers); - const cookie = getChatGptCloudflareCookieHeader(CODEX_USAGE_URL); - if (cookie) { - requestHeaders.set("Cookie", cookie); - } - const response = await fetch(CODEX_USAGE_URL, { - method: "GET", - headers: requestHeaders, - signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS), - }); - storeChatGptCloudflareCookies(CODEX_USAGE_URL, response.headers); - return response; - }; - - const response = await doFetch(); - if ( - response.status === 403 && - response.headers.get("cf-mitigated")?.toLowerCase() === "challenge" - ) { - // Cloudflare handed us a fresh clearance cookie; retry once with it. - return doFetch(); - } - return response; -} - -function normalizeCodexUsage( - raw: unknown, - accountEmail: string | null = null, -): ProviderUsage { - const parsed = codexUsageResponseSchema.safeParse(raw); - if (!parsed.success) { - return { - status: "error", - message: "Codex usage response was malformed.", - planLabel: null, - accountEmail: null, - }; - } - - const windows = [ - codexWindow(parsed.data.rate_limit?.primary_window, "Current session"), - codexWindow(parsed.data.rate_limit?.secondary_window, "Weekly limit"), - ].filter((window): window is ProviderUsageWindow => window !== null); - - return { - status: "ok", - accountEmail, - planLabel: codexPlanLabel(parsed.data.plan_type), - windows, - }; -} - -async function fetchCodexUsage(): Promise { - let credentials; - try { - credentials = await readCodexAuthCredentials(); - } catch (error) { - // Missing file (never logged in) and invalid/empty credentials (logged out, - // or tokens cleared) both mean "sign in to Codex" rather than a hard error. - if ( - error instanceof ExpectedCommandDispatchError && - (error.code === "codex_auth_missing" || - error.code === "codex_auth_invalid") - ) { - return { status: "unauthenticated" }; - } - return { - status: "error", - message: errorMessage(error), - planLabel: null, - accountEmail: null, - }; - } - - if (credentials.type === "apiKey") { - return { - status: "error", - message: - "Codex is authenticated with an API key, which has no subscription usage limits.", - planLabel: null, - accountEmail: null, - }; - } - - const headers = new Headers(); - headers.set("Authorization", `Bearer ${credentials.accessToken}`); - headers.set("chatgpt-account-id", credentials.accountId); - headers.set("originator", "bb"); - headers.set("User-Agent", "bb-host-daemon"); - headers.set("Accept", "application/json"); - if (credentials.isFedrampAccount) { - headers.set("X-OpenAI-Fedramp", "true"); - } - - const response = await fetchChatGptUsage(headers); - if (response.status === 401) { - return { status: "expired" }; - } - if (!response.ok) { - return { - status: "error", - message: `Codex usage request failed (HTTP ${response.status}).`, - planLabel: null, - accountEmail: null, - }; - } - - return normalizeCodexUsage(await response.json(), credentials.accountEmail); -} - -// --------------------------------------------------------------------------- -// Claude Code (Anthropic OAuth) usage -// --------------------------------------------------------------------------- - -const execFileAsync = promisify(execFile); - -const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; -const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; -const CLAUDE_OAUTH_BETA_HEADER = "oauth-2025-04-20"; -const CLAUDE_USER_AGENT = "claude-code/2.1.0"; - -const claudeCredentialsSchema = z.object({ - claudeAiOauth: z.object({ - accessToken: z.string().min(1), - expiresAt: z.number().nullish(), - subscriptionType: z.string().nullish(), - rateLimitTier: z.string().nullish(), - }), -}); -type ClaudeCredentials = z.infer< - typeof claudeCredentialsSchema ->["claudeAiOauth"]; - -const claudeAccountSchema = z.object({ - oauthAccount: z - .object({ emailAddress: z.string().email().nullish() }) - .nullish(), -}); - -const claudeUsageWindowSchema = z.object({ - utilization: z.number().nullish(), - resets_at: z.string().nullish(), -}); - -const claudeScopedUsageLimitSchema = z - .object({ - kind: z.string(), - scope: z - .object({ - model: z - .object({ display_name: z.string().trim().min(1).nullish() }) - .nullish(), - // Surface-specific buckets need a distinct display identity. Until the - // provider documents that shape, accept only the aggregate model row. - surface: z.null().optional(), - }) - .nullish(), - percent: z.number().nullish(), - resets_at: z.string().nullish(), - }) - .passthrough(); - -const claudeUsageResponseSchema = z - .object({ - five_hour: claudeUsageWindowSchema.nullish(), - seven_day: claudeUsageWindowSchema.nullish(), - limits: z - .array(claudeScopedUsageLimitSchema.nullable().catch(null)) - .nullish() - .catch([]), - }) - .passthrough(); - -async function readClaudeKeychainCredentials(): Promise { - if (process.platform !== "darwin") { - return null; - } - const argumentSets = [ - [ - "find-generic-password", - "-s", - CLAUDE_KEYCHAIN_SERVICE, - "-a", - os.userInfo().username, - "-w", - ], - ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"], - ]; - for (const args of argumentSets) { - try { - const { stdout } = await execFileAsync("security", args, { - timeout: 10_000, - }); - const trimmed = stdout.trim(); - if (trimmed.length > 0) { - return trimmed; - } - } catch { - // Try the next lookup, then fall back to the credentials file. - } - } - return null; -} - -async function readClaudeFileCredentials(): Promise { - try { - return await fs.readFile( - path.join(os.homedir(), ".claude", ".credentials.json"), - "utf8", - ); - } catch { - return null; - } -} - -async function readClaudeCredentials(): Promise { - const raw = - (await readClaudeKeychainCredentials()) ?? - (await readClaudeFileCredentials()); - if (!raw) { - return null; - } - let json: unknown; - try { - json = JSON.parse(raw); - } catch { - return null; - } - const parsed = claudeCredentialsSchema.safeParse(json); - return parsed.success ? parsed.data.claudeAiOauth : null; -} - -async function readClaudeAccountEmail(): Promise { - try { - const raw = await fs.readFile( - path.join(os.homedir(), ".claude.json"), - "utf8", - ); - const parsed = claudeAccountSchema.safeParse(JSON.parse(raw)); - return parsed.success - ? (parsed.data.oauthAccount?.emailAddress ?? null) - : null; - } catch { - return null; - } -} - -function claudePlanLabel(credentials: ClaudeCredentials): string | null { - const tier = credentials.rateLimitTier ?? ""; - const maxMatch = tier.match(/max_(\d+)x/u); - if (maxMatch) { - return `Max (${maxMatch[1]}x)`; - } - const subscription = credentials.subscriptionType; - if (subscription) { - return subscription.charAt(0).toUpperCase() + subscription.slice(1); - } - return null; -} - -function claudeWindow( - window: z.infer | null | undefined, - label: string, -): ProviderUsageWindow | null { - if (!window || window.utilization == null) { - return null; - } - return { - label, - usedPercent: clampPercent(window.utilization), - resetsAt: normalizeIsoTimestamp(window.resets_at), - }; -} - -function claudeScopedWindows( - limits: - | (z.infer | null)[] - | null - | undefined, -): ProviderUsageWindow[] { - // `limits` repeats the aggregate session/week rows and adds model buckets. - // Only the model-scoped weekly rows are additive to the legacy top-level data. - const windows: ProviderUsageWindow[] = []; - const seenLabels = new Set(); - for (const limit of limits ?? []) { - const label = limit?.scope?.model?.display_name; - if ( - limit == null || - limit.kind !== "weekly_scoped" || - label == null || - limit.percent == null || - seenLabels.has(label.toLowerCase()) - ) { - continue; - } - seenLabels.add(label.toLowerCase()); - windows.push({ - label, - usedPercent: clampPercent(limit.percent), - resetsAt: normalizeIsoTimestamp(limit.resets_at), - }); - } - return windows; -} - -function normalizeClaudeUsage( - raw: unknown, - credentials: ClaudeCredentials, - accountEmail: string | null = null, -): ProviderUsage { - const parsed = claudeUsageResponseSchema.safeParse(raw); - if (!parsed.success) { - return { - status: "error", - message: "Claude usage response was malformed.", - planLabel: null, - accountEmail: null, - }; - } - - const windows = [ - claudeWindow(parsed.data.five_hour, "Current session"), - claudeWindow(parsed.data.seven_day, "Weekly limit"), - ...claudeScopedWindows(parsed.data.limits), - ].filter((window): window is ProviderUsageWindow => window !== null); - - return { - status: "ok", - accountEmail, - planLabel: claudePlanLabel(credentials), - windows, - }; -} - -async function fetchClaudeUsage(): Promise { - const [credentials, accountEmail] = await Promise.all([ - readClaudeCredentials(), - readClaudeAccountEmail(), - ]); - if (!credentials) { - return { status: "unauthenticated" }; - } - if (credentials.expiresAt != null && Date.now() >= credentials.expiresAt) { - // The Claude CLI owns these tokens and refreshes them on its own next run; - // refreshing here risks rotating its refresh token out from under it. - return { status: "expired" }; - } - - const response = await fetch(CLAUDE_USAGE_URL, { - method: "GET", - headers: { - Authorization: `Bearer ${credentials.accessToken}`, - Accept: "application/json", - "Content-Type": "application/json", - "anthropic-beta": CLAUDE_OAUTH_BETA_HEADER, - "User-Agent": CLAUDE_USER_AGENT, - }, - signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS), - }); - - if (response.status === 401) { - return { status: "expired" }; - } - // Plan and account came from the local credential file, so a rate limit or - // outage should not blank them — bb still knows which plan pays for this. - const known = { - planLabel: claudePlanLabel(credentials), - accountEmail, - } as const; - if (response.status === 429) { - return { - status: "error", - message: "Claude usage is rate limited right now. Try again shortly.", - ...known, - }; - } - if (!response.ok) { - return { - status: "error", - message: `Claude usage request failed (HTTP ${response.status}).`, - ...known, - }; - } - - return normalizeClaudeUsage(await response.json(), credentials, accountEmail); -} - -// --------------------------------------------------------------------------- -// Cursor (Cursor subscription) -// --------------------------------------------------------------------------- - -const CURSOR_DASHBOARD_URL = - "https://api2.cursor.sh/aiserver.v1.DashboardService"; -const CURSOR_KEYCHAIN_ACCOUNT = "cursor-user"; -const CURSOR_ACCESS_TOKEN_SERVICE = "cursor-access-token"; - -const cursorNonNegativeIntegerSchema = z - .union([ - z.number().int().nonnegative(), - z.string().regex(/^\d+$/u).transform(Number), - ]) - .refine(Number.isSafeInteger); - -const cursorPlanUsageSchema = z.object({ - // Connect's JSON encoding omits scalar zero values. - totalPercentUsed: z.number().nonnegative().default(0), -}); - -const cursorSpendLimitUsageSchema = z.object({ - overallLimit: cursorNonNegativeIntegerSchema.nullish(), - overallUsed: cursorNonNegativeIntegerSchema.nullish(), - individualLimit: cursorNonNegativeIntegerSchema.nullish(), - individualUsed: cursorNonNegativeIntegerSchema.nullish(), - pooledLimit: cursorNonNegativeIntegerSchema.nullish(), - pooledUsed: cursorNonNegativeIntegerSchema.nullish(), -}); - -const cursorCurrentPeriodUsageSchema = z - .object({ - billingCycleEnd: cursorNonNegativeIntegerSchema.nullish(), - planUsage: cursorPlanUsageSchema.nullish(), - spendLimitUsage: cursorSpendLimitUsageSchema.nullish(), - }) - .passthrough(); - -const cursorPlanInfoSchema = z - .object({ - planInfo: z - .object({ - planName: z.string().min(1), - }) - .nullish(), - }) - .passthrough(); - -const cursorFileCredentialsSchema = z.object({ - accessToken: z.string().min(1).nullish(), -}); - -const cursorCachedEmailSchema = z.string().email(); - -function cursorAuthFilePath(): string { - if (process.platform === "win32") { - const appData = - process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"); - return path.join(appData, "Cursor", "auth.json"); - } - if (process.platform === "darwin") { - return path.join(os.homedir(), ".cursor", "auth.json"); - } - const configHome = - process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); - return path.join(configHome, "cursor", "auth.json"); -} - -async function readCursorKeychainAccessToken(): Promise { - if (process.platform !== "darwin") { - return null; - } - try { - const { stdout } = await execFileAsync( - "security", - [ - "find-generic-password", - "-s", - CURSOR_ACCESS_TOKEN_SERVICE, - "-a", - CURSOR_KEYCHAIN_ACCOUNT, - "-w", - ], - { timeout: 10_000 }, - ); - return stdout.trim() || null; - } catch { - return null; - } -} - -async function readCursorFileAccessToken(): Promise { - let raw: string; - try { - raw = await fs.readFile(cursorAuthFilePath(), "utf8"); - } catch { - return null; - } - let json: unknown; - try { - json = JSON.parse(raw); - } catch { - return null; - } - const parsed = cursorFileCredentialsSchema.safeParse(json); - return parsed.success ? (parsed.data.accessToken ?? null) : null; -} - -async function readCursorAccessToken(): Promise { - return ( - (await readCursorKeychainAccessToken()) ?? - (await readCursorFileAccessToken()) - ); -} - -function cursorStateDatabasePath(): string { - if (process.platform === "win32") { - const appData = - process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"); - return path.join(appData, "Cursor", "User", "globalStorage", "state.vscdb"); - } - if (process.platform === "darwin") { - return path.join( - os.homedir(), - "Library", - "Application Support", - "Cursor", - "User", - "globalStorage", - "state.vscdb", - ); - } - const configHome = - process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); - return path.join( - configHome, - "Cursor", - "User", - "globalStorage", - "state.vscdb", - ); -} - -function readCursorAccountEmailFromDatabase( - databasePath: string, -): string | null { - let database: Database.Database | null = null; - try { - database = new Database(databasePath, { - fileMustExist: true, - readonly: true, - }); - const row = database - .prepare("SELECT value FROM ItemTable WHERE key = ?") - .get("cursorAuth/cachedEmail"); - const parsed = z.object({ value: z.string() }).safeParse(row); - if (!parsed.success) { - return null; - } - const email = cursorCachedEmailSchema.safeParse(parsed.data.value); - return email.success ? email.data : null; - } catch { - return null; - } finally { - database?.close(); - } -} - -function readCursorAccountEmail(): string | null { - return readCursorAccountEmailFromDatabase(cursorStateDatabasePath()); -} - -interface CursorSpendUsageWindowArgs { - label: string; - used: number | null | undefined; - limit: number | null | undefined; - resetsAt: string | null; -} - -function cursorSpendUsageWindow( - args: CursorSpendUsageWindowArgs, -): ProviderUsageWindow | null { - if (args.used == null || args.limit == null || args.limit <= 0) { - return null; - } - return { - label: args.label, - usedPercent: clampPercent((args.used / args.limit) * 100), - resetsAt: args.resetsAt, - cost: { - usedUsdCents: args.used, - limitUsdCents: args.limit, - }, - }; -} - -function cursorPlanUsageWindow( - usedPercent: number | null | undefined, - resetsAt: string | null, -): ProviderUsageWindow | null { - if (usedPercent == null) { - return null; - } - return { - label: "Plan usage", - usedPercent: clampPercent(usedPercent), - resetsAt, - }; -} - -function normalizeCursorUsage( - rawUsage: unknown, - rawPlan: unknown, - accountEmail: string | null = null, -): ProviderUsage { - const usage = cursorCurrentPeriodUsageSchema.safeParse(rawUsage); - if (!usage.success) { - return { - status: "error", - message: "Cursor usage response was malformed.", - planLabel: null, - accountEmail: null, - }; - } - const plan = cursorPlanInfoSchema.safeParse(rawPlan); - const resetsAt = epochMillisecondsToIso(usage.data.billingCycleEnd); - const spendLimit = usage.data.spendLimitUsage; - const spendLimitPair = - spendLimit?.overallLimit != null - ? { limit: spendLimit.overallLimit, used: spendLimit.overallUsed ?? 0 } - : spendLimit?.individualLimit != null - ? { - limit: spendLimit.individualLimit, - used: spendLimit.individualUsed ?? 0, - } - : spendLimit?.pooledLimit != null - ? { limit: spendLimit.pooledLimit, used: spendLimit.pooledUsed ?? 0 } - : null; - const windows = [ - cursorPlanUsageWindow(usage.data.planUsage?.totalPercentUsed, resetsAt), - cursorSpendUsageWindow({ - label: "On-demand spend", - used: spendLimitPair?.used, - limit: spendLimitPair?.limit, - resetsAt, - }), - ].filter((window): window is ProviderUsageWindow => window !== null); - - return { - status: "ok", - accountEmail, - planLabel: plan.success ? (plan.data.planInfo?.planName ?? null) : null, - windows, - }; -} - -type CursorDashboardMethod = "GetCurrentPeriodUsage" | "GetPlanInfo"; - -function fetchCursorDashboard( - method: CursorDashboardMethod, - accessToken: string, -): Promise { - // Cursor has no personal-usage CLI command or public individual API. These - // authenticated Connect RPCs are the same dashboard methods shipped in the - // Cursor CLI, so keep their response parsing isolated at this boundary. - return fetch(`${CURSOR_DASHBOARD_URL}/${method}`, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - "Content-Type": "application/json", - "Connect-Protocol-Version": "1", - "x-cursor-client-type": "cli", - "x-cursor-client-version": "cli-bb-host-daemon", - }, - body: "{}", - signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS), - }); -} - -async function fetchCursorUsage(): Promise { - if (!(await isProviderCliInstalled("cursor"))) { - return { status: "not_installed" }; - } - - const accessToken = await readCursorAccessToken(); - if (!accessToken) { - return { status: "unauthenticated" }; - } - - const [usageResponse, planResponse] = await Promise.all([ - fetchCursorDashboard("GetCurrentPeriodUsage", accessToken), - fetchCursorDashboard("GetPlanInfo", accessToken), - ]); - if (usageResponse.status === 401 || planResponse.status === 401) { - return { status: "expired" }; - } - if (!usageResponse.ok) { - return { - status: "error", - message: `Cursor usage request failed (HTTP ${usageResponse.status}).`, - planLabel: null, - accountEmail: null, - }; - } - - const rawPlan: unknown = planResponse.ok ? await planResponse.json() : {}; - return normalizeCursorUsage( - await usageResponse.json(), - rawPlan, - readCursorAccountEmail(), - ); -} - -// --------------------------------------------------------------------------- -// Public entry point -// --------------------------------------------------------------------------- - -/** - * Reads live usage/rate-limit snapshots for local Codex, Claude Code, and - * Cursor subscriptions. Each provider resolves independently so one failing - * never blanks the others. Tokens are read from the providers' own credential - * stores and used as-is — we never refresh another tool's tokens. - */ -export async function getProviderUsage(): Promise { - const [codex, claudeCode, cursor] = await Promise.all([ - fetchCodexUsage().catch( - (error): ProviderUsage => ({ - status: "error", - message: errorMessage(error), - planLabel: null, - accountEmail: null, - }), - ), - fetchClaudeUsage().catch( - (error): ProviderUsage => ({ - status: "error", - message: errorMessage(error), - planLabel: null, - accountEmail: null, - }), - ), - fetchCursorUsage().catch( - (error): ProviderUsage => ({ - status: "error", - message: errorMessage(error), - planLabel: null, - accountEmail: null, - }), - ), - ]); - return { codex, claudeCode, cursor }; -} - -export const __testing = { - normalizeCodexUsage, - normalizeClaudeUsage, - normalizeCursorUsage, - codexPlanLabel, - claudePlanLabel, - readCursorAccountEmailFromDatabase, -}; diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts index 685057b9e1..8ceedfcb34 100644 --- a/apps/host-daemon/src/runtime-manager.test.ts +++ b/apps/host-daemon/src/runtime-manager.test.ts @@ -14,7 +14,11 @@ import { type HostWorkspace, type ProvisionWorkspaceArgs, } from "@bb/host-workspace"; -import { makeWorkspaceMergeBase, makeWorkspaceStatus } from "@bb/test-helpers"; +import { + createDeferredPromise, + makeWorkspaceMergeBase, + makeWorkspaceStatus, +} from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { RuntimeManager, @@ -31,7 +35,6 @@ type GetSharedGitRefsFingerprintResult = Awaited< ReturnType >; type CommitArgs = Parameters; -type FetchArgs = Parameters; type SquashMergeArgs = Parameters; type ProvisionWorkspaceMockArgs = Parameters< (options: ProvisionWorkspaceArgs) => Promise @@ -124,6 +127,7 @@ async function writeInjectedSkillSource( } afterEach(async () => { + vi.unstubAllEnvs(); await Promise.all( tempDirs .splice(0) @@ -131,20 +135,6 @@ afterEach(async () => { ); }); -function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve; - reject = innerReject; - }); - return { - promise, - reject, - resolve, - }; -} - function getProvisionWorkspacePath(args: ProvisionWorkspaceArgs): string { switch (args.workspaceProvisionType) { case "managed-worktree": @@ -207,14 +197,12 @@ function createFakeWorkspace( diffPatch: vi.fn(async () => []), getPullRequest: vi.fn(async () => ({ outcome: "none" as const })), runPullRequestAction: vi.fn(async () => undefined), - listBranches: vi.fn(async () => ["main"]), listFiles: vi.fn(async () => []), commit: vi.fn(async (..._args: CommitArgs) => ({ commitSha: "commit-1", commitSubject: "commit", })), reset: vi.fn(async () => undefined), - fetch: vi.fn(async (..._args: FetchArgs) => undefined), squashMerge: vi.fn(async (..._args: SquashMergeArgs) => ({ merged: true, commitSha: "commit-1", @@ -285,6 +273,14 @@ function createFakeRuntime() { models: [], selectedOnlyModels: [], })), + providerHealth: vi.fn(async () => ({ supported: false as const })), + providerUsage: vi.fn(async () => ({ supported: false as const })), + providerInstallationStatus: vi.fn(async () => { + throw new Error("Unexpected provider installation status call"); + }), + providerInstallationRun: vi.fn(async () => { + throw new Error("Unexpected provider installation run call"); + }), listRunningProviders: vi.fn((): string[] => []), getActiveTurnId: (threadId) => activeTurnsByThreadId.get(threadId) ?? null, waitForActiveTurn: async (threadId) => @@ -990,7 +986,7 @@ describe("RuntimeManager", () => { }); it("shares existing environment provisioning cancellation across concurrent callers", async () => { - const provisionStarted = createDeferred(); + const provisionStarted = createDeferredPromise(); const provisionSignals: AbortSignal[] = []; let callCount = 0; const workspace = createFakeWorkspace("/tmp/env-1"); @@ -1188,6 +1184,34 @@ describe("RuntimeManager", () => { ); }); + it("forwards the bridge record-mode directory to provider processes but not the shell env", async () => { + vi.stubEnv("BB_PROVIDER_BRIDGE_RECORD_DIR", "/tmp/provider-recordings/raw"); + const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); + const createRuntime = vi.fn(() => createFakeRuntime()); + const manager = new RuntimeManager({ + provisionWorkspace, + createRuntime, + shellEnv: { + PATH: "/tmp/bb-bin:/usr/bin", + }, + }); + + await manager.ensureEnvironment({ + environmentId: "env-1", + workspacePath: "/tmp/env-1", + }); + + expect(createRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + env: { + PATH: "/tmp/bb-bin:/usr/bin", + BB_PROVIDER_BRIDGE_RECORD_DIR: "/tmp/provider-recordings/raw", + }, + shellEnv: { PATH: "/tmp/bb-bin:/usr/bin" }, + }), + ); + }); + it("passes the resolved shell PATH to managed worktree setup", async () => { const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); const manager = new RuntimeManager({ @@ -1211,51 +1235,45 @@ describe("RuntimeManager", () => { expect(provisionWorkspace).toHaveBeenCalledWith( expect.objectContaining({ - setupPath: "/resolved/user/bin:/usr/bin:/bin", + shellPath: "/resolved/user/bin:/usr/bin:/bin", }), ); }); - it("passes shell PATH through to provider process env", async () => { + it("passes the resolved shell PATH to unmanaged workspace Git", async () => { const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); - const createRuntime = vi.fn(() => createFakeRuntime()); const manager = new RuntimeManager({ provisionWorkspace, - createRuntime, shellEnv: { - PATH: "/tmp/bb-bin:/home/me/.local/bin:/usr/bin", - BB_SERVER_URL: "http://127.0.0.1:3334", - OPENAI_API_KEY: "test-openai-key", + PATH: "/resolved/user/bin:/usr/bin:/bin", }, }); await manager.ensureEnvironment({ environmentId: "env-1", - workspacePath: "/tmp/env-1", + provision: { + workspaceProvisionType: "unmanaged", + path: "/tmp/env-1", + }, }); - expect(createRuntime).toHaveBeenCalledWith( + expect(provisionWorkspace).toHaveBeenCalledWith( expect.objectContaining({ - env: { - PATH: "/tmp/bb-bin:/home/me/.local/bin:/usr/bin", - }, - shellEnv: { - PATH: "/tmp/bb-bin:/home/me/.local/bin:/usr/bin", - BB_SERVER_URL: "http://127.0.0.1:3334", - OPENAI_API_KEY: "test-openai-key", - }, + shellPath: "/resolved/user/bin:/usr/bin:/bin", }), ); }); - it("merges managed shell env into future runtime creation", async () => { + it("passes shell PATH through to provider process env", async () => { const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); const createRuntime = vi.fn(() => createFakeRuntime()); const manager = new RuntimeManager({ provisionWorkspace, createRuntime, shellEnv: { - PATH: "/tmp/bb-bin:/usr/bin", + PATH: "/tmp/bb-bin:/home/me/.local/bin:/usr/bin", + BB_SERVER_URL: "http://127.0.0.1:3334", + OPENAI_API_KEY: "test-openai-key", }, }); @@ -1264,22 +1282,15 @@ describe("RuntimeManager", () => { workspacePath: "/tmp/env-1", }); - manager.replaceManagedShellEnv({ - GITHUB_TOKEN: "test-github-token", - OPENAI_API_KEY: "test-openai-key", - }); - await manager.ensureEnvironment({ - environmentId: "env-2", - workspacePath: "/tmp/env-2", - }); - - expect(createRuntime).toHaveBeenNthCalledWith( - 2, + expect(createRuntime).toHaveBeenCalledWith( expect.objectContaining({ + env: { + PATH: "/tmp/bb-bin:/home/me/.local/bin:/usr/bin", + }, shellEnv: { - GITHUB_TOKEN: "test-github-token", + PATH: "/tmp/bb-bin:/home/me/.local/bin:/usr/bin", + BB_SERVER_URL: "http://127.0.0.1:3334", OPENAI_API_KEY: "test-openai-key", - PATH: "/tmp/bb-bin:/usr/bin", }, }), ); @@ -1326,11 +1337,45 @@ describe("RuntimeManager", () => { ); }); + it("shuts down provider maintenance workers after the request becomes idle", async () => { + vi.useFakeTimers(); + try { + const dataDir = await makeTempDir("bb-provider-maintenance-idle-"); + const runtime = createFakeRuntime(); + const request = createDeferredPromise(); + const requestStarted = createDeferredPromise(); + const manager = new RuntimeManager({ + createRuntime: () => runtime, + providerMaintenanceIdleTimeoutMs: 100, + }); + + const activeRequest = manager.withProviderMaintenanceRuntime( + { dataDir }, + async () => { + requestStarted.resolve(); + return request.promise; + }, + ); + await requestStarted.promise; + await vi.advanceTimersByTimeAsync(200); + expect(runtime.shutdown).not.toHaveBeenCalled(); + + request.resolve(); + await activeRequest; + await vi.advanceTimersByTimeAsync(99); + expect(runtime.shutdown).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(runtime.shutdown).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + it("does not let stale provider maintenance creation replace a newer runtime", async () => { const dataDir = await makeTempDir("bb-provider-maintenance-race-"); const staleRuntime = createFakeRuntime(); const currentRuntime = createFakeRuntime(); - const staleCreation = createDeferred(); + const staleCreation = createDeferredPromise(); const manager = new RuntimeManager({ shellEnv: { PATH: "/old/bin:/usr/bin", @@ -1686,7 +1731,7 @@ describe("RuntimeManager", () => { }); it("skips idle eviction while environment creation is still pending", async () => { - const deferredWorkspace = createDeferred(); + const deferredWorkspace = createDeferredPromise(); const manager = new RuntimeManager({ provisionWorkspace: vi.fn(async () => deferredWorkspace.promise), createRuntime: vi.fn(() => createFakeRuntime()), diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts index 07353b15a6..5c38c372c2 100644 --- a/apps/host-daemon/src/runtime-manager.ts +++ b/apps/host-daemon/src/runtime-manager.ts @@ -42,11 +42,13 @@ import { } from "./injected-skills.js"; import { reconnectProvisionArgs } from "./workspace-provision-target.js"; import type { FetchSkillTree } from "./skill-trees.js"; +import { userExecutableProcessOptions } from "./user-executable-env.js"; type StopWatching = () => void | Promise; const STOP_WATCHING: StopWatching = () => undefined; const PROVIDER_MAINTENANCE_WORKSPACE_DIR = "provider-maintenance-workspace"; +const PROVIDER_MAINTENANCE_IDLE_TIMEOUT_MS = 60_000; const PROVIDER_PROCESS_EXIT_DETAIL_MAX_LENGTH = 4000; interface RuntimeSkillConfig { @@ -149,7 +151,7 @@ export interface RuntimeEntry { terminals: Set; } -export interface InjectedSkillsChangedNotification { +interface InjectedSkillsChangedNotification { changedPaths: string[]; sourceType: InjectedSkillsObservedChange["sourceType"]; } @@ -172,15 +174,15 @@ export interface EnsureEnvironmentArgs { provision?: ProvisionWorkspaceArgs; } -export interface CancelEnvironmentProvisionArgs { +interface CancelEnvironmentProvisionArgs { environmentId: string; } -export interface CancelEnvironmentProvisionResult { +interface CancelEnvironmentProvisionResult { aborted: boolean; } -export interface RefreshEnvironmentWorkspaceArgs { +interface RefreshEnvironmentWorkspaceArgs { environmentId: string; provision: ProvisionWorkspaceArgs; workspacePath: string; @@ -201,6 +203,7 @@ export interface RuntimeManagerOptions { provisionWorkspace?: ( options: ProvisionWorkspaceArgs, ) => Promise; + providerMaintenanceIdleTimeoutMs?: number; shellEnv?: AgentRuntimeOptions["shellEnv"]; onEvent?: (args: { environmentId: string; event: ThreadEvent }) => void; threadStorageRootPath?: string | null; @@ -226,7 +229,7 @@ export interface RuntimeManagerReapIdleProviderSessionsArgs { providerSessionReapingEnabled: boolean; } -export interface RuntimeManagerReapedIdleProviderSession extends ReapedIdleProviderSession { +interface RuntimeManagerReapedIdleProviderSession extends ReapedIdleProviderSession { environmentId: string; } @@ -238,9 +241,9 @@ export interface RuntimeManagerReapIdleProviderSessionsResult { * `interrupt` stops an old runtime even while it runs a turn. `keep` leaves * that turn alone and reports its environment to the caller. */ -export type ReleaseThreadActiveTurnPolicy = "interrupt" | "keep"; +type ReleaseThreadActiveTurnPolicy = "interrupt" | "keep"; -export interface ReleaseThreadFromOtherEnvironmentsResult { +interface ReleaseThreadFromOtherEnvironmentsResult { /** Environments that still run a turn for the thread under `keep`. */ activeTurnEnvironmentIds: string[]; /** Provider checkpoint retained by a stopped runtime, when one reported it. */ @@ -288,10 +291,13 @@ function providerProcessEnvFromShellEnv( if (shellEnv.PATH) { env.PATH = shellEnv.PATH; } - // The Claude bridge resolves the CLI from its own process env; forward the - // documented override past the BB_* spawn sanitization. - if (shellEnv.BB_CLAUDE_CODE_EXECUTABLE) { - env.BB_CLAUDE_CODE_EXECUTABLE = shellEnv.BB_CLAUDE_CODE_EXECUTABLE; + // Bridge record mode (docs/provider-bridge-protocol.md) rides the same + // forward, from the daemon's own env rather than the shell env: the shell + // env doubles as the agent's shell environment, and the variable must reach + // the bridge process only, never the provider child or its shells. + const recordDir = process.env.BB_PROVIDER_BRIDGE_RECORD_DIR; + if (recordDir) { + env.BB_PROVIDER_BRIDGE_RECORD_DIR = recordDir; } return Object.keys(env).length > 0 ? env : null; } @@ -324,7 +330,9 @@ export class RuntimeManager { private pendingProviderMaintenanceRuntime: PendingProviderMaintenanceRuntime | null = null; private providerMaintenanceRuntimeGeneration = 0; - private managedShellEnv: NonNullable = {}; + private providerMaintenanceActiveRequests = 0; + private providerMaintenanceIdleTimer: ReturnType | null = + null; private stopWatchingDataDirSkillsRoot: StopWatching = STOP_WATCHING; constructor(private readonly options: RuntimeManagerOptions = {}) { @@ -619,10 +627,7 @@ export class RuntimeManager { } getShellEnv(): NonNullable { - return { - ...this.baseShellEnv, - ...this.managedShellEnv, - }; + return { ...this.baseShellEnv }; } async replaceBaseShellEnv( @@ -824,12 +829,6 @@ export class RuntimeManager { return null; } - replaceManagedShellEnv( - shellEnv: NonNullable, - ): void { - this.managedShellEnv = { ...shellEnv }; - } - /** * Tears down the resident provider-maintenance runtime so the next caller * gets a fresh one. In-flight maintenance RPCs fail with "Runtime shutting @@ -847,6 +846,7 @@ export class RuntimeManager { } private async shutdownProviderMaintenanceRuntime(): Promise { + this.clearProviderMaintenanceIdleTimer(); const existingRuntime = this.providerMaintenanceRuntime; const pendingRuntime = this.pendingProviderMaintenanceRuntime; this.providerMaintenanceRuntimeGeneration += 1; @@ -871,6 +871,38 @@ export class RuntimeManager { ); } + private clearProviderMaintenanceIdleTimer(): void { + if (this.providerMaintenanceIdleTimer === null) return; + clearTimeout(this.providerMaintenanceIdleTimer); + this.providerMaintenanceIdleTimer = null; + } + + private scheduleProviderMaintenanceIdleShutdown(): void { + this.clearProviderMaintenanceIdleTimer(); + if ( + this.providerMaintenanceActiveRequests > 0 || + (this.providerMaintenanceRuntime === null && + this.pendingProviderMaintenanceRuntime === null) + ) { + return; + } + + const timeoutMs = + this.options.providerMaintenanceIdleTimeoutMs ?? + PROVIDER_MAINTENANCE_IDLE_TIMEOUT_MS; + this.providerMaintenanceIdleTimer = setTimeout(() => { + this.providerMaintenanceIdleTimer = null; + if (this.providerMaintenanceActiveRequests > 0) return; + void this.shutdownProviderMaintenanceRuntime().catch((error) => { + this.options.logger?.warn( + { err: error }, + "Failed to shut down idle provider maintenance runtime", + ); + }); + }, timeoutMs); + this.providerMaintenanceIdleTimer.unref(); + } + private async evictIdleRuntimeEntries(): Promise { const idleEntries = [...this.entries.values()].filter( (entry) => !this.entryHasActiveEnvironmentWork(entry), @@ -885,13 +917,6 @@ export class RuntimeManager { await this.cleanupUnusedInjectedSkillStagingDirs([]); } - async openWorkspace(path: string): Promise { - return this.provisionWorkspace({ - workspaceProvisionType: "unmanaged", - path, - }); - } - async ensureProviderMaintenanceRuntime(args: { dataDir: string; }): Promise { @@ -928,6 +953,23 @@ export class RuntimeManager { return promise; } + async withProviderMaintenanceRuntime( + args: { dataDir: string }, + request: (runtime: AgentRuntime) => Promise, + ): Promise { + this.clearProviderMaintenanceIdleTimer(); + this.providerMaintenanceActiveRequests += 1; + try { + const runtime = await this.ensureProviderMaintenanceRuntime(args); + return await request(runtime); + } finally { + this.providerMaintenanceActiveRequests -= 1; + if (this.providerMaintenanceActiveRequests === 0) { + this.scheduleProviderMaintenanceIdleShutdown(); + } + } + } + async ensureEnvironment(args: EnsureEnvironmentArgs): Promise { const skillConfig = await this.resolveRuntimeSkillConfig(args); const existing = this.entries.get(args.environmentId); @@ -1023,7 +1065,7 @@ export class RuntimeManager { ); } - const workspace = await this.provisionWorkspace(args.provision); + const workspace = await this.provisionHostWorkspace(args.provision); if (workspace.path !== args.workspacePath) { throw new Error( `Workspace refresh for ${args.environmentId} returned ${workspace.path}, not ${args.workspacePath}`, @@ -1108,7 +1150,10 @@ export class RuntimeManager { ); } - await this.provisionWorkspace({ ...args.provision, signal: args.signal }); + await this.provisionHostWorkspace({ + ...args.provision, + signal: args.signal, + }); this.options.onWorkspaceStatusChanged?.({ environmentId: args.entry.environmentId, changeKinds: ["work-status-changed", "git-refs-changed"], @@ -1382,12 +1427,8 @@ export class RuntimeManager { ); } - const setupPath = this.getShellEnv().PATH; - const workspace = await this.provisionWorkspace({ + const workspace = await this.provisionHostWorkspace({ ...provision, - ...(provision.workspaceProvisionType === "managed-worktree" && setupPath - ? { setupPath } - : {}), signal: args.provisionSignal, }); const workspaceWriteRoots = @@ -1421,6 +1462,21 @@ export class RuntimeManager { })), onInteractiveRequest: this.options.onInteractiveRequest, onStderr: this.options.onStderr, + onProviderRecovery: (hint) => { + // Parse-and-forward only: the recovery actions land with the runtime + // cleanup workstream. Logged so a hint is never silently consumed. + this.options.logger?.debug( + { + environmentId: args.environmentId, + providerId: hint.providerId, + threadId: hint.threadId, + kind: hint.kind, + retryable: hint.retryable, + message: hint.message, + }, + "Provider bridge raised a recovery hint", + ); + }, onProcessExit: (info) => { if (!info.expected) { for (const event of this.buildUnexpectedProviderExitEvents(info)) { @@ -1454,6 +1510,15 @@ export class RuntimeManager { }; } + private provisionHostWorkspace( + provision: ProvisionWorkspaceArgs, + ): Promise { + return this.provisionWorkspace({ + ...provision, + ...userExecutableProcessOptions(this.getShellEnv()), + }); + } + private async stopWatchingStatus(entry: RuntimeEntry): Promise { const stopWatchingStatus = entry.stopWatchingStatus; entry.stopWatchingStatus = STOP_WATCHING; diff --git a/apps/host-daemon/src/runtime-shell-env.ts b/apps/host-daemon/src/runtime-shell-env.ts index 2995380cc8..389f432def 100644 --- a/apps/host-daemon/src/runtime-shell-env.ts +++ b/apps/host-daemon/src/runtime-shell-env.ts @@ -11,7 +11,7 @@ interface ResolveLocalBbExecutablePathOptions { cliRuntimePath?: string; } -export interface PrepareRuntimeShellEnvOptions { +interface PrepareRuntimeShellEnvOptions { bbExecutableDirectory: string; /** * Absolute path to the daemon-managed `bb` executable. Defaults to @@ -24,7 +24,7 @@ export interface PrepareRuntimeShellEnvOptions { inheritedPath?: string; } -export interface ResolveUserShellPathOptions { +interface ResolveUserShellPathOptions { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; spawnUserShellEnv?: SpawnUserShellEnv; @@ -382,7 +382,7 @@ export async function resolveLocalBbExecutablePath( } /** Platform-stable name of the bb CLI file inside `BB_CLI_DIR` / daemon dist. */ -export function bbExecutableFileName(): string { +function bbExecutableFileName(): string { return "bb"; } @@ -417,14 +417,5 @@ export function prepareRuntimeShellEnv( ? undefined : String(options.hostDaemonPort), }); - // Provider process spawning strips inherited BB_* variables, so the - // documented Claude CLI override must be forwarded explicitly for the - // bridge to see it. - assignIfDefined({ - key: "BB_CLAUDE_CODE_EXECUTABLE", - target: shellEnv, - value: process.env.BB_CLAUDE_CODE_EXECUTABLE, - }); - return shellEnv; } diff --git a/apps/host-daemon/src/server-client.test.ts b/apps/host-daemon/src/server-client.test.ts index 29310df417..e4647ee52b 100644 --- a/apps/host-daemon/src/server-client.test.ts +++ b/apps/host-daemon/src/server-client.test.ts @@ -88,6 +88,7 @@ describe("createServerClient", () => { hostType: "persistent", dataDir: "/tmp/bb", instanceId: "instance-1", + localApiPort: null, activeThreads: [], loadedEnvironments: [], }); @@ -107,6 +108,7 @@ describe("createServerClient", () => { const fetchFn = vi.fn(async (_input, init) => { expect(JSON.parse(String(init?.body))).toMatchObject({ hasMachineCredential, + localApiPort: 38_888, }); return Response.json( { @@ -132,6 +134,7 @@ describe("createServerClient", () => { hostType: "persistent", dataDir: "/tmp/bb", instanceId: "instance-1", + localApiPort: 38_888, activeThreads: [], loadedEnvironments: [], }); @@ -439,7 +442,6 @@ describe("createServerClient", () => { threadId: "thr_123", }, ], - kind: "accepted", rejectedEvents: [], }); }); diff --git a/apps/host-daemon/src/server-client.ts b/apps/host-daemon/src/server-client.ts index d61e79689d..4382c02bfc 100644 --- a/apps/host-daemon/src/server-client.ts +++ b/apps/host-daemon/src/server-client.ts @@ -174,6 +174,7 @@ interface OpenSessionArgs { hostType: HostDaemonSessionOpenRequest["hostType"]; dataDir: string; instanceId: string; + localApiPort: number | null; activeThreads: HostDaemonActiveThread[] | Promise; loadedEnvironments: | HostDaemonLoadedEnvironment[] @@ -475,6 +476,7 @@ export function createServerClient( options.machineCredential.trim().length > 0, platform: resolveHostPlatform(), dataDir: args.dataDir, + localApiPort: args.localApiPort, protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, activeThreads: await args.activeThreads, loadedEnvironments: await args.loadedEnvironments, @@ -566,7 +568,6 @@ export function createServerClient( return readHostArtifactBytes(response, args.expectedByteLength); }, - async postEvents( events: HostDaemonEventEnvelope[], ): Promise { @@ -588,7 +589,6 @@ export function createServerClient( const parsed = hostDaemonEventBatchResponseSchema.parse(json); return { acceptedEvents: parsed.acceptedEvents, - kind: "accepted", rejectedEvents: parsed.rejectedEvents, }; }, diff --git a/apps/host-daemon/src/server-connection-support.ts b/apps/host-daemon/src/server-connection-support.ts index 0076065b1d..0aaa3470d8 100644 --- a/apps/host-daemon/src/server-connection-support.ts +++ b/apps/host-daemon/src/server-connection-support.ts @@ -5,7 +5,6 @@ import { type HostDaemonConnectSharesReplaceMessage, type HostDaemonOnlineRpcRequestMessage, type HostDaemonServerWsMessage, - type HostDaemonSessionCloseReason, type HostDaemonSessionOpenRequest, type HostDaemonSessionOpenResponse, type HostDaemonWatchSetReplaceMessage, @@ -64,6 +63,7 @@ export interface ServerConnectionOptions { hostType: HostDaemonSessionOpenRequest["hostType"]; dataDir: string; instanceId: string; + localApiPort: number | null; setSession?: (session: HostDaemonSessionOpenResponse | null) => void; getActiveThreads?: () => | HostDaemonActiveThread[] @@ -83,22 +83,11 @@ export interface ServerConnectionOptions { onConnectSharesReplace?: ( message: HostDaemonConnectSharesReplaceMessage, ) => void | Promise; - onSessionClose?: ( - reason: HostDaemonSessionCloseReason, - ) => void | Promise; onSessionOpened?: ( session: HostDaemonSessionOpenResponse, ) => void | Promise; createWebSocket?: CreateReconnectingWebSocket; - minReconnectionDelay?: number; - maxReconnectionDelay?: number; - reconnectionDelayGrowFactor?: number; - connectionTimeout?: number; startupTimeoutMs?: number; - setTimeoutFn?: typeof setTimeout; - clearTimeoutFn?: typeof clearTimeout; - setIntervalFn?: typeof setInterval; - clearIntervalFn?: typeof clearInterval; } export const DEFAULT_MIN_RECONNECTION_DELAY = 1_000; diff --git a/apps/host-daemon/src/server-connection.test.ts b/apps/host-daemon/src/server-connection.test.ts index 1e7f9057e1..9784121a5d 100644 --- a/apps/host-daemon/src/server-connection.test.ts +++ b/apps/host-daemon/src/server-connection.test.ts @@ -173,6 +173,7 @@ function createConnectionFixture(args: ConnectionFixtureArgs = {}) { hostName: "Server Connection Test Host", hostType: "persistent", instanceId: "instance-server-connection-test", + localApiPort: 38_887, logger, ...(args.machineCredential !== undefined ? { machineCredential: args.machineCredential } @@ -306,7 +307,10 @@ describe("ServerConnection", () => { try { await fixture.connection.start(); expect(fixture.openSession).toHaveBeenCalledWith( - expect.objectContaining({ connectMachineId: "machine-cloud-1" }), + expect.objectContaining({ + connectMachineId: "machine-cloud-1", + localApiPort: 38_887, + }), ); } finally { await fixture.connection.shutdown(); @@ -350,6 +354,33 @@ describe("ServerConnection", () => { } }); + it("reports a system-suspension gap without calling it a heartbeat stall", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { connection, logger } = createConnectionFixture({ + heartbeatIntervalMs: 5_000, + leaseTimeoutMs: 30_000, + }); + try { + await connection.start(); + await vi.advanceTimersByTimeAsync(5_000); + + vi.setSystemTime(300_000); + await vi.advanceTimersByTimeAsync(5_000); + + expect(logger.warn).not.toHaveBeenCalledWith( + expect.anything(), + "Host daemon heartbeat timer delayed", + ); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ gapMs: 300_000 }), + "Host daemon resumed after likely system suspension", + ); + } finally { + await connection.shutdown(); + } + }); + it("queues output above high water and flushes it before lifecycle messages", async () => { vi.useFakeTimers(); const { connection, webSocket } = createConnectionFixture(); diff --git a/apps/host-daemon/src/server-connection.ts b/apps/host-daemon/src/server-connection.ts index 7bb9777af4..b797d44b33 100644 --- a/apps/host-daemon/src/server-connection.ts +++ b/apps/host-daemon/src/server-connection.ts @@ -24,6 +24,7 @@ import { type ReconnectingWebSocketLike, type ServerConnectionOptions, } from "./server-connection-support.js"; +import { isLikelySystemSuspensionDelay } from "./system-suspension.js"; import { normalizeCaughtError, runtimeErrorLogFields } from "./error-utils.js"; import { ServerResponseError } from "./server-client.js"; @@ -66,6 +67,10 @@ export interface HandleServerSessionInvalidatedArgs { source: ServerSessionInvalidationSource; } +type SessionCloseHandler = ( + reason: HostDaemonSessionCloseReason, +) => void | Promise; + const SERVER_MESSAGE_PAYLOAD_PREVIEW_CHARS = 512; const TERMINAL_SOCKET_HIGH_WATER_BYTES = 1024 * 1024; // A 16 MiB raw burst expands to about 21.4 MiB as base64 + JSON. Keep @@ -136,22 +141,14 @@ function summarizeServerMessagePayload( export class ServerConnection { private readonly createWebSocket: CreateReconnectingWebSocket; - private readonly minReconnectionDelay: number; - private readonly maxReconnectionDelay: number; - private readonly reconnectionDelayGrowFactor: number; - private readonly connectionTimeout: number; private readonly startupTimeoutMs: number; - private readonly setTimeoutFn: typeof setTimeout; - private readonly clearTimeoutFn: typeof clearTimeout; - private readonly setIntervalFn: typeof setInterval; - private readonly clearIntervalFn: typeof clearInterval; private session: HostDaemonSessionOpenResponse | null = null; private websocket: ReconnectingWebSocketLike | null = null; private heartbeatInterval: ReturnType | null = null; private lastHeartbeatTickAt: number | null = null; private stopped = false; - private sessionCloseHandler: ServerConnectionOptions["onSessionClose"]; + private sessionCloseHandler: SessionCloseHandler | undefined; private fatalConnectError: ServerResponseError | null = null; private protocolMismatchObserved = false; private sessionInvalidationInProgress = false; @@ -166,24 +163,10 @@ export class ServerConnection { >(); constructor(private readonly options: ServerConnectionOptions) { - this.sessionCloseHandler = options.onSessionClose; this.createWebSocket = options.createWebSocket ?? createDefaultReconnectingWebSocket; - this.minReconnectionDelay = - options.minReconnectionDelay ?? DEFAULT_MIN_RECONNECTION_DELAY; - this.maxReconnectionDelay = - options.maxReconnectionDelay ?? DEFAULT_MAX_RECONNECTION_DELAY; - this.reconnectionDelayGrowFactor = - options.reconnectionDelayGrowFactor ?? - DEFAULT_RECONNECTION_DELAY_GROW_FACTOR; - this.connectionTimeout = - options.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT_MS; this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; - this.setTimeoutFn = options.setTimeoutFn ?? setTimeout; - this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout; - this.setIntervalFn = options.setIntervalFn ?? setInterval; - this.clearIntervalFn = options.clearIntervalFn ?? clearInterval; } get sessionId(): string | null { @@ -290,7 +273,7 @@ export class ServerConnection { if (this.terminalSocketDrainTimeout !== null) { return; } - this.terminalSocketDrainTimeout = this.setTimeoutFn(() => { + this.terminalSocketDrainTimeout = setTimeout(() => { this.terminalSocketDrainTimeout = null; this.flushTerminalSocketPayloads(false); }, TERMINAL_SOCKET_DRAIN_POLL_MS); @@ -334,16 +317,14 @@ export class ServerConnection { private clearTerminalSocketPayloads(): void { if (this.terminalSocketDrainTimeout !== null) { - this.clearTimeoutFn(this.terminalSocketDrainTimeout); + clearTimeout(this.terminalSocketDrainTimeout); this.terminalSocketDrainTimeout = null; } this.pendingTerminalSocketPayloads.length = 0; this.pendingTerminalSocketBytes = 0; } - setSessionCloseHandler( - handler: ServerConnectionOptions["onSessionClose"], - ): void { + setSessionCloseHandler(handler: SessionCloseHandler | undefined): void { this.sessionCloseHandler = handler; } @@ -380,6 +361,7 @@ export class ServerConnection { hostType: this.options.hostType, connectMachineId: this.options.connectMachineId, dataDir: this.options.dataDir, + localApiPort: this.options.localApiPort, activeThreads: this.options.getActiveThreads?.() ?? [], loadedEnvironments: this.options.getLoadedEnvironments?.() ?? [], }); @@ -446,10 +428,10 @@ export class ServerConnection { return this.buildWebSocketUrl(sessionId); }, { - minReconnectionDelay: this.minReconnectionDelay, - maxReconnectionDelay: this.maxReconnectionDelay, - reconnectionDelayGrowFactor: this.reconnectionDelayGrowFactor, - connectionTimeout: this.connectionTimeout, + minReconnectionDelay: DEFAULT_MIN_RECONNECTION_DELAY, + maxReconnectionDelay: DEFAULT_MAX_RECONNECTION_DELAY, + reconnectionDelayGrowFactor: DEFAULT_RECONNECTION_DELAY_GROW_FACTOR, + connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS, headers: { authorization: buildHostDaemonWebSocketAuthorizationHeader( this.options.hostKey, @@ -470,7 +452,7 @@ export class ServerConnection { let settled = false; let hasOpened = false; - const startupTimer = this.setTimeoutFn(() => { + const startupTimer = setTimeout(() => { if (this.protocolMismatchObserved) { return; } @@ -486,7 +468,7 @@ export class ServerConnection { return; } settled = true; - this.clearTimeoutFn(startupTimer); + clearTimeout(startupTimer); void this.shutdown(); reject(normalizeCaughtError(error)); }; @@ -502,7 +484,7 @@ export class ServerConnection { const handleOpen = async () => { hasOpened = true; this.sessionInvalidationInProgress = false; - this.clearTimeoutFn(startupTimer); + clearTimeout(startupTimer); this.resetHeartbeat(); this.options.setSession?.(session); this.options.logger.info( @@ -745,7 +727,7 @@ export class ServerConnection { } this.lastHeartbeatTickAt = Date.now(); - this.heartbeatInterval = this.setIntervalFn(() => { + this.heartbeatInterval = setInterval(() => { const session = this.session; if (!session) { return; @@ -755,7 +737,23 @@ export class ServerConnection { if (lastTickAt !== null) { const gapMs = now - lastTickAt; const thresholdMs = session.leaseTimeoutMs / 2; - if (gapMs > thresholdMs) { + if ( + isLikelySystemSuspensionDelay({ + gapMs, + intervalMs: session.heartbeatIntervalMs, + }) + ) { + this.options.logger.info( + { + gapMs, + heartbeatIntervalMs: session.heartbeatIntervalMs, + leaseTimeoutMs: session.leaseTimeoutMs, + sessionId: session.sessionId, + websocketReadyState: this.websocket?.readyState ?? null, + }, + "Host daemon resumed after likely system suspension", + ); + } else if (gapMs > thresholdMs) { this.options.logger.warn( { gapMs, @@ -782,7 +780,7 @@ export class ServerConnection { if (!this.heartbeatInterval) { return; } - this.clearIntervalFn(this.heartbeatInterval); + clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; this.lastHeartbeatTickAt = null; } diff --git a/apps/host-daemon/src/sha256-hex.ts b/apps/host-daemon/src/sha256-hex.ts new file mode 100644 index 0000000000..ea71243200 --- /dev/null +++ b/apps/host-daemon/src/sha256-hex.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto"; + +export function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/apps/host-daemon/src/start-host-daemon.ts b/apps/host-daemon/src/start-host-daemon.ts index 15f20b9812..2e35346ad7 100644 --- a/apps/host-daemon/src/start-host-daemon.ts +++ b/apps/host-daemon/src/start-host-daemon.ts @@ -1,18 +1,14 @@ import { randomUUID } from "node:crypto"; import { dirname } from "node:path"; -import { - loadHostDaemonStartConfig, - type HostDaemonConnectionConfig, -} from "@bb/config/host-daemon"; -import type { HostType, ToolCallRequest, ToolCallResponse } from "@bb/domain"; +import { loadHostDaemonStartConfig } from "@bb/config/host-daemon"; +import type { HostType } from "@bb/domain"; import { createHostWatcher, createSubprocessParcelWatcherBackend, setParcelWatcherBackend, - type HostWatcher, } from "@bb/host-watcher"; import { createLogger } from "@bb/logger"; -import { type CreateHostDaemonAppOptions, createHostDaemonApp } from "./app.js"; +import { createHostDaemonApp } from "./app.js"; import { readHostAuthState, resolveServerUrl, @@ -22,10 +18,7 @@ import type { HostDaemon } from "./daemon.js"; import { enrollDaemonHost } from "./enroll.js"; import { loadHostIdentity, persistHostId } from "./identity.js"; import { acquireDaemonLock } from "./lock.js"; -import { - resolveHostDaemonLocalApiConfig, - type HostDaemonLocalApiOverrides, -} from "./local-api-config.js"; +import { resolveHostDaemonLocalApiConfig } from "./local-api-config.js"; import { prepareRuntimeShellEnv, resolveBbExecutablePathInDirectory, @@ -37,59 +30,25 @@ import { startMachineAuthProxy, type MachineAuthProxy, } from "./machine-auth-proxy.js"; -import type { CreateReconnectingWebSocket } from "./server-connection.js"; -export interface StartHostDaemonOptions { - dataDir?: string; - serverUrl?: string; - hostDaemonPort?: number; +interface StartHostDaemonOptions { enrollKey?: string; hostId?: string; hostName?: string; bbExecutableDirectory?: string; bridgeBundleDir?: string; hostType?: HostType; - enableLocalApi?: boolean; - localApi?: HostDaemonLocalApiOverrides; machineCredential?: string; connectMachineId?: string; autoUpdate?: boolean; - logger?: HostDaemonLogger; - createInstanceId?: () => string; - acquireLock?: typeof acquireDaemonLock; - loadIdentity?: typeof loadHostIdentity; - createRuntime?: CreateHostDaemonAppOptions["createRuntime"]; - hostWatcher?: HostWatcher; - onToolCall?: (request: ToolCallRequest) => Promise; - fetchFn?: typeof fetch; - createWebSocket?: CreateReconnectingWebSocket; -} - -function requireHostDaemonConfig( - config: HostDaemonConnectionConfig | undefined, -): HostDaemonConnectionConfig { - if (config === undefined) { - throw new Error("Host daemon config is required"); - } - - return config; } export async function startHostDaemon( options: StartHostDaemonOptions = {}, ): Promise { - const enableLocalApi = options.enableLocalApi ?? true; - const resolvedConfig = loadHostDaemonStartConfig({ - dataDir: options.dataDir, - enableLocalApi, - hostDaemonPort: options.hostDaemonPort ?? options.localApi?.port, - serverUrl: options.serverUrl, - }); + const resolvedConfig = loadHostDaemonStartConfig({}); const dataDir = resolvedConfig.dataDir; const hostDaemonConfig = resolvedConfig.connectionConfig; - if (dataDir === undefined) { - throw new Error("Host daemon data directory is required"); - } // The real logger writes into the shared data dir, so it must not exist // before the lock is held (a losing daemon would mutate the winner's // rolling logs). Lock diagnostics delegate to it once it is created below; @@ -99,41 +58,38 @@ export async function startHostDaemon( // Losing the lock to another live daemon before the app exists exits // directly; once the app is running it gets a graceful shutdown first. let handleDaemonLockLost: () => void = () => process.exit(1); - const releaseLock = await (options.acquireLock ?? acquireDaemonLock)( - dataDir, - { - logger: { - warn: (fields, message) => { - if (lockDiagnosticsLogger) { - lockDiagnosticsLogger.warn(fields, message); - } else { - console.warn(message, fields); - } - }, - error: (fields, message) => { - if (lockDiagnosticsLogger) { - lockDiagnosticsLogger.error(fields, message); - } else { - console.error(message, fields); - } - }, + const releaseLock = await acquireDaemonLock(dataDir, { + logger: { + warn: (fields, message) => { + if (lockDiagnosticsLogger) { + lockDiagnosticsLogger.warn(fields, message); + } else { + console.warn(message, fields); + } + }, + error: (fields, message) => { + if (lockDiagnosticsLogger) { + lockDiagnosticsLogger.error(fields, message); + } else { + console.error(message, fields); + } }, - onLockLost: () => handleDaemonLockLost(), }, - ); + onLockLost: () => handleDaemonLockLost(), + }); let app: Awaited> | undefined; let machineAuthProxy: MachineAuthProxy | undefined; try { const persistedAuth = await readHostAuthState(dataDir); - const identity = await (options.loadIdentity ?? loadHostIdentity)({ + const identity = await loadHostIdentity({ dataDir, providedHostId: options.hostId, providedHostName: options.hostName, }); - const instanceId = (options.createInstanceId ?? randomUUID)(); + const instanceId = randomUUID(); const serverUrl = resolveServerUrl({ - providedServerUrl: options.serverUrl ?? hostDaemonConfig?.BB_SERVER_URL, + providedServerUrl: hostDaemonConfig.BB_SERVER_URL, }); if (!serverUrl) { throw new Error("Host daemon server URL is required"); @@ -161,7 +117,6 @@ export async function startHostDaemon( persistedAuth?.hostKey ?? ( await enrollDaemonHost({ - fetchFn: options.fetchFn, hostId: identity.hostId, hostName: identity.hostName, hostType, @@ -187,28 +142,20 @@ export async function startHostDaemon( }); } - const localApiConfig = enableLocalApi - ? resolveHostDaemonLocalApiConfig({ - hostDaemonPort: - options.hostDaemonPort ?? - requireHostDaemonConfig(hostDaemonConfig).BB_HOST_DAEMON_PORT, - hostType, - localApi: options.localApi, - }) - : null; + const localApiConfig = resolveHostDaemonLocalApiConfig({ + hostDaemonPort: hostDaemonConfig.BB_HOST_DAEMON_PORT, + }); const bbExecutablePath = options.bbExecutableDirectory !== undefined ? resolveBbExecutablePathInDirectory(options.bbExecutableDirectory) : await resolveLocalBbExecutablePath(); const bbExecutableDirectory = dirname(bbExecutablePath); - const logger = - options.logger ?? - createLogger({ - component: "host-daemon", - base: { serverUrl }, - dataDir, - transportMode: "worker", - }); + const logger = createLogger({ + component: "host-daemon", + base: { serverUrl }, + dataDir, + transportMode: "worker", + }); lockDiagnosticsLogger = logger; if (options.machineCredential !== undefined) { machineAuthProxy = await startMachineAuthProxy({ @@ -216,31 +163,28 @@ export async function startHostDaemon( serverUrl, }); } - let hostWatcher = options.hostWatcher; - if (hostWatcher === undefined) { - // Run @parcel/watcher in an isolated child process. A parcel inotify - // crash/hang/leak is then contained in the child and self-heals via - // SIGKILL + respawn, instead of taking down the daemon. - setParcelWatcherBackend( - createSubprocessParcelWatcherBackend({ - log: (level, message, fields) => { - if (level === "error") { - logger.error(fields ?? {}, message); - } else if (level === "warn") { - logger.warn(fields ?? {}, message); - } else { - logger.info(fields ?? {}, message); - } - }, - }), - ); - hostWatcher = createHostWatcher(); - } + // Run @parcel/watcher in an isolated child process. A parcel inotify + // crash/hang/leak is then contained in the child and self-heals via + // SIGKILL + respawn, instead of taking down the daemon. + setParcelWatcherBackend( + createSubprocessParcelWatcherBackend({ + log: (level, message, fields) => { + if (level === "error") { + logger.error(fields ?? {}, message); + } else if (level === "warn") { + logger.warn(fields ?? {}, message); + } else { + logger.info(fields ?? {}, message); + } + }, + }), + ); + const hostWatcher = createHostWatcher(); const resolveRuntimeShellEnv = async () => prepareRuntimeShellEnv({ bbExecutableDirectory, bbExecutablePath, - hostDaemonPort: localApiConfig?.port, + hostDaemonPort: localApiConfig.port, inheritedPath: (await resolveUserShellPath()) ?? process.env.PATH, serverUrl: machineAuthProxy?.serverUrl ?? serverUrl, }); @@ -259,21 +203,17 @@ export async function startHostDaemon( hostName: identity.hostName, instanceId, appUrl: - hostDaemonConfig?.BB_APP_URL === "" + hostDaemonConfig.BB_APP_URL === "" ? undefined - : hostDaemonConfig?.BB_APP_URL, - devAppPort: hostDaemonConfig?.BB_DEV_APP_PORT, + : hostDaemonConfig.BB_APP_URL, + devAppPort: hostDaemonConfig.BB_DEV_APP_PORT, logger, releaseLock, localApiConfig, - createRuntime: options.createRuntime, runtimeShellEnv, runtimeShellEnvResolvedAtMs, resolveRuntimeShellEnv, hostWatcher, - onToolCall: options.onToolCall, - fetchFn: options.fetchFn, - createWebSocket: options.createWebSocket, closeMachineAuthProxy: machineAuthProxy?.close, // This function owns the daemon process, so it arms the shutdown // force-exit. A self-update restart depends on the process exiting. diff --git a/apps/host-daemon/src/system-suspension.ts b/apps/host-daemon/src/system-suspension.ts new file mode 100644 index 0000000000..d6d8b70351 --- /dev/null +++ b/apps/host-daemon/src/system-suspension.ts @@ -0,0 +1,14 @@ +const LIKELY_SYSTEM_SUSPENSION_MIN_DELAY_MS = 60_000; + +/** + * Long timer gaps on a laptop are overwhelmingly process suspension during + * system sleep, not JavaScript monopolizing the event loop. Keep sub-minute + * delays visible as real stalls while preventing a wake from flooding the log + * with event-loop and heartbeat warnings for time the process did not run. + */ +export function isLikelySystemSuspensionDelay(args: { + gapMs: number; + intervalMs: number; +}): boolean { + return args.gapMs - args.intervalMs >= LIKELY_SYSTEM_SUSPENSION_MIN_DELAY_MS; +} diff --git a/apps/host-daemon/src/terminals/terminal-manager.test.ts b/apps/host-daemon/src/terminals/terminal-manager.test.ts index a19f9a236e..31c48d0a8c 100644 --- a/apps/host-daemon/src/terminals/terminal-manager.test.ts +++ b/apps/host-daemon/src/terminals/terminal-manager.test.ts @@ -4,7 +4,11 @@ import path from "node:path"; import type { AgentRuntime } from "@bb/agent-runtime"; import type { HostDaemonDaemonWsMessage } from "@bb/host-daemon-contract"; import type { HostWorkspace } from "@bb/host-workspace"; -import { makeWorkspaceMergeBase, makeWorkspaceStatus } from "@bb/test-helpers"; +import { + createDeferredPromise, + makeWorkspaceMergeBase, + makeWorkspaceStatus, +} from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { HostDaemonLogger } from "../logger.js"; import { RuntimeManager } from "../runtime-manager.js"; @@ -47,11 +51,6 @@ interface WaitForOutputArgs { text: string; } -interface Deferred { - promise: Promise; - resolve: (value: T) => void; -} - type TerminalMessageObserver = (message: HostDaemonDaemonWsMessage) => void; interface CreateHarnessOptions { @@ -77,19 +76,6 @@ async function writeEmptyFile(filePath: string): Promise { await fs.writeFile(filePath, ""); } -function createDeferred(): Deferred { - let resolveDeferred: (value: T) => void = () => { - throw new Error("Deferred resolver was not set"); - }; - const promise = new Promise((resolve) => { - resolveDeferred = resolve; - }); - return { - promise, - resolve: resolveDeferred, - }; -} - function createFakeLogger(): HostDaemonLogger { return { debug: vi.fn(), @@ -227,6 +213,14 @@ function createFakeRuntime(): AgentRuntime { archiveThread: vi.fn(async () => undefined), unarchiveThread: vi.fn(async () => undefined), listModels: vi.fn(async () => ({ models: [], selectedOnlyModels: [] })), + providerHealth: vi.fn(async () => ({ supported: false as const })), + providerUsage: vi.fn(async () => ({ supported: false as const })), + providerInstallationStatus: vi.fn(async () => { + throw new Error("Unexpected provider installation status call"); + }), + providerInstallationRun: vi.fn(async () => { + throw new Error("Unexpected provider installation run call"); + }), listRunningProviders: vi.fn(() => []), getActiveTurnId: vi.fn(() => null), waitForActiveTurn: vi.fn(async () => null), @@ -271,14 +265,12 @@ function createFakeWorkspace(path: string): HostWorkspace { })), diffPatch: vi.fn(async () => []), getPullRequest: vi.fn(async () => ({ outcome: "none" as const })), - listBranches: vi.fn(async () => ["main"]), listFiles: vi.fn(async () => []), commit: vi.fn(async () => ({ commitSha: "commit-1", commitSubject: "commit", })), reset: vi.fn(async () => undefined), - fetch: vi.fn(async () => undefined), squashMerge: vi.fn(async () => ({ commitSha: "commit-1", commitSubject: "commit", @@ -540,7 +532,7 @@ describe("TerminalManager", () => { }); it("closes a terminal after an in-progress open finishes", async () => { - const shell = createDeferred(); + const shell = createDeferredPromise(); let resolveShellCalls = 0; const harness = createHarnessWithShell({ resolveShell: () => { @@ -603,7 +595,7 @@ describe("TerminalManager", () => { }); it("closes environment terminals after in-progress opens finish", async () => { - const shell = createDeferred(); + const shell = createDeferredPromise(); let resolveShellCalls = 0; const harness = createHarnessWithShell({ resolveShell: () => { @@ -662,7 +654,7 @@ describe("TerminalManager", () => { }); it("shuts down terminals after in-progress opens finish", async () => { - const shell = createDeferred(); + const shell = createDeferredPromise(); let resolveShellCalls = 0; const harness = createHarnessWithShell({ resolveShell: () => { @@ -712,7 +704,7 @@ describe("TerminalManager", () => { }); it("rejects duplicate opens queued behind an in-progress open", async () => { - const shell = createDeferred(); + const shell = createDeferredPromise(); let resolveShellCalls = 0; const harness = createHarnessWithShell({ resolveShell: () => { @@ -773,7 +765,7 @@ describe("TerminalManager", () => { }); it("serializes PTY exits behind already queued terminal messages", async () => { - const shell = createDeferred(); + const shell = createDeferredPromise(); let resolveShellCalls = 0; let exitOnOpened = false; let harness: TerminalManagerHarness | null = null; diff --git a/apps/host-daemon/src/terminals/terminal-manager.ts b/apps/host-daemon/src/terminals/terminal-manager.ts index 96a9ac12e5..9976be7012 100644 --- a/apps/host-daemon/src/terminals/terminal-manager.ts +++ b/apps/host-daemon/src/terminals/terminal-manager.ts @@ -94,11 +94,8 @@ export interface TerminalManagerOptions { logger: HostDaemonLogger; platform?: NodeJS.Platform; ptyAdapter?: TerminalPtyAdapter; - outputBatchDelayMs?: number; resolveShell?: ResolveTerminalShell; runtimeManager: RuntimeManager; - scrollbackMaxBytes?: number; - scrollbackMaxChunks?: number; sendMessage: (message: HostDaemonDaemonWsMessage) => boolean; } @@ -201,7 +198,7 @@ interface TerminalOperationCompletion { resolve: () => void; } -export const nodePtyAdapter: TerminalPtyAdapter = { +const nodePtyAdapter: TerminalPtyAdapter = { spawn(args) { ensureNodePtySpawnHelperExecutable(args.logger); const pty = spawnPty(args.file, args.args, { @@ -244,7 +241,7 @@ interface EnsureNodePtySpawnHelperExecutableInPackageArgs { type NodePtySpawnHelperPathList = string[]; -export function resolveNodePtySpawnHelperCandidatePaths( +function resolveNodePtySpawnHelperCandidatePaths( args: ResolveNodePtySpawnHelperPathArgs, ): NodePtySpawnHelperPathList { const helperPaths: string[] = []; @@ -335,7 +332,7 @@ function isNonEmptyString(value: string | undefined): value is string { return value !== undefined && value.length > 0; } -export async function resolveDefaultTerminalShell(): Promise { +async function resolveDefaultTerminalShell(): Promise { const candidates = [ process.env.SHELL, "/bin/zsh", @@ -463,12 +460,9 @@ function consumePrimaryDeviceAttributesQueries( export class TerminalManager { private readonly closeGracePeriodMs: number; - private readonly outputBatchDelayMs: number; private readonly platform: NodeJS.Platform; private readonly ptyAdapter: TerminalPtyAdapter; private readonly resolveShell: ResolveTerminalShell; - private readonly scrollbackMaxBytes: number; - private readonly scrollbackMaxChunks: number; private readonly terminalOperations = new Map>(); private readonly openingTerminalEnvironmentIds = new Map< string, @@ -479,15 +473,9 @@ export class TerminalManager { constructor(private readonly options: TerminalManagerOptions) { this.closeGracePeriodMs = options.closeGracePeriodMs ?? DEFAULT_TERMINAL_CLOSE_GRACE_PERIOD_MS; - this.outputBatchDelayMs = - options.outputBatchDelayMs ?? DEFAULT_OUTPUT_BATCH_DELAY_MS; this.platform = options.platform ?? process.platform; this.ptyAdapter = options.ptyAdapter ?? nodePtyAdapter; this.resolveShell = options.resolveShell ?? resolveDefaultTerminalShell; - this.scrollbackMaxBytes = - options.scrollbackMaxBytes ?? DEFAULT_SCROLLBACK_MAX_BYTES; - this.scrollbackMaxChunks = - options.scrollbackMaxChunks ?? DEFAULT_SCROLLBACK_MAX_CHUNKS; } async handleMessage(message: HostDaemonServerTerminalMessage): Promise { @@ -905,7 +893,7 @@ export class TerminalManager { session.outputFlushTimeout = setTimeout(() => { session.outputFlushTimeout = null; this.flushTerminalOutput(session); - }, this.outputBatchDelayMs); + }, DEFAULT_OUTPUT_BATCH_DELAY_MS); } private flushTerminalOutput(session: TerminalSession): void { @@ -956,8 +944,8 @@ export class TerminalManager { private pruneScrollback(session: TerminalSession): void { while ( - session.scrollbackBytes > this.scrollbackMaxBytes || - session.scrollback.length > this.scrollbackMaxChunks + session.scrollbackBytes > DEFAULT_SCROLLBACK_MAX_BYTES || + session.scrollback.length > DEFAULT_SCROLLBACK_MAX_CHUNKS ) { const removed = session.scrollback.shift(); if (!removed) { diff --git a/apps/host-daemon/src/thread-storage-root.test.ts b/apps/host-daemon/src/thread-storage-root.test.ts index 837f916a73..4de448f11a 100644 --- a/apps/host-daemon/src/thread-storage-root.test.ts +++ b/apps/host-daemon/src/thread-storage-root.test.ts @@ -46,15 +46,4 @@ describe("thread storage root", () => { expect(rootPath).toBe(path.join(dataDir, "thread-storage")); }); - - it("uses an explicitly configured root", async () => { - const dataDir = await makeTempDir("bb-thread-storage-root-data-"); - const configuredRoot = await makeTempDir("bb-thread-storage-root-env-"); - - const rootPath = await ensureThreadStorageRoot(dataDir, { - configuredRoot, - }); - - expect(rootPath).toBe(configuredRoot); - }); }); diff --git a/apps/host-daemon/src/thread-storage-root.ts b/apps/host-daemon/src/thread-storage-root.ts index 3b905bb2cb..4294bb5883 100644 --- a/apps/host-daemon/src/thread-storage-root.ts +++ b/apps/host-daemon/src/thread-storage-root.ts @@ -1,26 +1,14 @@ import fs from "node:fs/promises"; import path from "node:path"; -interface ThreadStorageRootPathOptions { - configuredRoot?: string; -} - -export function threadStorageRootPath( - dataDir: string, - options: ThreadStorageRootPathOptions = {}, -): string { - const configuredRoot = options.configuredRoot; - if (configuredRoot && configuredRoot.trim().length > 0) { - return path.resolve(configuredRoot); - } +export function threadStorageRootPath(dataDir: string): string { return path.join(dataDir, "thread-storage"); } export async function ensureThreadStorageRoot( dataDir: string, - options: ThreadStorageRootPathOptions = {}, ): Promise { - const rootPath = threadStorageRootPath(dataDir, options); + const rootPath = threadStorageRootPath(dataDir); await fs.mkdir(rootPath, { recursive: true }); return rootPath; } diff --git a/apps/host-daemon/src/user-executable-env.ts b/apps/host-daemon/src/user-executable-env.ts new file mode 100644 index 0000000000..615dcabe46 --- /dev/null +++ b/apps/host-daemon/src/user-executable-env.ts @@ -0,0 +1,11 @@ +/** + * Process options for tools the user expects BB to resolve like their shell + * does. OS/service utilities deliberately do not use this policy, so a user + * login-shell PATH cannot shadow commands such as `scutil` or `osascript`. + */ +export function userExecutableProcessOptions(shellEnv: NodeJS.ProcessEnv): { + shellPath?: string; +} { + const shellPath = shellEnv.PATH; + return shellPath === undefined ? {} : { shellPath }; +} diff --git a/apps/host-daemon/src/watch-manager.test.ts b/apps/host-daemon/src/watch-manager.test.ts index a82cd71cfd..b06cf26146 100644 --- a/apps/host-daemon/src/watch-manager.test.ts +++ b/apps/host-daemon/src/watch-manager.test.ts @@ -5,7 +5,11 @@ import type { WorkspaceWatchError, } from "@bb/host-watcher"; import type { HostWorkspace } from "@bb/host-workspace"; -import { makeWorkspaceMergeBase, makeWorkspaceStatus } from "@bb/test-helpers"; +import { + createDeferredPromise, + makeWorkspaceMergeBase, + makeWorkspaceStatus, +} from "@bb/test-helpers"; import { describe, expect, it, vi } from "vitest"; import { WatchManager, type WatchManagerOptions } from "./watch-manager.js"; @@ -21,22 +25,6 @@ type WatchThreadStorageRootImplementation = ( args: WatchThreadStorageRootArgs, ) => StopWatching; -interface Deferred { - promise: Promise; - resolve: (value: TValue | PromiseLike) => void; - reject: (reason?: Error) => void; -} - -function createDeferred(): Deferred { - let resolve!: (value: TValue | PromiseLike) => void; - let reject!: (reason?: Error) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - function createFakeWorkspace(path: string, isGitRepo = true) { let localStateFingerprint: GetLocalStateFingerprintResult = `local:${path}:initial`; let localStateFingerprintError: Error | null = null; @@ -84,14 +72,12 @@ function createFakeWorkspace(path: string, isGitRepo = true) { diffPatch: vi.fn(async () => []), getPullRequest: vi.fn(async () => ({ outcome: "none" as const })), runPullRequestAction: vi.fn(async () => undefined), - listBranches: vi.fn(async () => ["main"]), listFiles: vi.fn(async () => []), commit: vi.fn(async () => ({ commitSha: "commit-1", commitSubject: "commit", })), reset: vi.fn(async () => undefined), - fetch: vi.fn(async () => undefined), squashMerge: vi.fn(async () => ({ merged: true, commitSha: "commit-1", @@ -150,7 +136,8 @@ function createFakeHostWatcher( describe("WatchManager", () => { it("starts watching before initial workspace fingerprints finish", async () => { const workspace = createFakeWorkspace("/tmp/env-watch"); - const localFingerprint = createDeferred(); + const localFingerprint = + createDeferredPromise(); workspace.getLocalStateFingerprint.mockImplementationOnce( () => localFingerprint.promise, ); @@ -552,7 +539,7 @@ describe("WatchManager", () => { it("serializes watch-set replacement while workspace watch startup is pending", async () => { const stopWatchingStatus = vi.fn(() => undefined); const workspace = createFakeWorkspace("/tmp/env-watch"); - const pendingWorkspace = createDeferred(); + const pendingWorkspace = createDeferredPromise(); const provisionWorkspace = vi.fn(() => { return pendingWorkspace.promise; }); @@ -599,7 +586,7 @@ describe("WatchManager", () => { it("waits for pending watch startup before removing an environment watch", async () => { const stopWatchingStatus = vi.fn(() => undefined); const workspace = createFakeWorkspace("/tmp/env-watch"); - const pendingWorkspace = createDeferred(); + const pendingWorkspace = createDeferredPromise(); const { hostWatcher, watchWorkspace } = createFakeHostWatcher({ watchWorkspaceImplementation: () => stopWatchingStatus, }); diff --git a/apps/host-daemon/src/watch-manager.ts b/apps/host-daemon/src/watch-manager.ts index 7cb055a20d..7da65ec508 100644 --- a/apps/host-daemon/src/watch-manager.ts +++ b/apps/host-daemon/src/watch-manager.ts @@ -18,6 +18,7 @@ import type { WorkspaceWatchError, } from "@bb/host-watcher"; import { reconnectProvisionArgsFromWorkspaceContext } from "./workspace-provision-target.js"; +import { userExecutableProcessOptions } from "./user-executable-env.js"; type StopWatching = () => void | Promise; @@ -56,6 +57,7 @@ export interface WatchManagerOptions { options: ProvisionWorkspaceArgs, ) => Promise; refreshWorkspace?: (args: RefreshWorkspaceArgs) => Promise; + shellEnv?: () => NodeJS.ProcessEnv; threadStorageRootPath?: string | null; onThreadStorageChanged?: (args: { environmentId: string; @@ -123,11 +125,15 @@ export class WatchManager { constructor(private readonly options: WatchManagerOptions = {}) { this.hostWatcher = options.hostWatcher; - this.provisionWorkspace = options.provisionWorkspace ?? provisionWorkspace; + const provision = options.provisionWorkspace ?? provisionWorkspace; + this.provisionWorkspace = (args: ProvisionWorkspaceArgs) => + provision({ + ...args, + ...userExecutableProcessOptions(options.shellEnv?.() ?? {}), + }); this.refreshWorkspace = options.refreshWorkspace ?? - ((args: RefreshWorkspaceArgs) => - this.provisionWorkspace(args.provision)); + ((args: RefreshWorkspaceArgs) => this.provisionWorkspace(args.provision)); } async replaceWatchSet(watchSet: HostDaemonWatchSet): Promise { diff --git a/apps/host-daemon/src/websocket-constructor.ts b/apps/host-daemon/src/websocket-constructor.ts index 0f2eac0613..3f11d9c767 100644 --- a/apps/host-daemon/src/websocket-constructor.ts +++ b/apps/host-daemon/src/websocket-constructor.ts @@ -1,6 +1,6 @@ import { WebSocket as NodeWebSocket } from "ws"; -export interface NodeWebSocketConstructor { +interface NodeWebSocketConstructor { new (address: string | URL, protocols?: string | string[]): object; } diff --git a/apps/host-daemon/src/workspace-resolution.ts b/apps/host-daemon/src/workspace-resolution.ts index e9bbe041d8..d0f75f7585 100644 --- a/apps/host-daemon/src/workspace-resolution.ts +++ b/apps/host-daemon/src/workspace-resolution.ts @@ -17,12 +17,6 @@ import { reconnectProvisionArgsFromWorkspaceContext } from "./workspace-provisio const WORKSPACE_RESOLUTION_FAILURE_CODES: readonly WorkspaceResolutionFailureCode[] = workspaceResolutionFailureCodeSchema.options; -interface BuildWorkspaceResolutionFailureArgs { - code: WorkspaceResolutionFailureCode; - message: string; - workspacePath: string; -} - interface WorkspaceResolutionFailureFromErrorArgs { error: unknown; workspacePath: string; @@ -73,57 +67,47 @@ function isPermissionDeniedError( return code === "EACCES" || code === "EPERM"; } -export function buildWorkspaceResolutionFailure( - args: BuildWorkspaceResolutionFailureArgs, -): WorkspaceResolutionFailure { - return { - code: args.code, - message: args.message, - workspacePath: args.workspacePath, - }; -} - export function workspaceResolutionFailureFromError( args: WorkspaceResolutionFailureFromErrorArgs, ): WorkspaceResolutionFailure { const { error, workspacePath } = args; if (error instanceof WorkspaceError) { - return buildWorkspaceResolutionFailure({ + return { code: isWorkspaceResolutionFailureCode(error.code) ? error.code : "unknown", message: error.message, workspacePath, - }); + }; } if (error instanceof CommandDispatchError) { - return buildWorkspaceResolutionFailure({ + return { code: isWorkspaceResolutionFailureCode(error.code) ? error.code : "unknown", message: error.message, workspacePath, - }); + }; } if (isPermissionDeniedError(error)) { - return buildWorkspaceResolutionFailure({ + return { code: "permission_denied", message: error.message, workspacePath, - }); + }; } if (error instanceof Error && error.message.trim().length > 0) { - return buildWorkspaceResolutionFailure({ + return { code: "unknown", message: error.message, workspacePath, - }); + }; } - return buildWorkspaceResolutionFailure({ + return { code: "unknown", message: "Unknown workspace resolution failure", workspacePath, - }); + }; } export async function resolveWorkspaceForCommand( @@ -161,11 +145,11 @@ export async function resolveWorkspaceForCommand( if (!workspace.isGitRepo) { return { ok: false, - failure: buildWorkspaceResolutionFailure({ + failure: { code: "not_git_repo", message: `Path is not a git repository: ${entry.workspace.path}`, workspacePath: entry.workspace.path, - }), + }, }; } } @@ -176,11 +160,11 @@ export async function resolveWorkspaceForCommand( ) { return { ok: false, - failure: buildWorkspaceResolutionFailure({ + failure: { code: "not_worktree", message: `Path is not a git worktree: ${entry.workspace.path}`, workspacePath: entry.workspace.path, - }), + }, }; } return { ok: true, entry }; diff --git a/apps/host-daemon/test/command/command-router.test.ts b/apps/host-daemon/test/command/command-router.test.ts index 4c71fdec40..c791e48fd9 100644 --- a/apps/host-daemon/test/command/command-router.test.ts +++ b/apps/host-daemon/test/command/command-router.test.ts @@ -9,6 +9,7 @@ import { type ClientTurnRequestId, type PromptInput, } from "@bb/domain"; +import { createDeferredPromise } from "@bb/test-helpers"; import { describe, expect, it, vi } from "vitest"; import { CommandRouter, @@ -37,12 +38,6 @@ type TextPromptInput = Extract; type ThreadStartCommand = Extract; type TurnSubmitCommand = Extract; -interface Deferred { - promise: Promise; - reject(error: Error): void; - resolve(value: T | PromiseLike): void; -} - interface RunRouterCommandArgs { command: HostDaemonCommand; requestId: string; @@ -65,23 +60,6 @@ interface CreateRouterArgs { let nextClientRequestIdValue = 1; -function createDeferred(): Deferred { - let resolveDeferred: ((value: T | PromiseLike) => void) | undefined; - let rejectDeferred: ((error: Error) => void) | undefined; - const promise = new Promise((resolve, reject) => { - resolveDeferred = resolve; - rejectDeferred = reject; - }); - if (!resolveDeferred || !rejectDeferred) { - throw new Error("Deferred promise callbacks were not initialized"); - } - return { - promise, - reject: rejectDeferred, - resolve: resolveDeferred, - }; -} - function createClientRequestId(): ClientTurnRequestId { const requestId = encodeClientTurnRequestIdNumber({ value: nextClientRequestIdValue, @@ -123,7 +101,7 @@ function createTurnSubmitCommand( model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -165,7 +143,7 @@ function createThreadStartCommand(): ThreadStartCommand { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -260,8 +238,8 @@ describe("CommandRouter", () => { environmentId: "env-router", workspacePath: "/tmp/env-router", }); - const destroyStarted = createDeferred(); - const releaseDestroy = createDeferred(); + const destroyStarted = createDeferredPromise(); + const releaseDestroy = createDeferredPromise(); harness.workspace.destroy = async () => { destroyStarted.resolve(); await releaseDestroy.promise; @@ -298,8 +276,8 @@ describe("CommandRouter", () => { environmentId: "env-router", workspacePath: "/tmp/env-router", }); - const startEntered = createDeferred(); - const releaseStart = createDeferred(); + const startEntered = createDeferredPromise(); + const releaseStart = createDeferredPromise(); const originalStartThread = harness.runtime.startThread; harness.runtime.startThread = async (args) => { startEntered.resolve(); @@ -377,8 +355,8 @@ describe("CommandRouter", () => { providerThreadId: "provider-moved", }); - const oldRunEntered = createDeferred(); - const releaseOldRun = createDeferred(); + const oldRunEntered = createDeferredPromise(); + const releaseOldRun = createDeferredPromise(); const originalOldRunTurn = oldRuntime.runTurn.bind(oldRuntime); oldRuntime.runTurn = async (args) => { oldRunEntered.resolve(); @@ -465,8 +443,8 @@ describe("CommandRouter", () => { providerThreadId: "provider-codex-turn", }); - const stopEntered = createDeferred(); - const releaseStop = createDeferred(); + const stopEntered = createDeferredPromise(); + const releaseStop = createDeferredPromise(); const originalStopThread = harness.runtime.stopThread; harness.runtime.stopThread = async (args) => { stopEntered.resolve(); diff --git a/apps/host-daemon/test/command/dispatch-helpers.ts b/apps/host-daemon/test/command/dispatch-helpers.ts index 5bb04b17fc..576740887c 100644 --- a/apps/host-daemon/test/command/dispatch-helpers.ts +++ b/apps/host-daemon/test/command/dispatch-helpers.ts @@ -6,7 +6,6 @@ import { promisify } from "node:util"; import type { AgentRuntime, AgentRuntimeBridgeLaunch, - AgentRuntimeExecutionOptions, AgentRuntimeProviderSession, } from "@bb/agent-runtime"; import type { @@ -62,11 +61,10 @@ interface FakeWorkspaceState { lastCommitMessage: string | undefined; lastDiffTarget: FakeWorkspaceDiffTarget | undefined; lastPullRequestAction: PullRequestActionOptions | undefined; - listedModelsProviderId: string | undefined; - listedModelsAcpLaunchSpec: HostDaemonAcpLaunchSpec | undefined; + pullRequestActionShellPath: string | undefined; pullRequest: GitHostPullRequest | null; pullRequestLookupError: string | null; - resetCount: number; + pullRequestLookupShellPath: string | undefined; statusReads: number; } @@ -74,7 +72,7 @@ interface FakeWorkspaceState { * Direct mutators for the fake runtime's thread state, replacing what the * deleted RuntimeManager thread bookkeeping used to provide in tests. */ -export interface FakeRuntimeThreadControls { +interface FakeRuntimeThreadControls { clearProviderSession: (threadId: string) => void; endActiveTurn: (threadId: string) => void; setActiveTurn: (threadId: string, turnId: string) => void; @@ -89,21 +87,13 @@ interface FakeRuntimeState { archivedProviderId: string | undefined; archivedProviderThreadId: string | undefined; archivedThreadId: string | undefined; - listedModelsProviderId: string | undefined; - listedModelsAcpLaunchSpec: HostDaemonAcpLaunchSpec | undefined; ranTurnClientRequestId: ClientTurnRequestId | undefined; ranTurnInput: PromptInput[] | undefined; - ranTurnInputGroups: PromptInput[][] | undefined; - ranTurnInstructions: string | undefined; - ranTurnOptions: AgentRuntimeExecutionOptions | undefined; ranTurnText: string | undefined; renamedTitle: string | undefined; - resumedDynamicTools: DynamicTool[] | undefined; resumedAcpLaunchSpec: HostDaemonAcpLaunchSpec | undefined; resumedBridgeLaunch: AgentRuntimeBridgeLaunch | undefined; resumedEnvironmentId: string | undefined; - resumedInstructions: string | undefined; - resumedOptions: AgentRuntimeExecutionOptions | undefined; resumedProviderThreadId: string | undefined; resumedThreadId: string | undefined; runningProviders: string[]; @@ -115,14 +105,10 @@ interface FakeRuntimeState { startedInput: PromptInput[] | undefined; startedInputGroups: PromptInput[][] | undefined; startedInstructions: string | undefined; - startedOptions: AgentRuntimeExecutionOptions | undefined; startedThreadId: string | undefined; steeredClientRequestId: ClientTurnRequestId | undefined; - steeredInput: PromptInput[] | undefined; - steeredInputGroups: PromptInput[][] | undefined; steeredTurnId: string | undefined; steeredTurnInstructions: string | undefined; - steeredTurnOptions: AgentRuntimeExecutionOptions | undefined; stoppedThreadId: string | undefined; unarchivedBridgeLaunch: AgentRuntimeBridgeLaunch | undefined; unarchivedProviderId: string | undefined; @@ -142,13 +128,12 @@ export function createFakeWorkspace(pathname: string) { statusReads: 0, lastDiffTarget: undefined, lastCommitMessage: undefined, - resetCount: 0, destroyed: false, - listedModelsProviderId: undefined, - listedModelsAcpLaunchSpec: undefined, lastPullRequestAction: undefined, + pullRequestActionShellPath: undefined, pullRequest: null, pullRequestLookupError: null, + pullRequestLookupShellPath: undefined, }; const workspace: FakeHostWorkspace = { path: pathname, @@ -224,7 +209,8 @@ export function createFakeWorkspace(pathname: string) { async diffPatch() { return []; }, - async getPullRequest() { + async getPullRequest(options) { + state.pullRequestLookupShellPath = options?.shellPath; if (state.pullRequestLookupError !== null) { return { outcome: "unavailable" as const, @@ -235,11 +221,9 @@ export function createFakeWorkspace(pathname: string) { ? { outcome: "none" as const } : { outcome: "found" as const, pullRequest: state.pullRequest }; }, - async runPullRequestAction(action) { + async runPullRequestAction(action, options) { state.lastPullRequestAction = action; - }, - async listBranches() { - return ["main"]; + state.pullRequestActionShellPath = options?.shellPath; }, async listFiles() { return listFilesRecursively(pathname, pathname); @@ -251,10 +235,7 @@ export function createFakeWorkspace(pathname: string) { commitSubject: options.message, }; }, - async reset() { - state.resetCount += 1; - }, - async fetch() {}, + async reset() {}, async squashMerge(options: { targetBranch: string; commitMessage: string; @@ -280,21 +261,13 @@ export function createFakeRuntime() { archivedProviderId: undefined, archivedProviderThreadId: undefined, archivedThreadId: undefined, - listedModelsProviderId: undefined, - listedModelsAcpLaunchSpec: undefined, ranTurnClientRequestId: undefined, ranTurnInput: undefined, - ranTurnInputGroups: undefined, - ranTurnInstructions: undefined, - ranTurnOptions: undefined, ranTurnText: undefined, renamedTitle: undefined, - resumedDynamicTools: undefined, resumedAcpLaunchSpec: undefined, resumedBridgeLaunch: undefined, resumedEnvironmentId: undefined, - resumedInstructions: undefined, - resumedOptions: undefined, resumedProviderThreadId: undefined, resumedThreadId: undefined, runningProviders: [], @@ -306,14 +279,10 @@ export function createFakeRuntime() { startedInput: undefined, startedInputGroups: undefined, startedInstructions: undefined, - startedOptions: undefined, startedThreadId: undefined, steeredClientRequestId: undefined, - steeredInput: undefined, - steeredInputGroups: undefined, steeredTurnId: undefined, steeredTurnInstructions: undefined, - steeredTurnOptions: undefined, stoppedThreadId: undefined, unarchivedBridgeLaunch: undefined, unarchivedProviderId: undefined, @@ -358,7 +327,6 @@ export function createFakeRuntime() { state.startedDynamicTools = args.dynamicTools; state.startedInput = args.input; state.startedInputGroups = args.inputGroups; - state.startedOptions = args.options; state.startedInstructions = args.instructions; providerSessionsByThreadId.set(args.threadId, { providerId: args.providerId, @@ -380,9 +348,6 @@ export function createFakeRuntime() { state.resumedBridgeLaunch = args.bridgeLaunch; state.resumedEnvironmentId = args.environmentId; state.resumedThreadId = args.threadId; - state.resumedDynamicTools = args.dynamicTools; - state.resumedOptions = args.options; - state.resumedInstructions = args.instructions; state.resumedProviderThreadId = args.providerThreadId; const providerThreadId = args.providerThreadId ?? `provider-${args.threadId}`; @@ -398,17 +363,11 @@ export function createFakeRuntime() { firstInput?.type === "text" ? firstInput.text : undefined; state.ranTurnClientRequestId = args.clientRequestId; state.ranTurnInput = args.input; - state.ranTurnInputGroups = args.inputGroups; - state.ranTurnOptions = args.options; - state.ranTurnInstructions = args.instructions; activeTurnsByThreadId.set(args.threadId, `turn-${nextTurnNumber++}`); }, async steerTurn(args) { state.steeredTurnId = args.expectedTurnId; state.steeredClientRequestId = args.clientRequestId; - state.steeredInput = args.input; - state.steeredInputGroups = args.inputGroups; - state.steeredTurnOptions = args.options; state.steeredTurnInstructions = args.instructions; return { status: "steered" }; }, @@ -464,14 +423,24 @@ export function createFakeRuntime() { hasOpenBackgroundWork() { return false; }, - async listModels(args) { - state.listedModelsProviderId = args.providerId; - state.listedModelsAcpLaunchSpec = args.acpLaunchSpec; + async listModels() { return { models: [] satisfies AvailableModel[], selectedOnlyModels: [] satisfies AvailableModel[], }; }, + async providerHealth() { + return { supported: false as const }; + }, + async providerUsage() { + return { supported: false as const }; + }, + async providerInstallationStatus() { + throw new Error("Unexpected provider installation status call"); + }, + async providerInstallationRun() { + throw new Error("Unexpected provider installation run call"); + }, async shutdown() { state.shutdownCount += 1; }, @@ -583,7 +552,10 @@ export async function cleanupTempDirs(): Promise { export const DISPATCH_TEST_BRIDGE_LAUNCH: HostDaemonBridgeLaunch = { pluginId: "provider-pi", source: { kind: "daemon-bundled", id: "pi" }, + providerOptions: {}, + envPassthrough: [], capabilities: { + experimental_providerInstallation: false, supportsServiceTier: true, permissionModes: ["accept-edits", "auto", "full"], supportsThreadArchive: true, @@ -605,4 +577,6 @@ export const DISPATCH_TEST_RUNTIME_BRIDGE_LAUNCH: AgentRuntimeBridgeLaunch = { dataDir: "/tmp/bb-test-data/plugins/provider-pi/bridge-data", source: { kind: "daemon-bundled", id: "pi" }, capabilities: DISPATCH_TEST_BRIDGE_LAUNCH.capabilities, + providerOptions: {}, + envPassthrough: [], }; diff --git a/apps/host-daemon/test/command/environment-dispatch.test.ts b/apps/host-daemon/test/command/environment-dispatch.test.ts index ba56a04522..5ca0b89f5b 100644 --- a/apps/host-daemon/test/command/environment-dispatch.test.ts +++ b/apps/host-daemon/test/command/environment-dispatch.test.ts @@ -4,6 +4,7 @@ import { WorkspaceError, type HostWorkspace, } from "@bb/host-workspace"; +import { createDeferredPromise } from "@bb/test-helpers"; import { dispatchCommand } from "../../src/command-dispatch.js"; import type { EventSinkInput } from "../../src/event-sink.js"; import { @@ -27,12 +28,6 @@ import { RuntimeManager } from "../../src/runtime-manager.js"; const DEFAULT_TERMINAL_START = { mode: "shell" } as const; -interface Deferred { - promise: Promise; - resolve: (value: TValue | PromiseLike) => void; - reject: (reason?: Error) => void; -} - interface ResizeCall { cols: number; rows: number; @@ -58,16 +53,6 @@ type TerminalExitListener = (event: TerminalPtyExit) => void; afterEach(cleanupTempDirs); -function createDeferred(): Deferred { - let resolve!: Deferred["resolve"]; - let reject!: Deferred["reject"]; - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve; - reject = innerReject; - }); - return { promise, reject, resolve }; -} - class FakeTerminalPty implements TerminalPtyProcess { readonly killCalls: (string | null)[]; readonly resizeCalls: ResizeCall[]; @@ -705,7 +690,7 @@ describe("environment command dispatch", () => { it("waits for terminal closes before destroying an environment", async () => { const harness = createHarness(); - const terminalClose = createDeferred(); + const terminalClose = createDeferredPromise(); const closeEnvironmentTerminals = vi.fn(() => terminalClose.promise); await harness.manager.ensureEnvironment({ environmentId: "env-1", @@ -750,7 +735,7 @@ describe("environment command dispatch", () => { it("waits for in-progress terminal opens to close before destroying an environment", async () => { const harness = createHarness(); - const shell = createDeferred(); + const shell = createDeferredPromise(); let resolveShellCalls = 0; const terminalFixture = createTerminalManager({ manager: harness.manager, diff --git a/apps/host-daemon/test/command/host-branches-dispatch.test.ts b/apps/host-daemon/test/command/host-branches-dispatch.test.ts index 76e499d84c..5d5d3b4aee 100644 --- a/apps/host-daemon/test/command/host-branches-dispatch.test.ts +++ b/apps/host-daemon/test/command/host-branches-dispatch.test.ts @@ -26,6 +26,40 @@ async function initBranchRepo(): Promise { return repoPath; } +async function expectResolvesWithin( + promise: Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout( + () => + reject(new Error(`Promise did not resolve within ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +async function waitForFile(filePath: string): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + try { + await fs.access(filePath); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`File did not appear within 2000ms: ${filePath}`); +} + describe("host.list_branches dispatch", () => { it("lists branches for a git repo and pins the default branch first", async () => { const repoPath = await initBranchRepo(); @@ -241,6 +275,31 @@ describe("host.list_branches dispatch", () => { expect(result.operation).toEqual({ kind: "none" }); }); + it("lists branches for a bare repository root that holds sibling worktrees", async () => { + const origin = await initBranchRepo(); + const root = await makeTempDir("bb-host-branches-bare-root-"); + await runGitCommand(["clone", "--bare", origin, ".bare"], { cwd: root }); + await fs.writeFile(path.join(root, ".git"), "gitdir: ./.bare\n", "utf8"); + await runGitCommand(["worktree", "add", "main", "main"], { cwd: root }); + const harness = createHarness(); + + const result = await dispatchOnlineRpcCommand( + { type: "host.list_branches", path: root, limit: 50 }, + harness.dispatchOptions(), + ); + + expect(result.checkout).toMatchObject({ + kind: "branch", + branchName: "develop", + }); + expect(result.defaultBranch).toBe("main"); + expect(result.branches).toEqual( + expect.arrayContaining(["main", "develop", "release/1.2"]), + ); + expect(result.hasUncommittedChanges).toBe(false); + expect(result.operation).toEqual({ kind: "none" }); + }); + it("returns an empty list for non-git directories", async () => { const dirPath = await makeTempDir("bb-host-branches-nongit-"); const harness = createHarness(); @@ -293,3 +352,140 @@ describe("host.list_branches dispatch", () => { }); }); }); + +describe("host.list_branch_options dispatch", () => { + it("pins local and remote defaults before applying the page limit", async () => { + const repoPath = await initBranchRepo(); + const remotePath = await makeTempDir("bb-host-branch-options-origin-"); + await runGitCommand(["init", "--bare"], { cwd: remotePath }); + await runGitCommand(["remote", "add", "origin", remotePath], { + cwd: repoPath, + }); + await runGitCommand(["branch", "bb/aardvark"], { cwd: repoPath }); + await runGitCommand(["push", "origin", "bb/aardvark", "main"], { + cwd: repoPath, + }); + await runGitCommand(["fetch", "origin"], { cwd: repoPath }); + const harness = createHarness(); + + const result = await dispatchOnlineRpcCommand( + { + type: "host.list_branch_options", + path: repoPath, + limit: 1, + remoteRefresh: "none", + }, + harness.dispatchOptions(), + ); + + expect(result.branches).toEqual(["main"]); + expect(result.branchesTruncated).toBe(true); + expect(result.remoteBranches).toEqual(["origin/main"]); + expect(result.remoteBranchesTruncated).toBe(true); + }); + + it("returns cached refs while a remote refresh continues in the background", async () => { + const repoPath = await initBranchRepo(); + const remotePath = await makeTempDir("bb-host-branch-options-remote-"); + await runGitCommand(["init", "--bare"], { cwd: remotePath }); + await runGitCommand(["remote", "add", "origin", remotePath], { + cwd: repoPath, + }); + await runGitCommand(["push", "origin", "main"], { cwd: repoPath }); + await runGitCommand(["fetch", "origin"], { cwd: repoPath }); + + const cloneParent = await makeTempDir("bb-host-branch-options-clone-"); + const clonePath = path.join(cloneParent, "repo"); + await runGitCommand(["clone", remotePath, clonePath], { cwd: cloneParent }); + await runGitCommand(["config", "user.name", "BB Tests"], { + cwd: clonePath, + }); + await runGitCommand(["config", "user.email", "bb@example.com"], { + cwd: clonePath, + }); + await runGitCommand(["switch", "-c", "feature/remote-only"], { + cwd: clonePath, + }); + await fs.writeFile(path.join(clonePath, "remote.txt"), "remote\n", "utf8"); + await runGitCommand(["add", "."], { cwd: clonePath }); + await runGitCommand(["commit", "-m", "Remote branch"], { cwd: clonePath }); + await runGitCommand(["push", "origin", "feature/remote-only"], { + cwd: clonePath, + }); + + const refreshStartedPath = path.join(repoPath, "refresh-started"); + const releaseRefreshPath = path.join(repoPath, "release-refresh"); + const uploadPackPath = path.join(repoPath, "delayed-upload-pack.sh"); + await fs.writeFile( + uploadPackPath, + `#!/bin/sh\ntouch ${JSON.stringify(refreshStartedPath)}\nwhile [ ! -f ${JSON.stringify(releaseRefreshPath)} ]; do sleep 0.01; done\nexec git-upload-pack "$@"\n`, + { encoding: "utf8", mode: 0o755 }, + ); + await runGitCommand( + ["config", "remote.origin.uploadpack", uploadPackPath], + { + cwd: repoPath, + }, + ); + const harness = createHarness(); + + const resultPromise = dispatchOnlineRpcCommand( + { + type: "host.list_branch_options", + path: repoPath, + query: "remote-only", + selectedBranch: "origin/feature/remote-only", + limit: 50, + remoteRefresh: "background", + }, + harness.dispatchOptions(), + ); + + try { + const result = await expectResolvesWithin(resultPromise, 2_000); + expect(result).toEqual({ + branches: [], + branchesTruncated: false, + remoteBranches: [], + remoteBranchesTruncated: false, + selectedBranch: { + kind: "missing", + name: "origin/feature/remote-only", + }, + }); + await waitForFile(refreshStartedPath); + } finally { + await fs.writeFile(releaseRefreshPath, "release\n", "utf8"); + } + + const refreshed = await dispatchOnlineRpcCommand( + { type: "host.list_branches", path: repoPath, limit: 50 }, + harness.dispatchOptions(), + ); + expect(refreshed.remoteBranches).toContain("origin/feature/remote-only"); + }); + + it("does not start a remote refresh when the caller opts out", async () => { + const repoPath = await initBranchRepo(); + const harness = createHarness(); + + const result = await dispatchOnlineRpcCommand( + { + type: "host.list_branch_options", + path: repoPath, + selectedBranch: "main", + limit: 1, + remoteRefresh: "none", + }, + harness.dispatchOptions(), + ); + + expect(result).toEqual({ + branches: ["main"], + branchesTruncated: true, + remoteBranches: [], + remoteBranchesTruncated: false, + selectedBranch: { kind: "local", name: "main" }, + }); + }); +}); diff --git a/apps/host-daemon/test/command/thread-dispatch.test.ts b/apps/host-daemon/test/command/thread-dispatch.test.ts index 38cd2e09ec..8283d5059c 100644 --- a/apps/host-daemon/test/command/thread-dispatch.test.ts +++ b/apps/host-daemon/test/command/thread-dispatch.test.ts @@ -87,7 +87,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -142,7 +142,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -214,7 +214,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -316,7 +316,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -375,7 +375,9 @@ describe("thread command dispatch", () => { const bridgeBytes = Buffer.from("export const bridge = true;\n"); const sha256 = createHash("sha256").update(bridgeBytes).digest("hex"); const harness = createHarness({ workspacePath: "/tmp/env-bridge-start" }); - const fetchPluginHostArtifact = vi.fn(async () => new Uint8Array(bridgeBytes)); + const fetchPluginHostArtifact = vi.fn( + async () => new Uint8Array(bridgeBytes), + ); await dispatchCommand( { @@ -390,12 +392,15 @@ describe("thread command dispatch", () => { providerId: "echo-agent", bridgeLaunch: { pluginId: "provider-echo", + providerOptions: {}, + envPassthrough: [], source: { kind: "artifact", digest: sha256, byteLength: bridgeBytes.byteLength, }, capabilities: { + experimental_providerInstallation: false, supportsServiceTier: false, permissionModes: ["full"] as const, supportsThreadArchive: false, @@ -409,7 +414,7 @@ describe("thread command dispatch", () => { model: "echo-default", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -436,8 +441,11 @@ describe("thread command dispatch", () => { expect(harness.runtimeState.startedBridgeLaunch).toEqual({ pluginId: "provider-echo", dataDir: path.join(dataDir, "plugins", "provider-echo", "bridge-data"), + providerOptions: {}, + envPassthrough: [], source: { kind: "artifact", digest: sha256, artifactPath }, capabilities: { + experimental_providerInstallation: false, supportsServiceTier: false, permissionModes: ["full"], supportsThreadArchive: false, @@ -454,15 +462,20 @@ describe("thread command dispatch", () => { const bridgeBytes = Buffer.from("export const archiveBridge = true;\n"); const sha256 = createHash("sha256").update(bridgeBytes).digest("hex"); const harness = createHarness({ workspacePath: "/tmp/env-bridge-archive" }); - const fetchPluginHostArtifact = vi.fn(async () => new Uint8Array(bridgeBytes)); + const fetchPluginHostArtifact = vi.fn( + async () => new Uint8Array(bridgeBytes), + ); const bridgeLaunch: HostDaemonBridgeLaunch = { pluginId: "provider-echo", + providerOptions: {}, + envPassthrough: [], source: { kind: "artifact", digest: sha256, byteLength: bridgeBytes.byteLength, }, capabilities: { + experimental_providerInstallation: false, supportsServiceTier: false, permissionModes: ["full"], supportsThreadArchive: true, @@ -477,14 +490,17 @@ describe("thread command dispatch", () => { kind: "artifact" as const, digest: sha256, artifactPath: path.join( - dataDir, - "plugin-host-artifacts", - "provider-echo", - sha256, - "host.mjs", - ), + dataDir, + "plugin-host-artifacts", + "provider-echo", + sha256, + "host.mjs", + ), }, + providerOptions: {}, + envPassthrough: [], capabilities: { + experimental_providerInstallation: false, supportsServiceTier: false, permissionModes: ["full"], supportsThreadArchive: true, @@ -534,7 +550,9 @@ describe("thread command dispatch", () => { const bridgeBytes = Buffer.from("export const resumeBridge = true;\n"); const sha256 = createHash("sha256").update(bridgeBytes).digest("hex"); const harness = createHarness({ workspacePath: "/tmp/env-bridge-resume" }); - const fetchPluginHostArtifact = vi.fn(async () => new Uint8Array(bridgeBytes)); + const fetchPluginHostArtifact = vi.fn( + async () => new Uint8Array(bridgeBytes), + ); await dispatchCommand( { @@ -548,7 +566,7 @@ describe("thread command dispatch", () => { model: "echo-default", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -564,12 +582,15 @@ describe("thread command dispatch", () => { providerThreadId: "provider-bridge-resume", bridgeLaunch: { pluginId: "provider-echo", + providerOptions: {}, + envPassthrough: [], source: { kind: "artifact", digest: sha256, byteLength: bridgeBytes.byteLength, }, capabilities: { + experimental_providerInstallation: false, supportsServiceTier: false, permissionModes: ["full"] as const, supportsThreadArchive: false, @@ -597,14 +618,17 @@ describe("thread command dispatch", () => { kind: "artifact" as const, digest: sha256, artifactPath: path.join( - dataDir, - "plugin-host-artifacts", - "provider-echo", - sha256, - "host.mjs", - ), + dataDir, + "plugin-host-artifacts", + "provider-echo", + sha256, + "host.mjs", + ), }, + providerOptions: {}, + envPassthrough: [], capabilities: { + experimental_providerInstallation: false, supportsServiceTier: false, permissionModes: ["full"], supportsThreadArchive: false, @@ -658,7 +682,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -719,7 +743,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -783,7 +807,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -845,7 +869,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -923,7 +947,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -995,7 +1019,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1057,7 +1081,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1112,7 +1136,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1173,7 +1197,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1279,7 +1303,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1379,7 +1403,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1424,7 +1448,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1515,7 +1539,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1551,7 +1575,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1610,7 +1634,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1650,7 +1674,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1706,7 +1730,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1772,7 +1796,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1832,7 +1856,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1886,7 +1910,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -1934,7 +1958,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -2032,7 +2056,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -2180,7 +2204,7 @@ describe("thread command dispatch", () => { model: "claude-opus-4-7", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -2240,7 +2264,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -2280,7 +2304,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -2320,7 +2344,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, diff --git a/apps/host-daemon/test/command/thread-stop-races.test.ts b/apps/host-daemon/test/command/thread-stop-races.test.ts index e8a2d758ac..054102b467 100644 --- a/apps/host-daemon/test/command/thread-stop-races.test.ts +++ b/apps/host-daemon/test/command/thread-stop-races.test.ts @@ -1,21 +1,27 @@ +import { readFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { writeFile } from "node:fs/promises"; -import type { - AgentRuntime, - AgentRuntimeProcessExitInfo, +import { fileURLToPath } from "node:url"; +import { + createAgentRuntime, + type AgentRuntime, + type AgentRuntimeProcessExitInfo, } from "@bb/agent-runtime"; import { - createAgentRuntimeWithAdapters, - createFakeAdapter, - type ProviderAdapter, - type ProviderAdapterFactory, + createScriptedEchoRequestRecord, + type ScriptedEchoLaunchScript, + type ScriptedEchoRequestRecord, } from "@bb/agent-runtime/test"; +import { buildPluginHost, resolvePluginBuildToolchain } from "@bb/plugin-build"; import { encodeClientTurnRequestIdNumber, type ClientTurnRequestId, type ThreadEvent, } from "@bb/domain"; -import type { HostDaemonOnlineRpcResponseMessage } from "@bb/host-daemon-contract"; +import type { + HostDaemonBridgeLaunch, + HostDaemonOnlineRpcResponseMessage, +} from "@bb/host-daemon-contract"; import { afterEach, describe, expect, it, vi } from "vitest"; import { dispatchCommand } from "../../src/command-dispatch.js"; import { @@ -31,33 +37,28 @@ import { makeDispatchOptions, makeTempDir, unexpectedProjectAttachmentFetch, - DISPATCH_TEST_BRIDGE_LAUNCH, } from "./dispatch-helpers.js"; /** * Race coverage for the thread.stop dispatch flow against the REAL agent - * runtime (fake provider adapter, real provider subprocess): the stop wait is - * event-driven via runtime.waitForActiveTurn, crash clearing is owned by the - * runtime, and repeated stops are idempotent. + * runtime (the scripted echo bridge, a real provider subprocess behind the + * real bridge-protocol adapter): the stop wait is event-driven via + * runtime.waitForActiveTurn, crash clearing is owned by the runtime, and + * repeated stops are idempotent. */ const ENVIRONMENT_ID = "env-stop-race"; const THREAD_STOP_ACTIVE_TURN_WAIT_MS = 5_000; -type RecordedAdapterCommand = Parameters< - ProviderAdapter["buildCommandPlan"] ->[0]; - -interface RaceHarnessArgs { - adapterFactory?: ProviderAdapterFactory; -} - interface RaceHarness { dispatchOptions: CommandDispatchOptions; events: ThreadEvent[]; exits: AgentRuntimeProcessExitInfo[]; + /** The scripted echo bridge launch every command in this harness carries. */ + launch: HostDaemonBridgeLaunch; manager: RuntimeManager; - recordedCommands: RecordedAdapterCommand[]; + /** Every request the bridge processes handled (the provider's view). */ + record: ScriptedEchoRequestRecord; requireRuntime: () => AgentRuntime; workspacePath: string; } @@ -66,6 +67,7 @@ interface ThreadStartArgs { threadId: string; providerId?: string; inputText?: string; + bridgeLaunch?: HostDaemonBridgeLaunch; } interface TurnSubmitArgs { @@ -73,48 +75,6 @@ interface TurnSubmitArgs { inputText: string; } -const CRASH_MID_TURN_PROVIDER_SCRIPT = ` -const readline = require("node:readline"); - -function send(message) { - process.stdout.write(JSON.stringify(message) + "\\n"); -} - -const rl = readline.createInterface({ input: process.stdin }); -rl.on("line", (line) => { - const message = JSON.parse(line); - if (message.method === "initialize") { - send({ jsonrpc: "2.0", id: message.id, result: { ok: true } }); - return; - } - if (message.method === "thread/start") { - const threadId = message.params.threadId; - send({ - jsonrpc: "2.0", - id: message.id, - result: { providerThreadId: "prov-crash" }, - }); - send({ - jsonrpc: "2.0", - method: "thread/identity", - params: { threadId, providerThreadId: "prov-crash" }, - }); - return; - } - if (message.method === "turn/start") { - const threadId = message.params.threadId; - send({ jsonrpc: "2.0", id: message.id, result: { ok: true } }); - send({ - jsonrpc: "2.0", - method: "turn/started", - params: { threadId, turnId: "turn-1", providerThreadId: "prov-crash" }, - }); - // Die mid-turn, after the turn/started handoff has been flushed. - setTimeout(() => process.exit(1), 50); - } -}); -`; - const managers: RuntimeManager[] = []; let nextClientRequestIdValue = 1; let nextRpcRequestIdValue = 1; @@ -133,19 +93,6 @@ function nextClientRequestId(): ClientTurnRequestId { return requestId; } -function withRecordedCommands( - adapter: ProviderAdapter, - recordedCommands: RecordedAdapterCommand[], -): ProviderAdapter { - return { - ...adapter, - buildCommandPlan(command) { - recordedCommands.push(command); - return adapter.buildCommandPlan(command); - }, - }; -} - /** Lets queued microtasks (the dispatch chain up to its turn waiter) run. */ function flushMicrotasks(): Promise { return new Promise((resolve) => { @@ -153,27 +100,82 @@ function flushMicrotasks(): Promise { }); } -async function createRaceHarness( - args: RaceHarnessArgs = {}, -): Promise { +/** + * The scripted echo bridge as the daemon receives a plugin provider: a built + * `bb.host` artifact named by digest and byte length on the wire, fetched and + * hash-verified into the daemon's cache before the bootstrap imports it. + * Built once per file from source, like the plugin runtime builds it. + */ +let scriptedEchoArtifact: Promise<{ + bytes: Uint8Array; + digest: string; +}> | null = null; + +function buildScriptedEchoArtifact(): Promise<{ + bytes: Uint8Array; + digest: string; +}> { + scriptedEchoArtifact ??= (async () => { + const rootDir = fileURLToPath( + new URL("../../../../tests/scripted-echo-provider", import.meta.url), + ); + const toolchain = await resolvePluginBuildToolchain( + path.join(os.tmpdir(), "bb-plugin-build-toolchain"), + ); + const build = await buildPluginHost(rootDir, "0.0.0-test", toolchain); + return { + bytes: await readFile(build.jsPath), + digest: build.artifactDigest, + }; + })(); + return scriptedEchoArtifact; +} + +/** + * The launch the dispatch commands carry, the way the server attaches a + * plugin provider's artifact. `scripted` rides `providerOptions` like any + * provider-owned static. + */ +async function scriptedEchoDispatchLaunch( + options: { pluginId?: string; scripted?: ScriptedEchoLaunchScript } = {}, +): Promise { + const artifact = await buildScriptedEchoArtifact(); + return { + pluginId: options.pluginId ?? "provider-scripted-echo", + source: { + kind: "artifact", + digest: artifact.digest, + byteLength: artifact.bytes.byteLength, + }, + providerOptions: + options.scripted === undefined + ? {} + : { scripted: JSON.parse(JSON.stringify(options.scripted)) }, + envPassthrough: [], + capabilities: { + experimental_providerInstallation: false, + supportsServiceTier: false, + permissionModes: ["accept-edits", "auto", "full"], + supportsThreadArchive: true, + supportsThreadRename: true, + fork: "checkpoint", + }, + }; +} + +async function createRaceHarness(): Promise { const workspacePath = await makeTempDir("bb-stop-race-workspace-"); const events: ThreadEvent[] = []; const exits: AgentRuntimeProcessExitInfo[] = []; - const recordedCommands: RecordedAdapterCommand[] = []; - const adapterFactory: ProviderAdapterFactory = - args.adapterFactory ?? (() => createFakeAdapter()); + const record = createScriptedEchoRequestRecord(); let runtime: AgentRuntime | null = null; const manager = new RuntimeManager({ provisionWorkspace: async () => createFakeWorkspace(workspacePath).workspace, createRuntime: (options) => { - runtime = createAgentRuntimeWithAdapters({ + runtime = createAgentRuntime({ ...options, - adapterFactory: (providerId, factoryOptions) => - withRecordedCommands( - adapterFactory(providerId, factoryOptions), - recordedCommands, - ), + env: { ...options.env, ...record.env }, }); return runtime; }, @@ -186,12 +188,26 @@ async function createRaceHarness( }); managers.push(manager); + const artifact = await buildScriptedEchoArtifact(); + const dataDir = await makeTempDir("bb-stop-race-daemon-data-"); return { - dispatchOptions: makeDispatchOptions({ runtimeManager: manager }), + dispatchOptions: makeDispatchOptions({ + runtimeManager: manager, + dataDir, + // The daemon's artifact fetcher, serving the built scripted echo bridge + // for whichever plugin id a launch names. + fetchPluginHostArtifact: async ({ digest }) => { + if (digest !== artifact.digest) { + throw new Error(`unknown plugin host artifact ${digest}`); + } + return artifact.bytes; + }, + }), events, exits, + launch: await scriptedEchoDispatchLaunch(), manager, - recordedCommands, + record, requireRuntime: () => { if (!runtime) { throw new Error("Runtime has not been created yet"); @@ -207,7 +223,7 @@ function threadStartCommand( args: ThreadStartArgs, ): CommandOf<"thread.start"> { return { - bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + bridgeLaunch: args.bridgeLaunch ?? harness.launch, type: "thread.start", environmentId: ENVIRONMENT_ID, threadId: args.threadId, @@ -226,7 +242,7 @@ function threadStartCommand( model: "fake-model", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, @@ -244,7 +260,7 @@ function turnSubmitCommand( args: TurnSubmitArgs, ): CommandOf<"turn.submit"> { return { - bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + bridgeLaunch: harness.launch, type: "turn.submit", environmentId: ENVIRONMENT_ID, threadId: args.threadId, @@ -254,14 +270,14 @@ function turnSubmitCommand( model: "fake-model", serviceTier: "default", reasoningLevel: "medium", - workflowsEnabled: false, + providerOptions: {}, permissionMode: "full", permissionScope: "full", approvalReviewer: null, permissionEscalation: null, }, resumeContext: { - bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + bridgeLaunch: harness.launch, workspaceContext: { workspacePath: harness.workspacePath, workspaceProvisionType: "unmanaged", @@ -287,10 +303,12 @@ function threadStopCommand(threadId: string): CommandOf<"thread.stop"> { }; } -function recordedThreadStops(harness: RaceHarness): RecordedAdapterCommand[] { - return harness.recordedCommands.filter( - (command) => command.type === "thread/stop", - ); +/** The `thread/stop` requests that reached a bridge process, in order. */ +function recordedThreadStops(harness: RaceHarness): Record[] { + return harness.record + .read() + .filter((request) => request.method === "thread/stop") + .map((request) => request.params ?? {}); } function routerStop( @@ -337,10 +355,12 @@ describe("thread.stop race semantics", () => { await expect(stopPromise).resolves.toEqual({ providerCheckpointId: null }); await expect(submitPromise).resolves.toEqual({ appliedAs: "new-turn" }); + // The wire carries the bridge's own turn id, reverse-mapped by the + // adapter from the assembler-minted id the runtime tracks. expect(recordedThreadStops(harness)).toEqual([ expect.objectContaining({ - type: "thread/stop", threadId: "t-race", + intent: "interrupt", activeTurnId: "turn-1", }), ]); @@ -379,8 +399,8 @@ describe("thread.stop race semantics", () => { // The stop reached the provider as a no-turn stop and released the thread. expect(recordedThreadStops(harness)).toEqual([ expect.objectContaining({ - type: "thread/stop", threadId: "t-idle", + intent: "release", activeTurnId: null, }), ]); @@ -391,14 +411,12 @@ describe("thread.stop race semantics", () => { }); it("clears the active turn when the provider crashes mid-turn so a later stop noops", async () => { - const crashDir = await makeTempDir("bb-stop-race-crash-"); - const crashScriptPath = path.join(crashDir, "crash-mid-turn-provider.cjs"); - await writeFile(crashScriptPath, CRASH_MID_TURN_PROVIDER_SCRIPT, "utf8"); - const harness = await createRaceHarness({ - adapterFactory: (providerId) => - providerId === "crasher" - ? createFakeAdapter({ id: "crasher", scriptPath: crashScriptPath }) - : createFakeAdapter(), + const harness = await createRaceHarness(); + // A second provider whose bridge dies mid-turn: it acknowledges the turn + // (turn/started reaches the runtime) and then exits. + const crasherLaunch = await scriptedEchoDispatchLaunch({ + pluginId: "provider-crasher", + scripted: { exitAfter: "turn/start" }, }); // A healthy sibling provider keeps the environment entry alive across // the crash, so the follow-up stop exercises the dispatch path. @@ -411,6 +429,7 @@ describe("thread.stop race semantics", () => { threadId: "t-crash", providerId: "crasher", inputText: "boom", + bridgeLaunch: crasherLaunch, }), harness.dispatchOptions, ); @@ -431,8 +450,8 @@ describe("thread.stop race semantics", () => { expect(crashExit?.threads).toEqual([ expect.objectContaining({ threadId: "t-crash", - providerThreadId: "prov-crash", - activeTurnId: "turn-1", + providerThreadId: "prov-1", + activeTurnId: expect.any(String), }), ]); // The runtime's own exit handling is the only clearing of that state. @@ -474,7 +493,7 @@ describe("thread.stop race semantics", () => { const runtime = harness.requireRuntime(); await vi.waitFor( () => { - expect(runtime.getActiveTurnId("t-double")).toBe("turn-1"); + expect(runtime.getActiveTurnId("t-double")).not.toBeNull(); }, { timeout: 5_000 }, ); diff --git a/apps/host-daemon/test/command/workspace-dispatch.test.ts b/apps/host-daemon/test/command/workspace-dispatch.test.ts index dfb2c1e06c..47b40e11a9 100644 --- a/apps/host-daemon/test/command/workspace-dispatch.test.ts +++ b/apps/host-daemon/test/command/workspace-dispatch.test.ts @@ -136,6 +136,9 @@ describe("workspace command dispatch", () => { it("covers workspace.pull_request", async () => { const harness = createHarness(); + await harness.manager.replaceBaseShellEnv({ + PATH: "/Users/test/.local/bin:/usr/bin", + }); await harness.manager.ensureEnvironment({ environmentId: "env-1", workspacePath: "/tmp/env-1", @@ -170,6 +173,9 @@ describe("workspace command dispatch", () => { harness.dispatchOptions(), ); expect(presentResult).toEqual({ outcome: "available", pullRequest }); + expect(harness.workspaceState.pullRequestLookupShellPath).toBe( + "/Users/test/.local/bin:/usr/bin", + ); harness.workspaceState.pullRequest = null; const absentResult = await dispatchOnlineRpcCommand( @@ -246,6 +252,9 @@ describe("workspace command dispatch", () => { it("covers workspace.pull_request_action", async () => { const harness = createHarness({ isWorktree: true }); + await harness.manager.replaceBaseShellEnv({ + PATH: "/Users/test/.local/bin:/usr/bin", + }); await harness.manager.ensureEnvironment({ environmentId: "env-1", workspacePath: "/tmp/env-1", @@ -268,6 +277,9 @@ describe("workspace command dispatch", () => { expect(harness.workspaceState.lastPullRequestAction).toEqual({ operation: "ready", }); + expect(harness.workspaceState.pullRequestActionShellPath).toBe( + "/Users/test/.local/bin:/usr/bin", + ); await expect( dispatchCommand( diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000000..87c3d6f7b6 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,43 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android +# `expo run:ios --output` product (CI installs it with simctl). +/build-output diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000000..b22f774cf6 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,932 @@ +# @bb/mobile + +Native iOS/Android client for bb (Expo SDK 57, React Native 0.86, Expo +Router, NativeWind v5). Plan and decisions: `plans/bb-mobile-expo.md`. + +Status: Phase 7 (settings, machines, updates, plugins, skills, share, +haptics, CI) — M4, the last build milestone, over Direct mode and bb connect; +M5 (SPA-in-WebView plugin surfaces, tablet layout, store releases) is +deferred. Direct-mode and bb connect server profiles (QR / code pairing, desktop-session +cookie, re-pair), the app shell (root stack, connection banner, settings), +theme/design system, the per-profile SDK/realtime/query layer, the grouped +thread list (home, long-press menus, organize/sort, drag-to-reorder +sections per organize mode under the web localStorage keys, search, archived), +thread creation on the shared composer (mentions, attachments, voice, fork / +handoff seeds), the thread detail screen (`/threads/[id]`: the virtualized +timeline with every row kind, markdown, inline diffs, terminal output, images + +lightbox, sticky-bottom, older pages, unread divider; the +prompt area with pending-interaction banners, the prompt chip row, the context +banner, the queued-message list and the follow-up composer; header / message / +git action sheets), deep links, and the workspace panel +(a bottom sheet with Info · Diff · Files · Terminal + the thread's synced file +tabs: thread metadata, the batched Diff tab with "Add to chat", file search / +thread-storage browser / previews for text, markdown, CSV, HTML, images, the +xterm terminal in a WebView with an accessory key bar, full-screen file and +terminal routes, and the same panel on the compose screen for project files +and a host terminal), the settings buckets (General / Appearance / +Experiments / Haptics, provider settings, usage limits, Machines with pairing / +rename / permission ceiling / provider CLI installs, Updates, Plugins with +detail / settings form / logs / catalog / marketplaces, Skills library + +skills.sh registry, Discord / GitHub), "Share link" from the +thread menu, and the `Mobile E2E` GitHub workflow are in place. The Phase 0 +spikes and the renderer / composer / interactions showcases live under +Settings → Developer. + +## Structure + +``` +app/ Expo Router routes (thin: each file re-exports a screen) + _layout.tsx providers: GestureHandlerRootView › SafeAreaProvider › + KeyboardProvider › PaletteProvider › ThemeProvider › + ProfilesProvider (QueryClientProvider per profile) › + SheetProvider › RootNavigator + Toaster + index.tsx home: the thread list + compose dock; the workspace menu (server + switcher / archived / Settings) opens from the header avatar + threads/[id].tsx thread detail (Phase 4a, read-only); threads/search.tsx; + threads/[id]/terminal/ (index: the thread's terminals, + [terminalId]: one terminal full screen, any orientation); + threads/[id]/files (the Files tab full screen, or a file + preview for ?kind=&path=&line=[&source=&status=]) + settings/ index (the settings buckets), servers/ (list), + servers/add (bb connect entry + Direct mode form), + archived (archived threads), server (server status + card), general, appearance, experiments, + providers/[providerId] (codex | claude-code), usage + (usage limits), updates (bb + provider CLIs + CLI + skills), machines/ (index: the paired machines + the + add-machine sheet, [hostId]: one machine), plugins/ + (index: installed plugins, browse: the catalog, + [pluginId]/index: one plugin, [pluginId]/logs: its + log tail), marketplaces, skills/ (index: the library, + [skillId]: one skill read-only, registry/index: + skills.sh browse, registry/[registrySkillId]: one + registry skill + install) + connect/index.tsx bb connect enrollment (QR / code) — also the re-pair + target (`?profileId=`) and the `bb://connect?code=…` link + (the new-thread composer is the home screen's bottom + dock: `/?projectId=§ionId=&initialPrompt=&reuseEnvironmentId=` + + the fork / handoff seed params open it) + projects/ new (create project), [id]/settings (rename, sources, delete), + [id]/threads/[threadId] (web deep-link alias → threads/[id]) + dev/ ui (gallery), diff (diff + terminal showcase), markdown + (markdown showcase), work-rows (timeline work-row renderers + on synthetic rows), interactions (pending-interaction + banners + queued list on synthetic payloads, plus a + "Live thread" section for any thread id), spike, + connect-spike (Phase 0 diagnostics). Dev / + EXPO_PUBLIC_BB_E2E=1 only: release bundles redirect + them (and bb://dev/* links) home + e2e/reset.tsx bb://e2e/reset — wipes local state (dev / EXPO_PUBLIC_BB_E2E=1) + +native-intent.tsx redirectSystemPath: every incoming URL (bb:// scheme, + universal links, dev-client URLs) → src/lib/links + resolution → profile switch + route / add-server prompt +src/ + app-shell/ RN glue: ProfilesProvider + hooks (useProfiles, + useProfileClient, useRealtimeConnectionState, + useConnectionBanner), useAppBoot, PaletteProvider, + client-registry (per-profile clients + the global + mutation-error toast), e2e reset wiring, + waitForActiveConnection, + ThreadOpenSignalHandler (realtime `thread-open` → + navigate, like the web's wsManager.onThreadOpen) + notifications/ push notifications arrive in a later PR (RN glue: + expo-notifications behind the data-layer contract, + registration sync, taps → thread, badge, Settings rows) + screens/ screen components (home/ — thread list + the + new-thread ComposeDock; compose/ — ComposeDock, + useComposeController; settings/, shell/, connect/ — bb connect enrollment: ConnectEnrollScreen, + ConnectScanner (expo-camera QR), AccountServersList; + projects/, pickers/ — reusable picker sheets: project, + provider, model+reasoning, permission mode, environment, + machine, branch, folder, remote path browser; + sidebar/ — grouped thread list (FlashList), rows, status + glyph, long-press action sheets, display options; + threads/ — search, archived; + thread/ — thread detail: ThreadDetailScreen (list + + prompt area inside KeyboardPaddingView), the native + header pieces (title + status subtitle, panel + "…"), + cards/ (PromptChip + the prompt chip row: workflows, + background tasks (glyph shimmers while live), plan + + Exit, goal + Clear, to-dos, model fallback, plus the + context/ chips, each a pill that opens a detail + sheet; context-window ring), prompt-area/ + (ThreadPromptArea: banner-or-stack + the follow-up + Composer; useFollowUpComposer: draft, submit mode, + send / queue / steer / stop, edit modes, quoting; + useThreadExecutionOptions: thread defaults → pills → + execution overrides; pure follow-up-submission.ts), + and timeline/ (FlashList TimelineList with + memoized cells, rows.ts list model — flat items with + kind/depth/parentKind/expanded + identity cache —, + renderers.ts registry + fallback, TimelineTitleView, + sticky-bottom + unread-divider policies; renderers/ + index.ts registers every row renderer once (dev warns + on a missing kind): conversation/ (authored bubble, + generated "Message from …" row, assistant markdown, + attachments), system/, turn/, summaries/, work/ + (WorkRowShell over the shared header), shared/ + (TimelineRowShell, ExpandableRowHeader, past-row dim); + host/ TimelineRowHostProvider (server URL, sender + metadata, thread navigation, image lightbox, + long-press message actions); lightbox/); + context/ — ThreadContextChips (related-thread chip, + child threads / needs-input chip, pull request chip + + Mark ready / merge methods, changed-files chip → sheet + with WorkspaceChangesList, merge base → + MergeBasePickerSheet, Open diff; archived / + environment-gone status chip), use-thread-context-chips.ts + (data assembly), pure context-model.ts; + actions/ — MessageActionSheet + message-actions-model + (copy / quote paragraph / add to chat / edit / fork / + send to main), useMessageActionHandlers (fork → + home dock seed, side-chat send-to-main), + ThreadActionsSheet (header "…" menu: handoff, new + thread in worktree, rename, pin, read state, move, + copy link, open in web, archive, delete), + ThreadGitActionSheet + useThreadGitActions (commit / + squash merge through the environment actions); + interactions/ — PendingInteractionBanner (approval / + user question / ask-user-question + secret-request + plugin forms / unsupported-plugin card), QuestionForm, + SecretRequestForm; + queue/ — QueuedMessagesList (send now, edit via + onEdit, move up/down, group toggle, delete); + dev/ — renderer showcases (markdown, work rows) + + fixtures; shell/hrefs.ts — typed-route boundary; + panel/ — the workspace panel (Phase 6): a 92% bottom + sheet with the tab strip (Info · Diff · Files · + Terminal + the thread's synced closable file tabs), + WorkspacePanelProvider + usePanel() controller, + the tab-content registry (registerPanelTabContent / + registerPanelLauncherContent; Diff / Files / Terminal + register their contents), ThreadInfoTabContent, the + thread + root-compose providers; see panel/README.md); + diff-tab/ — the panel's Diff tab: DiffTabContent + (header with target picker + merge base, file-count + and +/- pills, collapse-all, refresh; FlashList of + DiffTabFileCard over @/diff DiffFileCard with + skeleton / "Load diff" / too large / error bodies + and per-file "Add to chat"), DiffTargetPickerSheet, + register.tsx (the `git-diff` panel registration: + scroll-to path, close-then-quote into the thread's + composer host via the `quoteIntoComposer` prop); + terminal/ — the terminal (Phase 6): TerminalView + (xterm in a react-native-webview page + the RN-owned + attach socket), TerminalTabContent (terminal + + accessory key bar + not-running card), + TerminalAccessoryBar (esc / tab / sticky ctrl / + arrows / home / end / - / | / paste / keyboard / …), + TerminalSessionsList, panel-contents.tsx + + register.ts (the `terminal` tab kind and launcher), + TerminalScreen (`/threads/[id]/terminal/[terminalId]`, + any orientation) + ThreadTerminalsScreen, pure + terminal-bridge.ts / terminal-stream.ts / + terminal-scope.ts / terminal-theme.ts, and + page/terminal-page.ts (the WebView page source) + files/ — Files tab + file previews (Phase 6): + FilesTabContent (search box → Workspace files / + Thread storage sections with match highlights; + idle: Recent files + the thread-storage browser with + breadcrumbs; long-press → copy path / name), + FilePreviewView (header: name, source pill, size, + tappable path, Preview/Source toggle, jump to line, + open in browser, reload; bodies: TextFilePreviewBody + — virtualized mono lines with numbers, horizontal + scroll, line-range highlight, long-press line → Add + to chat / Copy line / Copy path:line; markdown via + @/markdown with sibling links + images; CSV grid; + HTML in a WebView on the CSP-sandboxed raw route; + image + lightbox; video hand-off card; loading / + not-found / too-large / error / empty / binary), + FilePreviewScreen (`/threads/[id]/files`), + file-preview-target.ts (target union ↔ route + params), file-opener.tsx (useThreadFileOpener: + context override → workspace panel tab → route; + records recents), use-thread-local-file-links.tsx + (absolute `/path[:line]` → workspace root → thread + storage → host file; relative references → root + picker), panel-contents.tsx + register.ts (the + "files" launcher + the three preview tab kinds) + data/ TanStack Query hooks + pure helpers per area (diff — + the Diff tab's data: diff/files TOC query, the + viewport-driven batched diff/patch loader, the target + selection over the merge base, the collapse store, + the add-to-chat patch text; thread-tabs — + GET/PUT /threads/:id/tabs sync: per-profile write + queue with 409 rebase-and-retry, MMKV fixed-panel + state store, useSyncedPanelTabs; connect — + pairing payload parsing, enrollment target resolution, + redeem + error copy, account servers hook; sidebar, + threads, thread-detail, projects, sections, hosts, + system, compose, environments — record, workspace + status + merge base, pull request, git / PR actions + with 409 "blocked" toasts —, interactions — resolve/respond/cancel + pending interactions, question form state, plugin + payload parsing, child-thread attention —, thread-runtime + — send/edit/stop/cancel-plan/clear-goal + the queued + message CRUD with optimistic transactions; + notifications — push registration policy — arrives in + a later PR); see src/data/README.md + lib/ pure TypeScript, vitest-tested (no react-native imports) + profiles/ ServerProfile model, SecureStore-backed store, URL + validation, /health + /system/config probe + sdk/ createMobileSdk (@bb/sdk/browser + app-surface header), + per-profile client registry + realtime/ WebSocketManager-shaped realtime on RN WebSocket + query/ query keys, per-profile QueryClient, AppState focus, + realtime → query invalidation (+ the observer-less + diff-patch cache it evicts on workspace events) + session/ bb connect desktop-session cookie scheduler (Phase 5) + connection/ active-profile connector (socket + session lifecycle), + connection banner derivation + e2e/ launch/deep-link reset logic + links/ incoming-link resolution: bb:// scheme + web/universal + links → mobile route, profile match, add-server prompt + native/ RN adapters for the lib contracts (SecureStore, + cookies, AppState) — never imported by tested modules + diff/ native unified-diff renderer: parse-unified-diff (parse-git-diff + wrapped in our DiffFile/DiffHunk/DiffLine types, tolerant of + bare/synthetic patches, binaries, renames), file-change-diff + (TimelineFileChange → diff | plain | none via client-core's + renderable-patch rules), DiffFileCard / DiffHunkView / + FileChangeDiffBlock (pinned gutter, horizontal scroll, + "Show N more lines" cap, add-to-chat action) + ansi/ SGR parser (ansi-to-spans → 16-color theme palette, 256/truecolor + snapped, cursor/OSC stripped, \r rewinds), AnsiText / + AnsiSpansText, TerminalOutputBlock (command card that collapses + to its tail) + markdown/ native markdown renderer (mdast → RN): parse (unified + remark + gfm/breaks/math/directive, memoized), the web's prompt/thread + mention transforms, directive normalization, link classification + (local file path[:line] / external / localhost rewrite), + sugar-high code spans, / / CodeBlock / + MarkdownTable / MarkdownImage / MentionPill, markdownToPlainText, + extractMarkdownHeadings; showcase at /dev/markdown + theme/ generated tokens, ThemeProvider, fonts (see src/ui/README.md) + ui/ NativeWind primitives (Text, Button, ListRow, Sheet, …; + KeyboardPaddingView — JS-state keyboard padding for + bottom-anchored composer screens; OverlayBounds — the + region under the header the composer's floating + typeahead may cover) +e2e/flows/ Maestro flows (smoke, phase1-shell, phase3-threads, phase3-compose, + phase4a-timeline, phase4a-diff-showcase, + phase4a-conversation-rows, phase4a-work-rows, + phase4b-send, phase4b-ask-user, phase4b-approve, + phase4b-queue, phase4b-actions, phase4b-composer, + phase4b-interactions, phase4b-approval, + phase4b-thread-actions, phase5-links, phase6-panel, + phase6-diff, phase6-files, phase6-terminal, + phase6-terminal-resume) +e2e/manual/ flows that need a server the harness cannot provide + (phase7-plugins-devserver: the checkout's dev server; + demo-server: the apps/demo-server worker) and so are + not part of `pnpm e2e:ios` +e2e/subflows/ shared steps (launch-app.yaml: cold start through the + dev client + Metro, or `launchApp` of the embedded + Release bundle with `-e BB_E2E_EMBEDDED_BUNDLE=1`; + launch-to-home.yaml: launch + add the harness server + + wait for Home), called with `runFlow: ../subflows/.yaml` +e2e/scripts/ seeding + CI helpers: create-idle-thread.sh ("P4b …" + threads), phase6-diff-setup.sh / phase6-files-setup.sh, + phase6-commit.js, connect-stub-control.js, + pick-simulator.mjs (newest iPhone 17 Pro/17/16 Pro + runtime), ci-run-flows.sh (the CI flow set against a + Release build; see "CI") +eas.json EAS Build profiles (development / development-device / + preview / production); see "Release" +assets/terminal/ index.html — the bundled xterm page (generated, committed; + `pnpm --filter @bb/mobile terminal:build`) +scripts/ generate-native-theme.ts (theme tokens), + build-terminal-page.ts (the terminal WebView page), + data-smoke.mts +``` + +Rules: import `@bb/sdk/browser` (never `@bb/sdk`); no `@bb/shared-ui`; no DOM +APIs; keep RN-dependent code out of `src/lib/**` except `src/lib/native`. + +## Prerequisites (macOS) + +- Xcode 26.2 with an iOS 26 simulator runtime (`xcodebuild -downloadPlatform iOS`). +- CocoaPods (`brew install cocoapods`), `export LANG=en_US.UTF-8`. +- For Maestro e2e: `brew install --cask temurin@17` or `brew install openjdk@17` + plus `brew install mobile-dev-inc/tap/maestro`; the `e2e:ios` script sets + `JAVA_HOME=/opt/homebrew/opt/openjdk@17` unless already set. + +## Develop + +```bash +pnpm install # applies patches/expo-modules-jsi@57.0.4.patch +cd apps/mobile +pnpm ios # prebuild + build the dev-client, opens the simulator +EXPO_PUBLIC_BB_SERVER_URL=http://127.0.0.1: pnpm dev # Metro (dev-client) +``` + +The iOS Simulator shares the Mac loopback, so `pnpm dev` (repo root) or +`scripts/bb-dev-app current` gives a server URL that works as-is. Physical +phones need a Tailscale Serve URL, bb connect, or a temporary +`BB_SERVER_BIND_HOST=0.0.0.0`. + +## E2E (Maestro) + +```bash +# terminal 1: deterministic backend (fake provider, fixed port 41999) +pnpm --filter @bb/integration-tests e2e:mobile-backend +# terminal 2: Metro (EXPO_PUBLIC_BB_E2E=1 wipes profiles/preferences on every launch) +cd apps/mobile && EXPO_PUBLIC_BB_SERVER_URL=http://127.0.0.1:41999 EXPO_PUBLIC_BB_E2E=1 pnpm dev --port 8082 +# terminal 3: flows +cd apps/mobile && pnpm e2e:ios +``` + +`phase1-shell.yaml` drives the real first-run flow (Add server → home → +workspace menu → Settings → Server status shows realtime connected); `smoke.yaml` +opens the Phase 0 diagnostics screen; `phase3-threads.yaml` exercises the +thread list (rename, pin, archive, Settings → Archived → unarchive, search); +`phase3-compose.yaml` creates a thread from the home dock (`bb://compose` +→ home; pickers, model, environment) and exercises the New-project machine/folder +pickers (pass `-e REPO_PARENT_DIR=` to also browse into the harness repo); +`phase4a-timeline.yaml` opens the seeded "Rich thread" (the seed leaves it +unread; after a run, `POST /api/v1/threads//unread` restores that), lands +on the unread divider and scrolls to the long message. +`phase4a-conversation-rows.yaml` opens +the seeded "Rows thread" (started on behalf of "Idle thread": the generated +"Forked from" row, its preview/body, long-press → Copy text, and the +source-thread chip) and the "Rich thread" (bubble, assistant prose, "Worked +for" recap; its pending question's banner covers the bottom third, so the +long-press check lives on the Rows thread). `phase4a-work-rows.yaml` adds the server, opens Settings → +Developer → Work rows showcase (`/dev/work-rows`: synthetic rows for every +`work:` through the real list model) and expands a command, a closed +step's compact intents, a tool card, a file-change diff, an answered +question, a delegation with children, and running / failed workflows. +`phase4b-interactions.yaml` and `phase4b-approval.yaml` take `-e THREAD_ID=` +(create your own thread through the API and send it `ask_user` / +`approve:command echo hi` first): they open Settings → Developer → +Interactions showcase, load that thread in the "Live thread" section, answer +the question / tap Allow once and expect the banner to clear, then walk the +synthetic banner variants (command/plan approvals, secret-request form, +unknown-plugin card) and the queued messages list. +`phase4b-thread-actions.yaml` opens a thread named "P4b banner parent" (create +it first: a managed-worktree thread through the API with a file written into +its worktree, so the context banner's changed-files row and the header git +button appear), expands the banner, opens the git sheet, renames through the +title, walks the "…" menu (Copy link toast), long-presses an assistant message +and forks into the home dock. +The Phase 4b thread-screen flows each open a thread by a fixed title that +must exist on the backend (create it through the API first; Maestro ignores +`-e` overrides for keys a flow's `env:` block defines): `phase4b-send.yaml` +("P4b send": type hello → optimistic row → "Response to: hello" → draft +cleared), `phase4b-ask-user.yaml` ("P4b ask user": `ask_user` → the question +banner replaces the composer → answer → composer back, turn completes), +`phase4b-approve.yaml` ("P4b approve": `approve:command echo hi` → Allow once +→ "Response to: …"; again → Deny → "Denied"), `phase4b-queue.yaml` ("P4b +queue": `delay:30000 first` → Stop + queue affordances → queue "second" → +Send now → steered into the turn), `phase4b-actions.yaml` ("P4b actions": +environment line, "…" → Rename, long-press → Copy text, Add to chat quotes +into the composer). `phase4b-composer.yaml` drives the composer showcase's +typeahead, pills, "+" menu and attachment chip. `phase5-links.yaml` takes +`-e THREAD_ID= -e PROJECT_ID=` from the +backend's startup JSON and drives the deep links while the app is warm: +`bb://threads/` and the web alias `bb://projects/

/threads/` open the +seeded "Completed thread", `bb://settings/servers` shows the added server, and +`bb://settings` opens Settings (the per-server push row is asserted once the +push-notifications PR lands). +`phase6-panel.yaml` opens a thread named "P6 panel thread" (create it first: +a managed-worktree thread through the API with a file written into its +worktree), presents the workspace panel from the header button, checks the +Info tab's Directory / Branch / Git status / Changed files rows, taps +"Changed files" (the Diff tab becomes the selected strip entry), switches to +Info and the Files launcher, and swipes the sheet away. Under +`EXPO_PUBLIC_BB_E2E=1` a Metro reload mid-flow (another agent saving a file) +wipes the profile; when sources are being edited concurrently, run Metro +without that flag for this flow. +`phase6-diff.yaml` opens the thread named "P6 diff" whose worktree +`e2e/scripts/phase6-diff-setup.sh` dirtied (`SERVER_URL=… ./e2e/scripts/phase6-diff-setup.sh` +creates a managed-worktree thread through the API, then modifies the first +tracked file, deletes the second and adds `phase6-added.ts` — the fake +provider never edits files, so the shell does; re-runs reset and re-dirty +the same worktree), taps the banner's "Open diff" (the panel's Diff tab), +checks the modified / deleted / added cards and the hunk, the target picker +(uncommitted + merge base), collapse-all, "Add to chat" (the panel closes +and `> diff --git …` lands in the composer), a banner file row focusing its +card, then commits through the API (`e2e/scripts/phase6-commit.js`, +`runScript`), refreshes and picks "Committed changes". +`phase6-terminal.yaml` takes `-e THREAD_TITLE=` (any thread; the seed's +"Idle thread" works): it opens the workspace panel's Terminal tab, starts a +session, opens it full screen, types `echo bb-42` and reads the output back +through the dev-only text mirror (`terminal-text-mirror`, a one-line +`accessibilityLabel` of the page's last lines — a WebView's text is invisible +to the accessibility tree, so this is how Maestro sees the terminal; only +rendered under `EXPO_PUBLIC_BB_E2E=1` / dev), interrupts a `sleep` with the +accessory bar's sticky Ctrl + `c`, recalls it with the arrow keys, and +renames the session from the "…" menu. `phase6-terminal-resume.yaml` runs a +slow producer, presses Home for 20 s (the socket suspends) and expects the +missed output after the app comes back. +`phase6-files.yaml` opens the thread named THREAD_TITLE ("Idle thread") +after `e2e/scripts/phase6-files-setup.sh` seeded its project checkout +(README.md, src/app.ts, data.csv, docs/index.html, assets/dot.png) and its +thread storage (notes/plan.md, report.csv) through `POST /files/write`: +workspace panel → Files launcher (storage browser lists notes › report.csv) +→ search "README" → the workspace result opens as a panel file tab +(markdown preview) → Source → Jump to line 60 → back to Files (the launcher +stayed mounted, so the query is still there: clear it) → notes › +plan.md (storage preview) → close the panel → the full-screen preview by +deep link (`bb://threads/<id>/files?kind=workspace&path=src%2Fapp.ts&line=12`, +pass `-e THREAD_ID=`) lands on the highlighted line → long-press a line → +Copy line toasts. +`phase5-connect.yaml` drives bb connect end to end against the stub apex + +gate (`pnpm --filter @bb/integration-tests e2e:mobile-connect-stub`, see +"bb connect" below): Add server → "Connect with bb connect" → an expired code +shows the inline error → the real code with the handle and the self-hosted +apex → enrolled screen (session signed in, account servers listed, one tap +adds the second server) → Done → home through the gate (cookie on fetch and +on `/ws`) → Settings → Servers shows the mode pills and handles → the stub +expires the session (`POST /__stub/expire-session`: banner, then the app +re-mints and reconnects by itself) → the stub revokes the machine +(`/__stub/revoke-machine`: "needs to be paired again" banner) → tap the +banner → "Sign in again" re-pairs the same profile with a new code → home +connected again. The flow needs the stub started with +`BB_MOBILE_E2E_SIMULATOR=<udid>` once (it installs its root certificate in +that simulator) and drives the stub through `e2e/scripts/connect-stub-control.js` +(plain-HTTP control port 42997). +`phase7-settings.yaml` walks the settings buckets against the harness: +Experiments → toggles "New onboarding" (the switch reads checked after +leaving and coming back, and `phase7-settings-assert.js` reads the server's +`/system/config` to agree), Appearance → palette → Nord (the row shows +"Nord", the API agrees, the UI re-tints from the refetched config), Machines +→ the harness host (`phase7-machine-name.js` exports its id and name) → +detail (permission limit, provider CLIs) → Rename → the list shows the new +name → renamed back through the API; `phase7-settings-reset.js` puts the +experiments and the palette back to the defaults at the start and the end, +so the flow is safe on a shared backend. The Machines row sits below the +fold, so the flow scrolls to it. +Flows dismiss the keyboard by tapping a static label ("Server URL", +"Pairing code"): Maestro's `hideKeyboard` looks for a Return/Done key and the +Add server and connect fields use "next". +Flows cold-start the dev client (`stopApp`) because a warm reload keeps the +last deep link as the initial URL. Flows that share seed threads are +order-sensitive: `phase3-threads.yaml` renames "Idle thread", which +`phase4a-conversation-rows.yaml` looks up, so run the Phase 4a flows first (or +restart the backend between them). Without `EXPO_PUBLIC_BB_E2E=1`, open `bb://e2e/reset` +(dev builds) to return the simulator to first run. + +### Flows against a Release build (no Metro) + +A Release build (`npx expo run:ios --configuration Release --no-bundler +--device <udid>`) embeds the JS bundle and never starts the dev launcher, so +the same flows run without Metro: build it with `EXPO_PUBLIC_BB_E2E=1` (and +`EXPO_PUBLIC_BB_SERVER_URL=http://127.0.0.1:41999` for the smoke screen) in +the environment — the Xcode "Bundle React Native code and images" phase +inlines `EXPO_PUBLIC_*` at bundle time — and pass +`-e BB_E2E_EMBEDDED_BUNDLE=1` to Maestro. `e2e/subflows/launch-app.yaml` +switches on that variable between the dev-client deep link and a plain +`launchApp`; it is a `-e` variable on purpose because values in a flow's +`env:` block beat `-e`, and `METRO_URL` lives in every flow's header. +`e2e/scripts/ci-run-flows.sh <udid> <artifacts dir> [flow…]` is what CI runs: +it seeds "P4b send" (`create-idle-thread.sh`) and "P6 panel thread" +(`phase6-diff-setup.sh`) through the API, then runs `smoke`, `phase1-shell`, +`phase4a-timeline`, `phase3-compose`, `phase4b-send`, `phase6-panel` one +`maestro test` at a time with `--test-output-dir` per flow (screenshots, +logs, JUnit) and exits non-zero if any failed; `--dev-client` as the first +argument drives a dev client through Metro instead. + +## CI + +- Typecheck, lint, and unit tests run on Linux in the regular `CI` workflow + (`pnpm exec turbo run build typecheck lint` in `Checks`, the `packages` + test shard for `vitest`), like every workspace package. +- `.github/workflows/mobile-e2e.yml` (`Mobile E2E`) runs the flows above on + the `blacksmith-6vcpu-macos-15` runner: label a pull request `mobile-e2e`, + dispatch it by hand (optional `flows` input), or wait for the nightly run. + It selects Xcode 26.2 (`DEVELOPER_DIR`, falling back to the newest 26.x), + boots the simulator `pick-simulator.mjs` chooses, installs Maestro 2.8.0 + (Java 17 from `actions/setup-java` only when the image has no JDK 17+), + restores `ios/Pods` + `Podfile.lock` and (behind the workflow's + `CACHE_DERIVED_DATA` knob — DerivedData is ~7 GB raw and the Actions cache + quota is shared with the Turbo caches) the Xcode DerivedData `Build/` + directory from `actions/cache` keyed on `pnpm-lock.yaml` + `app.json` + + `package.json` + `patches/**`, prebuilds, builds the Release app onto the + simulator, starts the harness backend (`turbo run e2e:mobile-backend`, + waits for `/health`), runs `ci-run-flows.sh`, and uploads + `e2e-artifacts/` (per-flow Maestro output, backend log, simulator log). + +## Files and previews (Phase 6) + +- **Where**: the workspace panel's Files launcher (search + thread storage + browser + recents) and one panel tab per opened file + (`workspace-file-preview` / `host-file-preview` / + `thread-storage-file-preview`, the client-core tab kinds synced with the + web strip), plus the full-screen route `/threads/[id]/files` (the same + `FilesTabContent` / `FilePreviewView` components) with the params + `kind` (`workspace` | `host` | `storage` | `project`), `path`, `line` + (`12` or `12-20`), `source` (`working-tree` | `head` | `merge-base:<ref>`) + and `status` (`deleted`). +- **Opening files**: `useThreadFileOpener(threadId)` — the mounted workspace + panel (`panel.openFile`, the file becomes a tab), else the route. Every open of a + workspace / storage file lands in the thread's Recent list (MMKV + `bb.thread.recentItems-<threadId>-1`, the web's key and JSON shape). +- **Local file links**: `useThreadLocalFileLinks` routes markdown + `/abs/path[:line]` links (timeline rows through `TimelineRowHostProvider`, + previewed markdown files): inside the environment's checkout → workspace + file; inside the thread storage root (known once the storage list is + cached) → storage file; otherwise a host-file read through the thread's + host. Relative references (`src/a.ts:12` links, a bare relative path) + resolve against the known roots and ask which one when both exist. +- **Content**: workspace files read `sdk.environments.diffFile` (working + tree / HEAD / merge base; images and videos become `data:` URLs), host / + storage / project files read the raw content routes with the profile fetch + (cookie jar shared with expo-image and the WebView) and classify with + `buildFilePreview` from `@bb/client-core`; a 413 `file_too_large` shows the + too-large state with "Open in browser". HTML renders in a WebView pointed + at the raw route (`/worktree/files/<path>`, `/thread-storage/files/<path>`, + `/files/raw?path=` for host files — all answered with + `Content-Security-Policy: sandbox allow-scripts`); video has no in-app + player in this build (no expo-av / expo-video) and hands off to the system. +- **Add to chat**: long-press a line → "Add to chat" quotes + `path:line\n<line>` into the thread's follow-up composer through the + per-thread composer host (`registerThreadComposerHost`, set by the thread + screen); a panel tab also closes the panel. Without a reachable composer + (a deep-linked preview) the `path:line` reference is copied instead. + +## Terminal (Phase 6) + +- **Where**: the workspace panel's Terminal tab (sessions of the panel's + scope + "Start terminal", then one tab per attached session with a + title / restart / new / close toolbar) and the full-screen route + `/threads/[id]/terminal/[terminalId]` (any orientation; the tab's title + opens it). `/threads/[id]/terminal` lists the thread's sessions. +- **Transport**: React Native owns the socket. `@bb/client-core` + `TerminalWebSocketTransport` over RN's `WebSocket` + (`ws(s)://<server>/ws/terminals/:id?sinceSeq=N`, cookies from the native + jar so bb connect works), heartbeat + reconnect from the transport, and + `suspend()` / `resume()` bound to `AppState`: backgrounding closes the + socket, foregrounding reattaches from the last chunk seen and the server + replays what was missed. A replay gap the socket cannot cover + (`replayStartSeq > nextOutputSeq`) is filled from + `GET /terminals/:id/output?sinceSeq=`; only when the scrollback no longer + reaches does the terminal reset with "Some terminal output was unavailable + after reconnect" (the web's behavior). `terminal-stream.ts` holds that + policy, `terminal-bridge.ts` the batching / encoding, both vitest-tested. +- **Page**: `assets/terminal/index.html` is a single self-contained document + (xterm.js + fit + unicode11 + web-links + the page script and CSS inlined) + built by `scripts/build-terminal-page.ts` + (`pnpm --filter @bb/mobile terminal:build`) and committed; + `src/screens/terminal/terminal-page.test.ts` rebuilds it in memory and + fails when it is stale (like the theme drift test). It is loaded with + `expo-asset` + `expo-file-system` and handed to `react-native-webview` as + `source={{ html }}` — the page itself makes no network requests. + RN → page: `init` / `theme` / `write` (base64 chunks batched to ≤16 KiB or + 16 ms) / `status` / `reset` / `resize` / `focus` / `blur` / `key` / + `paste`; page → RN: `ready` / `data` (keystrokes and terminal replies) / + `resize` (fit) / `link` / `title` / `text-mirror` / `error`. Replayed + output is written with a completion callback and `onData` is muted until + xterm has parsed it, so the PTY never receives a second cursor-position + (DA1) reply — the web's `forwardTerminalData` / `writeTerminalOutput` + semantics, ported to `terminal-bridge.ts`. +- **Input**: tapping the terminal focuses xterm's hidden textarea and raises + the keyboard (`keyboardDisplayRequiresUserAction={false}`); the accessory + bar above it adds esc, tab, a sticky ctrl (applied to the next keystroke), + arrows, home / end, `-`, `/`, `|`, paste (`expo-clipboard`), a keyboard + key and, full screen, a "…" that opens the same menu as the header + (rename / restart / new / close). Cursor keys follow DECCKM (SS3 in + application mode), Ctrl+arrow sends `CSI 1;5<final>`. +- **Data** (`src/data/terminals`): `useTerminals(scope)` + (`GET /terminals?threadId|environmentId|hostId`), + `useTerminalSession(id)`, `useCreateTerminal` / `useRestartTerminal` / + `useCloseTerminal` / `useRenameTerminal`, `useFetchTerminalOutput`. Realtime + `terminals-changed` (thread scope) invalidates the lists; the attach + socket's `attached` / `session-updated` / `exited` are written straight + into the cache. The shell's OSC title renames the session (debounced, + path-like prompts ignored — the web's `normalizeTerminalTitle`). +- **Measured** (iPhone Air simulator, Direct mode): `seq 1 320000` + (2.45 MB) reached the terminal in 365 ms from the first to the last + `postMessage` batch (86 socket chunks → 83 batches), the view stayed + responsive and ended on the last line; 1.42 MB took 369 ms. Server chunks + are up to 64 KiB, so the batcher mostly coalesces small interactive + output. + +## bb connect (Phase 5) + +- The pairing surfaces on the bb side (Settings → Remote access → Add mobile + device, `bb connect machine-code`) sit behind the `mobileApp` experiment + while the app is in early access: turn it on in Settings → Experiments or + with `bb settings experiment mobileApp true` before you mint a code. +- Enrollment (`src/screens/connect`, `src/data/connect`, route `/connect`): + "Add server" offers "Connect with bb connect" above the Direct URL form. + The screen scans the pairing QR (`expo-camera`; payload = the connect + plugin's `MobilePairingPayload` JSON `{code, serverUrl, apex, expiresAt}`, a + `bb://connect?code=…&serverUrl=…` link, or a bare code — + `parseConnectPairingPayload`) or takes the code by hand with an optional + server (handle like `bee` or `https://bee.getbb.app`) and an optional + self-hosted apex; the apex defaults to `deriveConnectBaseUrl(serverUrl)` + or `https://getbb.app` (`resolveEnrollmentTarget`). `redeemEnrollment` + calls `redeemMachineCredential` (`POST <apex>/api/connect/redeem-machine`) + and saves `{mode:"connect", serverUrl, handle, credential(bbcm_…), label}` + in SecureStore, then activates it: the connector mints the desktop-session + cookie and opens realtime (the enrolled screen shows that status live). + Errors map to copy per wire code (`describeEnrollmentError`: invalid / + expired / already used, the 409 `machine_limit` with the "revoke a device + in the dashboard" way out, network, unauthorized). +- Account servers: the machine credential is account-scoped (the apex stores + it against the user, `apps/web/src/server/api.ts` `redeemMachineCode`; the + gate checks it against the label's owner), and the desktop-session cookie + is a `.getbb.app` cookie carrying only the user id, so one enrollment + covers every server the account owns — the same as the desktop app's + Server menu. After pairing, "Servers on this account" + (`GET <serverUrl>/api/connect/servers` with the credential, + `listAccountServers`) adds any other server as a profile in one tap with + the same credential; no second code is needed. +- Session: `src/lib/session` mints `POST <serverUrl>/api/connect/desktop-session` + with the credential, installs the cookie in both native jars (`Secure` + follows the server URL's scheme so a plain-http stub gate works), renews + five minutes before expiry and on AppState active. The connector + (`src/lib/connection`) re-checks the session on any 401/403 (an API call + or the `/ws` upgrade — React Native reports the refused upgrade as the + close reason "Received bad response code from server: 401.") and on + repeated connection failures (throttled): a fresh cookie reconnects the + socket at once; a refused re-mint flips the profile to `auth-required`. + Queries that raced the first mint (or a re-mint) and hit the gate's 401 + page are fetched again once the cookie lands + (`refetchQueriesRejectedBeforeSession`); a 401 within two seconds of a + mint is attributed to a request that started with the old cookie and + only triggers that refetch, not another mint. +- Re-auth UX: the `auth-required` banner ("<label> needs to be paired again.") + is a button that opens `/connect?profileId=<id>`, which re-pairs the same + profile (new credential, same label and place in the list); Settings → + Servers offers "Sign in again" from the long-press menu for connect + profiles and shows a mode pill (`bb connect` / `direct`) plus `@handle`. + "Remove" only forgets the profile locally: the phone stays listed under + Machines in the getbb.app dashboard until revoked there (the copy says so). +- Stub for e2e (`tests/integration/mobile-e2e/connect-stub.ts`, + `pnpm --filter @bb/integration-tests e2e:mobile-connect-stub`): plays the + apex and the gate on one TLS port (`https://localhost:42998` / + `https://stub.localhost:42998`, so `@bb/connect-client`'s "server lives + under the apex" rule and the `Secure` cookie hold; iOS ATS refuses plain + http to a qualified name). It redeems `STUB-PAIR` (sentinels + `EXPIRED-CODE` / `USED-CODE` / `LIMIT-CODE` reproduce the apex errors), + mints sessions for its machines, lists two account servers, and reverse + proxies everything else (HTTP + WebSocket upgrade) to the harness backend + — only with a valid session cookie, otherwise the gate's HTML 401 — + rewriting `Origin: https://<gate host>` to the loopback origin like the + tunnel client does so the bb server's origin guard accepts RN's + WebSocket. Control: `POST /__stub/{expire-session,revoke-machine,reset}`, + `GET /__stub/state`, also on plain `http://127.0.0.1:42997`. It generates a + local CA under `~/.bb-mobile-e2e/connect-stub-certs` and installs it in + the simulator named by `BB_MOBILE_E2E_SIMULATOR` (`xcrun simctl keychain … +add-root-cert`). Env: `BB_MOBILE_E2E_GATE_PORT` (42998), + `BB_MOBILE_E2E_STUB_CONTROL_PORT` (42997), `BB_MOBILE_E2E_UPSTREAM_URL` + (`http://127.0.0.1:${BB_MOBILE_E2E_PORT ?? 41999}`), + `BB_MOBILE_E2E_CONNECT_CODE`, `BB_MOBILE_E2E_STUB_HANDLE`, + `BB_MOBILE_E2E_SESSION_TTL_MS`, `BB_MOBILE_E2E_STUB_LOG=1` (one line per + gate request). + +## Push notifications and deep links (Phase 5) + +- Push notifications (Expo push registration, tap routing, foreground toast, + app-icon badge, Settings → Notifications rows, and the server side) arrive + in a later PR; `expo-notifications` is already part of the native build. +- Deep links: `bb://<mobile path>` (`bb://threads/<id>`, `bb://settings/servers`, + `bb://projects/<p>/threads/<t>`, …) and universal / app links + `https://<handle>.getbb.app/{threads,projects,settings}/*` (iOS + `associatedDomains: applinks:getbb.app, applinks:*.getbb.app`; Android + `intentFilters` with `autoVerify`). `app/+native-intent.tsx` resolves every + URL with `src/lib/links`: a web link whose origin matches a saved profile + switches to that profile (waiting for its connection) and maps the web + path onto the mobile route (`mapWebPathToMobilePath`; web-only surfaces + land on home / settings); an unknown server opens Add server prefilled + with the origin and the follow-up path. Universal links only resolve once + `https://<handle>.getbb.app/.well-known/apple-app-site-association` / + `assetlinks.json` are served (the connect gate and the apex do, before the + session gate — `packages/connect-db/src/app-links.ts`) and the app is + signed with the team id in that file; until then only the `bb://` scheme + works, and wildcard associated-domain behavior still needs a physical + device check. The realtime `thread-open` signal (`POST /threads/:id/open`, + `bb thread open`) navigates to the thread while the app is foregrounded. + +## Plugins, marketplaces, skills (Phase 7) + +- Data: `src/data/plugins/` (`usePluginList` over `GET /plugins`, kept live by + `plugins-changed`; `usePlugin`, `usePluginSettings`, `usePluginUpdates`, + `usePluginLogs` (raw `GET /plugins/:id/logs?tail=`, not in the SDK), + `usePluginCatalogSearch`, `usePluginCatalogInstallPlan`, + `usePluginMarketplaces`, `useServerSvgAsset`; mutations enable / disable, + update settings (changed keys only, secrets write-only), check / apply + updates, remove, reload, install (source or catalog, with the third-party + `confirmedSource`), add / refresh / remove marketplaces; pure + `plugin-model.ts` ports the web's row signal / health presentation / + settings-form rules) and `src/data/skills/` (`useProjectSkills` of the + personal project = the library, skill files / content, the skills.sh + registry search / entry / detail, install / delete; pure `skill-model.ts`). +- Screens: `src/screens/plugins/` (PluginsScreen with the long-press menu, + PluginDetailScreen: enable switch, health + recovery, update card, + `PluginSettingsForm` (string / secret / boolean / select → option sheet / + project → ProjectPicker), includes, runtime, source, reload / logs / + uninstall; PluginLogsScreen; PluginBrowseScreen grouped by publisher with + `AddPluginSheet` (full-trust warning, third-party resolved-source + disclosure); MarketplacesScreen) and `src/screens/extensions/` (skills + library grouped by scope, SkillDetailScreen rendering SKILL.md with + `@/markdown`, RegistrySkillsScreen with Load more, RegistrySkillDetailScreen + with "Install to my skills"). +- Plugin compact icons and provider logos are `currentColor` SVGs served by bb: + `ServerSvgIcon` reads them as text through the profile fetch and renders + `SvgXml` with the theme foreground (an image view would paint them black); + the provider picker uses it for `GET /system/providers/:id/logo`. +- Plugin _frontends_ (nav panels, settings sections, directives) still do not + run natively (see the plan's A5 / Limitations); only the server-side + management surfaces above are covered. +- The integration harness runs no plugin service: on `e2e:mobile-backend` the + installed list is empty (the flow asserts the empty state) while the + catalog / marketplaces / skills routes work. `e2e/manual/phase7-plugins- +devserver.yaml` drives the same screens against the checkout's dev server + (`scripts/bb-dev-app current`; real builtin plugins, read-mostly) and is not + part of `pnpm e2e:ios`. + +## Share sheet and haptics (Phase 7) + +- Outbound: the thread "…" menu's "Share link" hands the thread's web URL to + the OS share sheet (`src/lib/share/share-thread.ts`, RN `Share.share`; iOS + gets a `url` item, Android a `message`). +- Inbound "Send to bb" is wired for `expo-share-intent` but the native module + is **not** in the current dev client: `src/lib/share/share-intent.ts` loads + it optionally (Metro's `allowOptionalDependencies` keeps the bundle building + without it) and `src/app-shell/ShareIntentHandler.tsx` renders nothing when + it is absent. To enable it: `npx expo install expo-share-intent`, add + `["expo-share-intent", { "iosActivationRules": { "NSExtensionActivationSupportsText": true, "NSExtensionActivationSupportsWebURLWithMaxCount": 1 } }]` + to `app.json` plugins, rebuild the dev client (`pnpm ios`, ~10 min, also + reinstall it on every simulator the flows use). Shared text / URLs open + `/compose?initialPrompt=`; media / file shares are declined with a toast + in this phase. +- Haptics: `src/lib/haptics/` — `haptic(kind)` maps semantic kinds + (`selection`, `impact-light|medium|heavy`, `success`, `warning`, `error`) + onto expo-haptics and honors the Settings → Preferences → Haptics toggle + (MMKV `bb.haptics.enabled`, default on). Call sites: `Button haptic`, + picker rows (selection), composer send (medium), approvals / saves / + installs (success), destructive ActionSheet rows (warning), long-press + menus (heavy). Screens never import expo-haptics directly. + +## Release (EAS) + +The app lives in the EAS project `@bb-team/bb-app` (id in +`app.json` → `extra.eas.projectId`; the Expo slug `bb-app` also names the +dev-client scheme `exp+bb-app://`). Apple team `9QCU24SXK5`, bundle id +`app.getbb.mobile`, App Store Connect app `6803559210`. EAS holds the iOS +credentials (distribution certificate, App Store provisioning profile, APNs +push key); nobody needs a local Xcode signing setup to ship. + +- **Log in once**: `pnpm exec eas login` (or `EXPO_TOKEN`). `eas-cli` is a + pinned devDependency, so use `pnpm exec eas …` from `apps/mobile`. +- **Build profiles** (`eas.json`): `development` (simulator dev client), + `development-device` (dev client for a physical iPhone; needed for push + acceptance), `preview` (internal ad-hoc), `production` (App Store / + TestFlight; `autoIncrement` + `appVersionSource: remote` keep the build + number on EAS, `version` in `app.json` is the marketing version). +- **TestFlight by hand**: `pnpm exec eas build -p ios --profile production`, + then `pnpm exec eas submit -p ios --latest`. The submit profile reads the + App Store Connect API key from the gitignored `apps/mobile/asc-api-key.p8` + (key id and issuer id are in `eas.json`); get the `.p8` from a teammate or + App Store Connect → Users and Access → Integrations → App Store Connect API + (role App Manager, one-time download). Both commands also work with + `--non-interactive`. +- **CI**: `.github/workflows/mobile-ios-eas.yml` writes the `.p8` from the + `ASC_API_KEY_P8` secret, optionally sets `app.json` `version`, and runs + `eas build -p ios --profile <profile> [--auto-submit]` with + `EXPO_TOKEN`. EAS builds, then uploads to TestFlight; the job waits for + both and fails when either fails. Logs are on expo.dev under the project's + Builds and Submissions (the run summary links them). After a submit, the + job runs `scripts/testflight-distribute.mjs`, which waits for App Store + Connect to process the build, submits it for Beta App Review when it has + none, and adds it to the external group named by the `external_group` + input (default `External testers`; empty skips the step). Run the script + by hand with `node scripts/testflight-distribute.mjs --version X.Y.Z + --build N` from `apps/mobile` with the `.p8` in place. + Run it alone from the Actions tab ("Mobile iOS (EAS)") or + `gh workflow run mobile-ios-eas.yml -f profile=production -f submit=true`. + The nightly `publish-bb-app.yml` calls the same workflow after the npm + nightly publish with an empty `version`, so every nightly keeps the + marketing version committed in `app.json` and only the EAS build number + moves. This is deliberate: TestFlight needs a Beta App Review for the + first build of each new marketing version, and later builds of the same + version skip it. Bump `app.json` `version` only when you want a new + review, for example for a store release. Repo + secrets: `EXPO_TOKEN` (a robot token from the `bb-team` Expo org) and + `ASC_API_KEY_P8` (the `.p8` contents). +- The `expo-modules-jsi` pnpm patch and the `lightningcss` override ship + with the repo and apply on EAS; the default build image provides + Xcode 26.x. +- Universal links need the signed app's team id in the AASA the connect gate + serves (`packages/connect-db/src/app-links.ts`) and a physical-device + check against `https://<handle>.getbb.app/threads/…`. Android signing + (`eas credentials -p android`, FCM V1, `ASSETLINKS_SHA256_FINGERPRINTS`) + is still open. +- `eas update` (JS-only fixes over the air) is deferred: `expo-updates` is + not installed, so the profiles define no update channels. + +## TestFlight testers + +**Internal testers** need no Apple review. A build reaches the group as soon as +App Store Connect finishes processing it, usually within 30 minutes. The group +`bb team` exists and the nightly feeds it. + +**External testers** need a Beta App Review on the first build of each +marketing version, and Apple usually auto-approves later builds of that +version. The nightly keeps one marketing version for this reason (see "CI" +above). Apple offers "Automatically distribute builds" only for internal +groups, so the CI distribute step adds each submitted build to the external +group through the App Store Connect API. Before a build can go to an external +group, App Store Connect needs all of this: + +- **Test Information** (`betaAppLocalizations`): a feedback email, a beta + description, and the privacy policy URL <https://getbb.app/privacy>. Per + build, a "What to test" note. +- **Beta App Review Details** (`betaAppReviewDetail`): contact first name, last + name, phone, and email. Apple uses these, testers never see them. +- **A way for the reviewer to use the app.** This is the part that fails. bb + opens on "Add server", and a reviewer has no bb server, so without help they + cannot get past the first screen and will reject the build. Neither real + path works for a reviewer: a bb server's API is unauthenticated and runs + commands, so it cannot be on the internet, and connect pairing codes are + single-use and expire in ten minutes. Give them the **demo server** instead: + `apps/demo-server` is a Cloudflare Worker that answers the launch-path API + from fixed data, runs nothing, and isolates each client address. Deploy it + with `pnpm --filter @bb/demo-server deploy`, and rehearse the notes with + `e2e/manual/demo-server.yaml` before every submission. Disclose it in the + notes: a disclosed demo mode is sanctioned by guideline 2.1. + +Review notes template — keep it literal, and assume the reviewer knows nothing +about coding agents: + +```text +bb is a client for a bb server that a developer runs on their own computer. +The app has no accounts of its own, so we have prepared a demo server for +you. It serves sample conversations and scripted replies; it does not run a +real coding agent. + +1. Open the app. It shows "Connect to a bb server". +2. Under "Direct URL", in "Server URL", enter: https://<DEMO-HOST> +3. Tap "Connect". +4. The app shows a list of conversations. Open any of them to read it. +5. Type a message and send it. The agent replies after a moment. + +Write to <EMAIL> if the server does not respond. +``` + +Rehearse it before submitting: hand a colleague a phone that has never run bb, +give them only these notes, and check that they reach a thread. + +The nightly keeps the marketing version in `app.json` and lets the EAS build +number tell nightlies apart, because a new version string triggers a fresh +Beta App Review and another build of the same version usually does not. + +## Local state + +- Server profiles: `expo-secure-store`, one key per profile + (`bb.profile.<id>`) plus `bb.profiles.index`. +- Preferences (theme mode `bb.theme`, sidebar `bb.sidebar.*`, thread-creation + picks `bb.promptbox.*` / `bb.root-compose.*`, composer drafts + `bb.promptbox.contents-*` in the web's `PromptDraftState` JSON, all with the + web app's key names): MMKV store `bb.preferences` (the push state of the + later push-notifications PR shares it). +- Each profile owns one SDK client, one realtime socket, and one TanStack + QueryClient (`src/lib/sdk/client-registry.ts`, instantiated once by + `src/app-shell/client-registry.ts`); the active profile's socket/session + lifecycle lives in `src/lib/connection`. +- Failed mutations toast globally from the profile QueryClient's mutation + cache (`meta.errorMessage` is the headline, the server/transport detail the + description; `meta.showErrorToast: false` opts out for inline errors) — + screens do not toast mutation errors themselves. + +## Theme tokens + +`src/theme/theme.native.ts` is generated from the web app's +`apps/app/src/components/ui/theme.css` plus the built-in palettes in +`apps/app/src/lib/themes/*.ts`: every color token per palette × light/dark as a +plain RN color string, with `nativeRadii` and the touch (`pointer: coarse`) +`nativeTypography` scale. Do not edit it by hand. After changing theme.css or a +palette, run `pnpm --filter @bb/mobile theme:generate` and commit the result; +`src/theme/generate-native-theme.test.ts` fails when the file is stale. + +## Notes + +- Workspace packages resolve from TypeScript source through `metro.config.js` + (`source` export condition for `@bb/*` only, `./x.js` → `./x.ts`). +- Import `@bb/sdk/browser`, never `@bb/sdk` (lint-enforced). +- Never spread a `Headers` instance into a fetch init on React Native. +- File uploads (`POST /projects/:id/attachments`, `/system/voice-transcription`) + go through `XMLHttpRequest` (`src/data/composer/multipart-upload.ts`): the + SDK's Blob upload cannot run on RN and `expo/fetch` (the global fetch) + rejects `{ uri, name, type }` form parts; RN's XHR streams them natively. +- `lightningcss` is pinned to 1.30.1 for `@expo/metro-config` (NativeWind v5). +- Type-scale line heights in `global.css` are unitless ratios + (`calc(22 / 15)`), not px: react-native-css drops the unit inside Tailwind's + `var(--tw-leading, …)` fallback and treats the number as an em multiplier. +- On a `ScrollView`, do not combine `contentContainerClassName` with an inline + `contentContainerStyle`; the class styles are dropped. Use one or the other. +- FlashList v2 keeps the first visible row anchored when rows are inserted + above it (`maintainVisibleContentPosition` is on by default); lists where + new rows must appear at the top (sidebar, search, archived) pass + `{ disabled: true }`. +- `Sheet` sets `accessible={false}` on the bottom-sheet container and + `keyboardShouldPersistTaps="handled"` on its scroll body so rows are + reachable by VoiceOver/Maestro and a tap lands while the keyboard is up. +- Maestro on iOS: `back` is not a thing; tap `id: BackButton`. The dev + client's floating gear can sit over the header's right icons on larger + simulators; Settings is reached through the header avatar on the left + (`e2e/subflows/open-settings.yaml`). diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000000..a3490ddbbc --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,101 @@ +{ + "expo": { + "name": "bb", + "slug": "bb-app", + "owner": "bb-team", + "version": "0.39.0", + "scheme": "bb", + "orientation": "default", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "bundleIdentifier": "app.getbb.mobile", + "supportsTablet": true, + "infoPlist": { + "NSLocalNetworkUsageDescription": "bb connects to a bb server on your local network or tailnet.", + "NSCameraUsageDescription": "bb attaches photos you take to your prompts.", + "NSMicrophoneUsageDescription": "bb records voice input and transcribes it into your prompt.", + "NSPhotoLibraryUsageDescription": "bb attaches photos you pick to your prompts.", + "ITSAppUsesNonExemptEncryption": false, + "NSAppTransportSecurity": { + "NSAllowsLocalNetworking": true + }, + "UIBackgroundModes": ["remote-notification"] + }, + "associatedDomains": ["applinks:getbb.app", "applinks:*.getbb.app"] + }, + "android": { + "package": "app.getbb.mobile", + "adaptiveIcon": { + "backgroundColor": "#FFFFFF", + "foregroundImage": "./assets/android-icon-foreground.png", + "backgroundImage": "./assets/android-icon-background.png", + "monochromeImage": "./assets/android-icon-monochrome.png" + }, + "predictiveBackGestureEnabled": false, + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [ + { + "scheme": "https", + "host": "*.getbb.app", + "pathPrefix": "/threads/" + }, + { + "scheme": "https", + "host": "*.getbb.app", + "pathPrefix": "/projects/" + }, + { + "scheme": "https", + "host": "*.getbb.app", + "pathPrefix": "/settings/" + } + ], + "category": ["BROWSABLE", "DEFAULT"] + } + ] + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + "expo-router", + "expo-secure-store", + "expo-image", + "expo-audio", + "expo-web-browser", + "expo-font", + "expo-splash-screen", + [ + "expo-notifications", + { + "icon": "./assets/android-icon-monochrome.png", + "color": "#000000", + "defaultChannel": "threads" + } + ], + "expo-camera", + "expo-image-picker", + [ + "expo-build-properties", + { + "android": { + "usesCleartextTraffic": true + } + } + ] + ], + "experiments": { + "typedRoutes": true + }, + "extra": { + "eas": { + "projectId": "3dca8cca-f48a-4c3a-ba3d-3af40e58a588" + } + } + } +} diff --git a/apps/mobile/app/+native-intent.tsx b/apps/mobile/app/+native-intent.tsx new file mode 100644 index 0000000000..94adf25930 --- /dev/null +++ b/apps/mobile/app/+native-intent.tsx @@ -0,0 +1,43 @@ +// Expo Router calls `redirectSystemPath` for every incoming URL (cold start +// and while running) before routing it: the `bb://` scheme, universal links +// (`https://<handle>.getbb.app/threads/<id>`), the dev-client's own URLs. +// Pure resolution lives in src/lib/links; this file does the RN side: +// activate the profile that owns a web link, wait for its connection, and +// send unknown servers to the add-server screen with the link remembered. +import { waitForActiveConnection } from "@/app-shell/connector"; +import { e2eModeEnabled } from "@/app-shell/e2e"; +import { addServerPathForLink, resolveIncomingLink } from "@/lib/links"; +import { getProfileStore } from "@/lib/native"; + +export async function redirectSystemPath({ + path, +}: { + path: string; + initial: boolean; +}): Promise<string> { + try { + const store = getProfileStore(); + await store.load(); + const snapshot = store.getSnapshot(); + const resolution = resolveIncomingLink(path, { + profiles: snapshot.profiles, + activeProfileId: snapshot.activeProfileId, + developerRoutesEnabled: e2eModeEnabled, + }); + switch (resolution.kind) { + case "passthrough": + return path; + case "navigate": + if (resolution.profileId !== null) { + await store.setActiveProfile(resolution.profileId); + await waitForActiveConnection(resolution.profileId); + } + return resolution.path; + case "unknown-server": + return addServerPathForLink(resolution.serverUrl, resolution.path); + } + } catch (error) { + console.warn("Could not resolve incoming link", path, error); + return "/"; + } +} diff --git a/apps/mobile/app/+not-found.tsx b/apps/mobile/app/+not-found.tsx new file mode 100644 index 0000000000..3f29c4e627 --- /dev/null +++ b/apps/mobile/app/+not-found.tsx @@ -0,0 +1,17 @@ +import { Link, Stack } from "expo-router"; +import { View } from "react-native"; +import { Text } from "@/ui"; + +export default function NotFoundScreen() { + return ( + <> + <Stack.Screen options={{ title: "Not found" }} /> + <View className="flex-1 items-center justify-center gap-3 bg-background p-6"> + <Text variant="heading">This screen does not exist.</Text> + <Link href="/"> + <Text tone="primary">Go home</Text> + </Link> + </View> + </> + ); +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx new file mode 100644 index 0000000000..dd8c24f70d --- /dev/null +++ b/apps/mobile/app/_layout.tsx @@ -0,0 +1,63 @@ +import "../global.css"; +import "../src/lib/polyfills"; + +import * as SplashScreen from "expo-splash-screen"; +import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { KeyboardProvider } from "react-native-keyboard-controller"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { + PaletteProvider, + ProfilesProvider, + ServerPaletteSync, + ShareIntentHandler, + ThreadOpenSignalHandler, + useAppBoot, +} from "@/app-shell"; +import { RootNavigator, RouteErrorBoundary } from "@/screens"; +import { ThemeProvider } from "@/theme"; +import { useAppFonts } from "@/theme/useAppFonts"; +import { SheetProvider, Toaster } from "@/ui"; + +// Deep links into a pushed screen still get home underneath. +export const unstable_settings = { anchor: "index" }; + +export { RouteErrorBoundary as ErrorBoundary }; + +export default function RootLayout() { + const fonts = useAppFonts(); + const boot = useAppBoot(); + const ready = fonts.ready && boot.ready; + + useEffect(() => { + if (ready) void SplashScreen.hideAsync().catch(() => undefined); + }, [ready]); + + if (!ready) return null; + + return ( + <GestureHandlerRootView style={{ flex: 1 }}> + <SafeAreaProvider> + <KeyboardProvider> + <PaletteProvider> + {(palette) => ( + <ThemeProvider palette={palette}> + <ProfilesProvider> + <ServerPaletteSync /> + <SheetProvider> + <RootNavigator /> + <ThreadOpenSignalHandler /> + <ShareIntentHandler /> + <Toaster /> + </SheetProvider> + </ProfilesProvider> + </ThemeProvider> + )} + </PaletteProvider> + <StatusBar style="auto" /> + </KeyboardProvider> + </SafeAreaProvider> + </GestureHandlerRootView> + ); +} diff --git a/apps/mobile/app/connect/index.tsx b/apps/mobile/app/connect/index.tsx new file mode 100644 index 0000000000..8a9ec740f4 --- /dev/null +++ b/apps/mobile/app/connect/index.tsx @@ -0,0 +1,3 @@ +import { ConnectEnrollScreen } from "@/screens/connect/ConnectEnrollScreen"; + +export default ConnectEnrollScreen; diff --git a/apps/mobile/app/dev/composer.tsx b/apps/mobile/app/dev/composer.tsx new file mode 100644 index 0000000000..7744bae654 --- /dev/null +++ b/apps/mobile/app/dev/composer.tsx @@ -0,0 +1,9 @@ +// Dev-only showcase: inert in production bundles (see app/e2e/reset.tsx). +import { Redirect } from "expo-router"; +import { e2eModeEnabled } from "@/app-shell"; +import { ComposerShowcaseScreen } from "@/screens/dev/ComposerShowcaseScreen"; + +export default function ComposerRoute() { + if (!e2eModeEnabled) return <Redirect href="/" />; + return <ComposerShowcaseScreen />; +} diff --git a/apps/mobile/app/dev/connect-spike.tsx b/apps/mobile/app/dev/connect-spike.tsx new file mode 100644 index 0000000000..b53f291c17 --- /dev/null +++ b/apps/mobile/app/dev/connect-spike.tsx @@ -0,0 +1,253 @@ +// Phase 0 connect spike screen. Not product UI. +// +// Flow under test (desktop-app model, no gate change): +// machine code → redeemMachineCredential (apex) +// → fetchDesktopSession (gate, machine header) +// → install cookie in the native cookie stores (@react-native-cookies/cookies) +// → verify fetch /api/v1/system/config, /ws upgrade, expo-image, WebView +// all authenticate through https://<handle>.getbb.app. +import { + fetchDesktopSession, + redeemMachineCredential, + type ConnectCredential, +} from "@bb/connect-client"; +import CookieManager from "@react-native-cookies/cookies"; +import { Image } from "expo-image"; +import { Redirect } from "expo-router"; +import { useState } from "react"; +import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { WebView } from "react-native-webview"; +import { e2eModeEnabled } from "@/app-shell"; + +const APEX_URL = process.env.EXPO_PUBLIC_BB_CONNECT_APEX ?? "https://getbb.app"; + +function ConnectSpikeScreen() { + const insets = useSafeAreaInsets(); + const [code, setCode] = useState(""); + const [credential, setCredential] = useState<ConnectCredential | null>(null); + const [cookieValue, setCookieValue] = useState<string | null>(null); + const [cookieHeader, setCookieHeader] = useState<string | null>(null); + const [imageNonce, setImageNonce] = useState(0); + const [imageStatusHeader, setImageStatusHeader] = useState("not loaded"); + const [showWebView, setShowWebView] = useState(false); + const [imageStatus, setImageStatus] = useState("not loaded"); + const [log, setLog] = useState<string[]>([]); + const append = (line: string) => + setLog((prev) => + [`${new Date().toISOString().slice(11, 19)} ${line}`, ...prev].slice( + 0, + 40, + ), + ); + + const redeem = async () => { + try { + const cred = await redeemMachineCredential({ + apexUrl: APEX_URL, + code: code.trim(), + }); + setCredential(cred); + append(`redeemed: handle=${cred.handle} serverUrl=${cred.serverUrl}`); + } catch (error) { + append(`redeem error: ${String(error)}`); + } + }; + + const mintSession = async () => { + if (!credential) return append("no credential"); + try { + const session = await fetchDesktopSession(credential); + const { cookie } = session; + // Install in NSHTTPCookieStorage (fetch/WebSocket/expo-image) AND + // WKHTTPCookieStore (WebView) on iOS; CookieManager on Android. + const cookieSpec = { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: "/", + secure: true, + httpOnly: true, + expires: new Date(cookie.expiresAt).toISOString(), + }; + await CookieManager.set(credential.serverUrl, cookieSpec, false); + await CookieManager.set(credential.serverUrl, cookieSpec, true); + setCookieValue(cookie.value.slice(0, 12) + "…"); + setCookieHeader(`${cookie.name}=${cookie.value}`); + setImageNonce((n) => n + 1); + const stored = await CookieManager.get(credential.serverUrl, false); + append( + `session cookie installed: ${Object.keys(stored).join(",")} exp=${new Date(cookie.expiresAt).toISOString()}`, + ); + } catch (error) { + append(`session error: ${String(error)}`); + } + }; + + const testFetch = async () => { + if (!credential) return append("no credential"); + try { + const res = await fetch(`${credential.serverUrl}/api/v1/system/config`); + const text = await res.text(); + append( + `fetch /system/config → ${res.status} ${res.headers.get("content-type")} ${text.slice(0, 60)}`, + ); + } catch (error) { + append(`fetch error: ${String(error)}`); + } + }; + + const testWebSocket = () => { + if (!credential) return append("no credential"); + const url = credential.serverUrl.replace(/^http/, "ws") + "/ws"; + const ws = new WebSocket(url); + ws.onopen = () => { + append(`WS open ${url}`); + ws.send( + JSON.stringify({ type: "subscribe", target: { kind: "system" } }), + ); + setTimeout(() => ws.close(), 3000); + }; + ws.onmessage = (e) => append(`WS msg ${String(e.data).slice(0, 80)}`); + ws.onerror = (e) => append(`WS error ${JSON.stringify(e)}`); + ws.onclose = (e) => append(`WS close code=${e.code} reason=${e.reason}`); + }; + + const clearCookies = async () => { + await CookieManager.clearAll(false); + await CookieManager.clearAll(true); + setCookieValue(null); + append("cookies cleared"); + }; + + return ( + <ScrollView + style={{ flex: 1 }} + contentContainerStyle={{ + padding: 16, + paddingBottom: insets.bottom + 32, + gap: 12, + }} + keyboardDismissMode="on-drag" + > + <Text style={{ fontSize: 20, fontWeight: "600" }}> + Connect cookie spike + </Text> + <Text>apex: {APEX_URL}</Text> + <TextInput + testID="machine-code" + value={code} + onChangeText={setCode} + placeholder="machine code" + autoCapitalize="none" + autoCorrect={false} + style={{ + borderWidth: 1, + borderColor: "#999", + borderRadius: 8, + padding: 10, + }} + /> + <View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap" }}> + <SpikeButton label="Redeem" onPress={redeem} /> + <SpikeButton label="Mint session + cookie" onPress={mintSession} /> + <SpikeButton label="fetch config" onPress={testFetch} /> + <SpikeButton label="WS" onPress={testWebSocket} /> + <SpikeButton + label="WebView" + onPress={() => setShowWebView((v) => !v)} + /> + <SpikeButton label="Clear cookies" onPress={clearCookies} /> + </View> + <Text> + credential:{" "} + {credential ? `${credential.handle} @ ${credential.serverUrl}` : "none"} + </Text> + <Text>cookie: {cookieValue ?? "none"}</Text> + + {credential && cookieHeader ? ( + <View style={{ gap: 4 }}> + <Text>expo-image via shared cookie jar: {imageStatus}</Text> + <Image + // Mount only after the cookie exists; skip caches so a 401 from + // an earlier attempt is never replayed. + source={{ + uri: `${credential.serverUrl}/api/v1/system/providers/codex/logo?jar=${imageNonce}`, + }} + cachePolicy="none" + style={{ width: 48, height: 48, backgroundColor: "#eee" }} + onLoad={() => setImageStatus("loaded")} + onError={(e) => setImageStatus(`error ${e.error}`)} + /> + <Text> + expo-image via explicit Cookie header: {imageStatusHeader} + </Text> + <Image + source={{ + uri: `${credential.serverUrl}/api/v1/system/providers/codex/logo?hdr=${imageNonce}`, + headers: { Cookie: cookieHeader }, + }} + cachePolicy="none" + style={{ width: 48, height: 48, backgroundColor: "#eee" }} + onLoad={() => setImageStatusHeader("loaded")} + onError={(e) => setImageStatusHeader(`error ${e.error}`)} + /> + </View> + ) : null} + + {showWebView && credential ? ( + <View style={{ height: 320, borderWidth: 1, borderColor: "#999" }}> + <WebView + source={{ uri: `${credential.serverUrl}/` }} + sharedCookiesEnabled + onLoadEnd={(e) => + append( + `WebView loaded ${e.nativeEvent.url} title=${e.nativeEvent.title}`, + ) + } + onHttpError={(e) => + append( + `WebView http error ${e.nativeEvent.statusCode} ${e.nativeEvent.url}`, + ) + } + /> + </View> + ) : null} + + <Text style={{ fontWeight: "600", marginTop: 8 }}>Log</Text> + {log.map((line, i) => ( + <Text key={i} style={{ fontFamily: "Menlo", fontSize: 12 }}> + {line} + </Text> + ))} + </ScrollView> + ); +} + +function SpikeButton({ + label, + onPress, +}: { + label: string; + onPress: () => void; +}) { + return ( + <Pressable + onPress={onPress} + style={({ pressed }) => ({ + backgroundColor: pressed ? "#1e3a8a" : "#2563eb", + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + })} + > + <Text style={{ color: "white", fontWeight: "600" }}>{label}</Text> + </Pressable> + ); +} + +// Dev-only route: inert in production bundles (see app/e2e/reset.tsx). +export default function ConnectSpikeRoute() { + if (!e2eModeEnabled) return <Redirect href="/" />; + return <ConnectSpikeScreen />; +} diff --git a/apps/mobile/app/dev/diff.tsx b/apps/mobile/app/dev/diff.tsx new file mode 100644 index 0000000000..48fba307b5 --- /dev/null +++ b/apps/mobile/app/dev/diff.tsx @@ -0,0 +1,288 @@ +// Dev-only showcase for the native diff renderer and ANSI terminal output +// (src/diff, src/ansi). Fixtures cover the shapes the timeline feeds them: +// git patches, client-core synthetic created/deleted patches, renames, +// binaries, plain-text fallbacks, and colored command output. Not product UI. +import type { TimelineFileChange } from "@bb/server-contract"; +import { Redirect } from "expo-router"; +import { useState, type ReactNode } from "react"; +import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { e2eModeEnabled } from "@/app-shell"; +import { AnsiText, TerminalOutputBlock } from "@/ansi"; +import { DiffFileCard, FileChangeDiffBlock, parseUnifiedDiff } from "@/diff"; +import { useTheme } from "@/theme/ThemeProvider"; +import type { ThemeModePreference } from "@/theme/theme-preference"; +import { Button, Text, toast } from "@/ui"; + +const ESC = "\u001b"; + +const MULTI_FILE_PATCH = `diff --git a/apps/mobile/src/diff/parse.ts b/apps/mobile/src/diff/parse.ts +index 1111111..2222222 100644 +--- a/apps/mobile/src/diff/parse.ts ++++ b/apps/mobile/src/diff/parse.ts +@@ -1,9 +1,11 @@ import type { GitDiffFileChangeKind } from "@bb/server-contract"; + export type DiffLineType = "context" | "add" | "del" | "meta"; + + export interface DiffLine { + type: DiffLineType; +- oldNo?: number; +- newNo?: number; ++ /** Line number in the old file (context and deleted lines). */ ++ oldNo?: number; ++ /** Line number in the new file (context and added lines). */ ++ newNo?: number; + text: string; + } + +@@ -120,7 +122,8 @@ function parseFileSegment(lines: readonly string[]): DiffFile | null { + const headers = readSegmentHeaders(lines); + const normalized = normalizeSegment(lines, headers); +- const parsed = parseGitDiff(normalized.join("\\n")); ++ let parsed; ++ try { ++ parsed = parseGitDiff(normalized.join("\\n")); ++ } catch { ++ return null; ++ } + const file = parsed.files[0]; + if (!file) { + return null; // a tab-indented line that is really, really long so the horizontal scroll has something to do +diff --git a/old/name.ts b/new/name.ts +similarity index 100% +rename from old/name.ts +rename to new/name.ts +diff --git a/assets/logo.png b/assets/logo.png +new file mode 100644 +index 0000000..1234567 +Binary files /dev/null and b/assets/logo.png differ +diff --git a/README.md b/README.md +deleted file mode 100644 +index e69de29..0000000 +--- a/README.md ++++ /dev/null +@@ -1,3 +0,0 @@ +-# Old readme +- +-Goodbye. +\\ No newline at end of file +`; + +const LONG_PATCH = (() => { + const lines: string[] = []; + for (let index = 1; index <= 400; index += 1) { + lines.push( + index % 7 === 0 + ? `-const v${index} = ${index};` + : index % 7 === 1 + ? `+const v${index} = ${index * 2};` + : ` const v${index} = ${index};`, + ); + } + return `diff --git a/big.ts b/big.ts\n--- a/big.ts\n+++ b/big.ts\n@@ -1,400 +1,400 @@\n${lines.join("\n")}\n`; +})(); + +function fileChange( + overrides: Partial<TimelineFileChange> & Pick<TimelineFileChange, "diff">, +): TimelineFileChange { + return { + path: "/Users/dev/repo/src/index.ts", + kind: "modify", + movePath: null, + diffStats: { added: 0, removed: 0 }, + ...overrides, + }; +} + +const FILE_CHANGES: { title: string; change: TimelineFileChange }[] = [ + { + title: "Bare hunks (provider sent @@ only)", + change: fileChange({ + diff: "@@ -10,3 +10,4 @@\n context\n-removed line\n+added line\n+another added\n context\n", + }), + }, + { + title: "Created file from content lines (no line numbers)", + change: fileChange({ + path: "/Users/dev/repo/notes.md", + kind: "create", + diff: "# Notes\n\nfirst paragraph\nsecond paragraph\n", + }), + }, + { + title: "Deleted file", + change: fileChange({ + path: "/Users/dev/repo/scratch.txt", + kind: "delete", + diff: "-temporary\n-content\n", + }), + }, + { + title: "Plain-text fallback", + change: fileChange({ + diff: "Applied edit to src/index.ts (3 lines changed)", + }), + }, + { + title: "No diff", + change: fileChange({ diff: null }), + }, +]; + +const ANSI_SAMPLES: { title: string; output: string; command?: string }[] = [ + { + title: "pnpm test (16 colors, bold, dim)", + command: "$ pnpm exec vitest run src/diff", + output: [ + `${ESC}[1m${ESC}[46m RUN ${ESC}[0m ${ESC}[36mv4.1.1${ESC}[0m ${ESC}[90m/Users/dev/repo/apps/mobile${ESC}[0m`, + "", + ` ${ESC}[32m✓${ESC}[0m src/diff/parse-unified-diff.test.ts ${ESC}[2m(12 tests)${ESC}[0m ${ESC}[33m 8ms${ESC}[0m`, + ` ${ESC}[31m✗${ESC}[0m src/diff/file-change-diff.test.ts ${ESC}[2m(1 test | ${ESC}[31m1 failed${ESC}[0m${ESC}[2m)${ESC}[0m`, + ` ${ESC}[31m→${ESC}[0m expected ${ESC}[32m'added'${ESC}[0m to be ${ESC}[31m'modified'${ESC}[0m`, + "", + ` ${ESC}[2mTest Files${ESC}[0m ${ESC}[1;31m1 failed${ESC}[0m | ${ESC}[1;32m1 passed${ESC}[0m ${ESC}[90m(2)${ESC}[0m`, + ` ${ESC}[2m Tests${ESC}[0m ${ESC}[1;31m1 failed${ESC}[0m | ${ESC}[1;32m18 passed${ESC}[0m ${ESC}[90m(19)${ESC}[0m`, + ].join("\n"), + }, + { + title: "256-color + truecolor + underline + inverse + progress \\r", + command: "$ ./build.sh --verbose", + output: [ + `${ESC}[38;5;208mwarning${ESC}[0m: ${ESC}[4mdeprecated API${ESC}[24m used in ${ESC}[38;2;100;149;237mmain.ts${ESC}[0m`, + `${ESC}[7m INFO ${ESC}[27m building…`, + `progress 10%\rprogress 50%\rprogress 100% ${ESC}[92mdone${ESC}[0m`, + `${ESC}[48;5;196m${ESC}[97m FATAL ${ESC}[0m ${ESC}[3mitalic detail${ESC}[0m ${ESC}[9mstruck${ESC}[0m`, + `${ESC}[2J${ESC}[H${ESC}[?25lcursor codes stripped${ESC}[?25h`, + `${ESC}]8;;https://example.com${ESC}\\hyperlink text${ESC}]8;;${ESC}\\ survives`, + ].join("\n"), + }, + { + title: "Long output collapses to its tail", + command: + '$ for i in $(seq 1 60); do echo "line $i of a fairly long command output that also scrolls horizontally"; done', + output: Array.from( + { length: 60 }, + (_, index) => + `${ESC}[90m${String(index + 1).padStart(2, " ")}${ESC}[0m line ${index + 1} of a fairly long command output that also scrolls horizontally past the edge`, + ).join("\n"), + }, +]; + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( + <View className="gap-3"> + <Text variant="sectionLabel">{title}</Text> + {children} + </View> + ); +} + +const MODES: ThemeModePreference[] = ["system", "light", "dark"]; + +function DiffShowcaseScreen() { + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const [showAddToChat, setShowAddToChat] = useState(true); + const parsed = parseUnifiedDiff(MULTI_FILE_PATCH); + const longFile = parseUnifiedDiff(LONG_PATCH).files[0]; + + return ( + <ScrollView + className="flex-1 bg-background" + contentContainerStyle={{ + padding: 16, + paddingBottom: insets.bottom + 32, + gap: 24, + }} + testID="dev-diff-screen" + > + <Section title={`Theme — ${theme.palette} / ${theme.mode}`}> + <View className="flex-row flex-wrap gap-2"> + {MODES.map((mode) => ( + <Button + key={mode} + size="sm" + variant={theme.preference === mode ? "default" : "outline"} + onPress={() => theme.setMode(mode)} + > + {mode} + </Button> + ))} + <Button + size="sm" + variant="outline" + pressed={showAddToChat} + onPress={() => setShowAddToChat((value) => !value)} + > + Add-to-chat action + </Button> + </View> + </Section> + + <Section + title={`Multi-file patch — ${parsed.stats.files} files, +${parsed.stats.additions} -${parsed.stats.deletions}`} + > + {parsed.files.map((file) => ( + <DiffFileCard + key={`${file.previousPath ?? ""}→${file.path}`} + file={file} + onAddToChat={ + showAddToChat + ? (target) => toast.message(`Add to chat: ${target.path}`) + : undefined + } + testID={`dev-diff-card-${file.path.replaceAll("/", "-")}`} + /> + ))} + </Section> + + <Section title="Timeline file-change rows (FileChangeDiffBlock)"> + {FILE_CHANGES.map(({ title, change }) => ( + <View key={title} className="gap-1.5"> + <Text variant="caption">{title}</Text> + <FileChangeDiffBlock + change={change} + workspaceRootPath="/Users/dev/repo" + onAddToChat={ + showAddToChat + ? (target) => toast.message(`Add to chat: ${target.path}`) + : undefined + } + /> + </View> + ))} + </Section> + + <Section title="Terminal output (ANSI)"> + {ANSI_SAMPLES.map(({ title, output, command }, index) => ( + <View key={title} className="gap-1.5"> + <Text variant="caption">{title}</Text> + <TerminalOutputBlock + commandLine={command} + output={output} + exitCode={index === 0 ? 1 : 0} + metadataLines={index === 1 ? ["source: agent"] : undefined} + testID={`dev-terminal-${index}`} + /> + </View> + ))} + <View className="gap-1.5"> + <Text variant="caption">Inline AnsiText</Text> + <AnsiText + text={`${ESC}[1;35mbold magenta${ESC}[0m, ${ESC}[33myellow${ESC}[0m, ${ESC}[44;97m white on blue ${ESC}[0m, ${ESC}[2mdim${ESC}[0m`} + /> + </View> + </Section> + <Section title="Large hunk — collapses behind Show more"> + {longFile ? ( + <DiffFileCard file={longFile} maxLines={40} testID="dev-diff-long" /> + ) : null} + </Section> + </ScrollView> + ); +} + +// Dev-only route: inert in production bundles (see app/e2e/reset.tsx). +export default function DiffShowcaseRoute() { + if (!e2eModeEnabled) return <Redirect href="/" />; + return <DiffShowcaseScreen />; +} diff --git a/apps/mobile/app/dev/interactions.tsx b/apps/mobile/app/dev/interactions.tsx new file mode 100644 index 0000000000..68c03316b8 --- /dev/null +++ b/apps/mobile/app/dev/interactions.tsx @@ -0,0 +1,9 @@ +// Dev-only showcase: inert in production bundles (see app/e2e/reset.tsx). +import { Redirect } from "expo-router"; +import { e2eModeEnabled } from "@/app-shell"; +import { InteractionsShowcaseScreen } from "@/screens/dev/InteractionsShowcaseScreen"; + +export default function InteractionsRoute() { + if (!e2eModeEnabled) return <Redirect href="/" />; + return <InteractionsShowcaseScreen />; +} diff --git a/apps/mobile/app/dev/markdown.tsx b/apps/mobile/app/dev/markdown.tsx new file mode 100644 index 0000000000..b4f7cea618 --- /dev/null +++ b/apps/mobile/app/dev/markdown.tsx @@ -0,0 +1,9 @@ +// Dev-only showcase: inert in production bundles (see app/e2e/reset.tsx). +import { Redirect } from "expo-router"; +import { e2eModeEnabled } from "@/app-shell"; +import { MarkdownShowcaseScreen } from "@/screens/dev/MarkdownShowcaseScreen"; + +export default function MarkdownRoute() { + if (!e2eModeEnabled) return <Redirect href="/" />; + return <MarkdownShowcaseScreen />; +} diff --git a/apps/mobile/app/dev/spike.tsx b/apps/mobile/app/dev/spike.tsx new file mode 100644 index 0000000000..18db9a9a92 --- /dev/null +++ b/apps/mobile/app/dev/spike.tsx @@ -0,0 +1,374 @@ +// Phase 0 runtime spike screen. Not product UI. +// +// Verifies, on a real device runtime: +// - workspace packages (@bb/*) resolve and evaluate under Hermes, +// - @bb/sdk/browser can call the server and open the realtime socket, +// - the Origin guard accepts RN's WebSocket, +// - the winter polyfills the shared code relies on exist, +// - a TextInput can render styled ranges (composer mention model spike). +import { builtInThemes } from "@bb/domain"; +import { deriveConnectBaseUrl } from "@bb/connect-client"; +import { createPublicApiClient } from "@bb/server-contract"; +import { createBrowserBbSdk, type BrowserBbSdk } from "@bb/sdk/browser"; +import { fileNameFromPath } from "@bb/thread-view"; +import { Link, Redirect } from "expo-router"; +import { version as reactVersion } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { e2eModeEnabled } from "@/app-shell"; + +const DEFAULT_SERVER_URL = + process.env.EXPO_PUBLIC_BB_SERVER_URL ?? "http://127.0.0.1:20304"; + +type CheckResult = { name: string; ok: boolean; detail: string }; + +function runRuntimeChecks(): CheckResult[] { + const results: CheckResult[] = []; + const check = (name: string, fn: () => string) => { + try { + results.push({ name, ok: true, detail: fn() }); + } catch (error) { + results.push({ name, ok: false, detail: String(error) }); + } + }; + check("react version", () => reactVersion); + check("@bb/domain builtInThemes", () => String(builtInThemes.length)); + check("@bb/thread-view fileNameFromPath", () => fileNameFromPath("a/b/c.ts")); + check("@bb/connect-client deriveConnectBaseUrl", () => + deriveConnectBaseUrl("https://bee.getbb.app"), + ); + check( + "@bb/server-contract createPublicApiClient", + () => typeof createPublicApiClient("http://x").system.config.$get, + ); + check("crypto.getRandomValues", () => { + const arr = new Uint8Array(4); + globalThis.crypto.getRandomValues(arr); + return Array.from(arr).join(","); + }); + check("URL setters + searchParams", () => { + const u = new URL("http://h:1/p?a=1#f"); + u.protocol = "ws:"; + u.pathname = "/ws"; + u.search = ""; + u.hash = ""; + return u.toString(); + }); + check("TextDecoder fatal throws on invalid utf-8", () => { + try { + new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array([0xff])); + } catch { + return "throws (good)"; + } + throw new Error("did not throw"); + }); + check("structuredClone", () => JSON.stringify(structuredClone({ a: [1] }))); + check("AbortSignal.timeout", () => typeof AbortSignal.timeout); + check( + "fetch impl", + () => (globalThis.fetch as { name?: string }).name ?? "?", + ); + check("Blob from ArrayBuffer", () => { + const b = new Blob([ + new Uint8Array([1, 2, 3]).buffer as unknown as BlobPart, + ]); + return `size=${b.size}`; + }); + check("FormData.set", () => typeof new FormData().set); + return results; +} + +function SpikeScreen() { + const insets = useSafeAreaInsets(); + const [serverUrl, setServerUrl] = useState(DEFAULT_SERVER_URL); + const [log, setLog] = useState<string[]>([]); + const [wsState, setWsState] = useState("idle"); + const sdkRef = useRef<BrowserBbSdk | null>(null); + const unsubscribeRef = useRef<(() => void)[]>([]); + const checks = useMemo(() => runRuntimeChecks(), []); + + // Leaving the screen closes the SDK realtime socket (the client keeps + // reconnecting and logging errors otherwise). + useEffect( + () => () => { + unsubscribeRef.current.forEach((fn) => fn()); + unsubscribeRef.current = []; + }, + [], + ); + + const append = (line: string) => + setLog((prev) => + [`${new Date().toISOString().slice(11, 19)} ${line}`, ...prev].slice( + 0, + 40, + ), + ); + + const getSdk = () => { + if (!sdkRef.current) { + sdkRef.current = createBrowserBbSdk({ + baseUrl: serverUrl, + fetch: (input, init) => { + // Never spread a Headers instance (RN's polyfill exposes internal + // fields as enumerable props, which expo/fetch cannot cast). + const headers = new Headers(init?.headers); + headers.set("x-bb-app-surface", "mobile"); + return fetch(input, { ...init, headers }); + }, + }); + } + return sdkRef.current; + }; + + const probeHttp = async () => { + try { + const config = await getSdk().system.config(); + append( + `HTTP ok: serverUrl=${config.serverUrl} primaryHostId=${config.primaryHostId} voice=${config.voiceTranscriptionEnabled}`, + ); + } catch (error) { + append(`HTTP error: ${String(error)}`); + } + }; + + const openRealtime = () => { + const sdk = getSdk(); + unsubscribeRef.current.forEach((fn) => fn()); + unsubscribeRef.current = [ + sdk.subscribe({ + event: "realtime:connection", + callback: (event) => { + setWsState(event.state); + append( + `WS ${event.state}${event.reconnected ? " (reconnected)" : ""}`, + ); + }, + }), + sdk.subscribe({ + event: "system:changed", + callback: (event) => + append(`system changed: ${event.changes.join(",")}`), + }), + sdk.subscribe({ + event: "thread:changed", + callback: (event) => + append( + `thread changed ${event.id ?? "?"}: ${event.changes.join(",")}`, + ), + }), + ]; + }; + + const pokeSystem = async () => { + try { + await getSdk().system.reloadConfig(); + append("poked: POST /system/config/reload"); + } catch (error) { + append(`poke error: ${String(error)}`); + } + }; + + const rawWebSocket = () => { + const url = serverUrl.replace(/^http/, "ws") + "/ws"; + const ws = new WebSocket(url); + ws.onopen = () => { + append(`raw WS open ${url}`); + ws.send( + JSON.stringify({ type: "subscribe", target: { kind: "system" } }), + ); + }; + ws.onmessage = (e) => append(`raw WS msg ${String(e.data).slice(0, 80)}`); + ws.onerror = (e) => append(`raw WS error ${JSON.stringify(e)}`); + ws.onclose = (e) => + append(`raw WS close code=${e.code} reason=${e.reason}`); + }; + + return ( + <ScrollView + style={{ flex: 1 }} + contentContainerStyle={{ + padding: 16, + paddingBottom: insets.bottom + 32, + gap: 12, + }} + keyboardDismissMode="on-drag" + > + <Text style={{ fontSize: 20, fontWeight: "600" }}> + bb mobile — Phase 0 spike + </Text> + + <Text style={{ fontWeight: "600" }}>Server URL</Text> + <TextInput + testID="server-url" + value={serverUrl} + onChangeText={setServerUrl} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + style={{ + borderWidth: 1, + borderColor: "#999", + borderRadius: 8, + padding: 10, + }} + /> + <View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap" }}> + <SpikeButton + testID="probe-http" + label="Probe HTTP" + onPress={probeHttp} + /> + <SpikeButton + testID="open-realtime" + label="Open realtime (sdk)" + onPress={openRealtime} + /> + <SpikeButton testID="raw-ws" label="Raw /ws" onPress={rawWebSocket} /> + <SpikeButton + testID="poke-system" + label="Poke (reload config)" + onPress={pokeSystem} + /> + <SpikeButton + testID="clear-log" + label="Clear" + onPress={() => { + unsubscribeRef.current.forEach((fn) => fn()); + unsubscribeRef.current = []; + sdkRef.current = null; + setLog([]); + setWsState("idle"); + }} + /> + </View> + <Text testID="ws-state">realtime: {wsState}</Text> + <Link href="/dev/connect-spike" style={{ color: "#2563eb" }}> + Connect cookie spike → + </Link> + + <Text style={{ fontWeight: "600", marginTop: 8 }}>Log</Text> + {log.slice(0, 8).map((line, i) => ( + <Text + key={i} + testID={i === 0 ? "log-latest" : undefined} + style={{ fontFamily: "Menlo", fontSize: 12 }} + > + {line} + </Text> + ))} + + <Text style={{ fontWeight: "600", marginTop: 8 }}> + Composer range spike + </Text> + <MentionRangeInput /> + + <Text style={{ fontWeight: "600", marginTop: 8 }}>Runtime checks</Text> + {checks.map((c) => ( + <Text + key={c.name} + testID={`check-${c.name}`} + style={{ color: c.ok ? "#166534" : "#991b1b" }} + > + {c.ok ? "✓" : "✗"} {c.name}: {c.detail} + </Text> + ))} + </ScrollView> + ); +} + +function SpikeButton({ + label, + onPress, + testID, +}: { + label: string; + onPress: () => void; + testID: string; +}) { + return ( + <Pressable + testID={testID} + onPress={onPress} + style={({ pressed }) => ({ + backgroundColor: pressed ? "#1e3a8a" : "#2563eb", + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + })} + > + <Text style={{ color: "white", fontWeight: "600" }}>{label}</Text> + </Pressable> + ); +} + +/** + * Prototype of the composer mention model: a plain-text TextInput whose + * children are styled Text spans for `@token` ranges. Tests whether inline + * styled ranges are viable (cursor stability, IME, Android) before choosing + * between inline pills and a chip strip. + */ +function MentionRangeInput() { + const [text, setText] = useState( + "Ask @thread-a about @path/to/file.ts and /commit", + ); + const parts = useMemo(() => { + const out: { text: string; kind: "plain" | "mention" | "command" }[] = []; + const re = /(@[\w./-]+|\/[\w-]+)/g; + let last = 0; + for (const m of text.matchAll(re)) { + const start = m.index ?? 0; + if (start > last) + out.push({ text: text.slice(last, start), kind: "plain" }); + out.push({ + text: m[0], + kind: m[0].startsWith("@") ? "mention" : "command", + }); + last = start + m[0].length; + } + if (last < text.length) out.push({ text: text.slice(last), kind: "plain" }); + return out; + }, [text]); + return ( + <TextInput + testID="mention-input" + multiline + onChangeText={setText} + style={{ + borderWidth: 1, + borderColor: "#999", + borderRadius: 8, + padding: 10, + minHeight: 60, + }} + > + <Text> + {parts.map((p, i) => ( + <Text + key={i} + style={ + p.kind === "mention" + ? { + color: "#1d4ed8", + backgroundColor: "#dbeafe", + fontWeight: "600", + } + : p.kind === "command" + ? { color: "#7c3aed", fontWeight: "600" } + : undefined + } + > + {p.text} + </Text> + ))} + </Text> + </TextInput> + ); +} + +// Dev-only route: inert in production bundles (see app/e2e/reset.tsx). +export default function SpikeRoute() { + if (!e2eModeEnabled) return <Redirect href="/" />; + return <SpikeScreen />; +} diff --git a/apps/mobile/app/dev/ui.tsx b/apps/mobile/app/dev/ui.tsx new file mode 100644 index 0000000000..286d9f04ac --- /dev/null +++ b/apps/mobile/app/dev/ui.tsx @@ -0,0 +1,428 @@ +// Dev-only gallery: renders every primitive in src/ui so the design system +// can be eyeballed per palette × mode on the simulator. Not product UI. +import { BUILTIN_THEME_IDS } from "@bb/domain"; +import { Redirect } from "expo-router"; +import { useMemo, useState, type ReactNode } from "react"; +import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { e2eModeEnabled } from "@/app-shell"; +import { VoiceBar, type VoiceBarController } from "@/composer"; +import { useTheme } from "@/theme/ThemeProvider"; +import type { ThemeModePreference } from "@/theme/theme-preference"; +import { + ActionSheet, + Badge, + Button, + EmptyState, + EmptyStatePanel, + ICON_NAMES, + Icon, + Input, + ListRow, + Pill, + Separator, + Sheet, + Skeleton, + Spinner, + Switch, + Text, + TextArea, + toast, + useSheet, +} from "@/ui"; + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( + <View className="gap-3"> + <Text variant="sectionLabel">{title}</Text> + {children} + </View> + ); +} + +const MODES: ThemeModePreference[] = ["system", "light", "dark"]; + +/** + * Speech-like synthetic input levels for the voice bar showcase: a slow + * syllable envelope with jitter, so the waveform scrolls without a mic. + */ +function syntheticVoiceLevel(): number { + const t = Date.now() / 1000; + const syllable = + Math.max(0, Math.sin(t * 5.3)) * (0.6 + 0.4 * Math.sin(t * 0.7)); + const pause = Math.sin(t * 0.45) > 0.75 ? 0 : 1; + const jitter = 0.75 + Math.random() * 0.25; + return Math.min(1, 0.06 + syllable * jitter * pause); +} + +function UiGalleryScreen() { + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const [checked, setChecked] = useState(true); + const [text, setText] = useState(""); + const [pressed, setPressed] = useState(false); + const [voiceState, setVoiceState] = useState<"recording" | "transcribing">( + "recording", + ); + const voice = useMemo( + (): VoiceBarController => ({ + state: voiceState, + readLevel: syntheticVoiceLevel, + stop: async () => setVoiceState("transcribing"), + cancel: () => setVoiceState("recording"), + }), + [voiceState], + ); + const sheet = useSheet(); + const scrollSheet = useSheet(); + const menu = useSheet(); + + return ( + <ScrollView + className="flex-1 bg-background" + contentContainerStyle={{ + padding: 16, + paddingBottom: insets.bottom + 32, + gap: 24, + }} + keyboardDismissMode="on-drag" + > + <Section + title={`Theme — ${theme.palette} / ${theme.mode} (pref ${theme.preference})`} + > + <View className="flex-row flex-wrap gap-2"> + {MODES.map((mode) => ( + <Button + key={mode} + size="sm" + variant={theme.preference === mode ? "default" : "outline"} + onPress={() => theme.setMode(mode)} + > + {mode} + </Button> + ))} + </View> + <Text variant="caption"> + Palettes come from the server ({BUILTIN_THEME_IDS.join(", ")}); the + integrator passes `palette` to UiProvider. + </Text> + <View className="flex-row flex-wrap gap-2"> + {( + [ + "background", + "foreground", + "primary", + "secondary", + "muted", + "accent", + "destructive", + "attention", + "warning", + "success", + "sidebar", + "border", + ] as const + ).map((key) => ( + <View key={key} className="items-center gap-1"> + <View + className="h-8 w-8 rounded-md border border-border" + style={{ backgroundColor: theme.tokens[key] }} + /> + <Text variant="chrome">{key}</Text> + </View> + ))} + </View> + </Section> + + <Section title="Text"> + <Text variant="title">Title — Inter SemiBold 18</Text> + <Text variant="heading">Heading — 16 semibold</Text> + <Text variant="label">Label — 15 medium</Text> + <Text variant="body"> + Body — 15 regular. The quick brown fox jumps over the lazy dog. + </Text> + <Text variant="bodyLarge">Body large — 16 regular.</Text> + <Text variant="caption">Caption — 14 muted</Text> + <Text variant="chrome">CHROME — 11 muted</Text> + <Text variant="mono">mono — const x = fn(a) => 0x1F;</Text> + <Text className="text-sm font-semibold text-destructive-text"> + className-driven: font-semibold text-destructive-text + </Text> + <View className="flex-row gap-3"> + <Text tone="muted">muted</Text> + <Text tone="subtle">subtle</Text> + <Text tone="readback">readback</Text> + <Text tone="primary">primary</Text> + <Text tone="warning">warning</Text> + <Text tone="success">success</Text> + </View> + </Section> + + <Section title="Button"> + <View className="flex-row flex-wrap gap-2"> + <Button + onPress={() => + toast.success("Saved", { description: "Default button" }) + } + > + Default + </Button> + <Button + variant="secondary" + icon="Plus" + onPress={() => toast.info("Secondary")} + > + Secondary + </Button> + <Button + variant="outline" + icon="Copy" + onPress={() => toast.message("Outline")} + > + Outline + </Button> + <Button + variant="ghost" + onPress={() => toast.warning("Ghost pressed")} + > + Ghost + </Button> + <Button + variant="destructive" + icon="Trash2" + onPress={() => toast.error("Deleted")} + > + Destructive + </Button> + <Button variant="link" onPress={() => undefined}> + Link + </Button> + </View> + <View className="flex-row flex-wrap items-center gap-2"> + <Button size="sm">Small</Button> + <Button size="lg">Large</Button> + <Button size="icon" icon="Settings" accessibilityLabel="Settings" /> + <Button + size="icon" + variant="ghost" + icon="MoreHorizontal" + accessibilityLabel="More" + /> + <Button loading>Loading</Button> + <Button disabled>Disabled</Button> + <Button + variant="ghost" + icon="Pin" + pressed={pressed} + haptic + onPress={() => setPressed((value) => !value)} + > + {pressed ? "Pinned" : "Pin"} + </Button> + </View> + </Section> + + <Section title="Badge + Pill"> + <View className="flex-row flex-wrap gap-2"> + <Badge>Default</Badge> + <Badge variant="secondary">Secondary</Badge> + <Badge variant="destructive">Destructive</Badge> + <Badge variant="outline">Outline</Badge> + </View> + <View className="flex-row flex-wrap gap-2"> + <Pill variant="secondary">secondary</Pill> + <Pill variant="outline">outline</Pill> + <Pill variant="emphasis">emphasis</Pill> + <Pill variant="destructive">destructive</Pill> + <Pill variant="secondary" size="sm"> + sm + </Pill> + </View> + </Section> + + <Section title="Input + TextArea + Switch"> + <Input + placeholder="Server URL" + value={text} + onChangeText={setText} + mono + keyboardType="url" + /> + <Input placeholder="Invalid" invalid /> + <Input placeholder="Disabled" editable={false} /> + <TextArea placeholder="Prompt…" /> + <View className="flex-row items-center justify-between"> + <Text variant="label">Notifications</Text> + <Switch checked={checked} onCheckedChange={setChecked} /> + </View> + <View className="flex-row items-center justify-between"> + <Text variant="label">Small switch</Text> + <Switch size="sm" checked={checked} onCheckedChange={setChecked} /> + </View> + </Section> + + <Section title="ListRow + Separator"> + <View className="overflow-hidden rounded-lg border border-border"> + <ListRow + leading="Folder" + title="bb" + subtitle="~/code/bb · main" + trailing="chevron" + onPress={() => toast.message("Row pressed")} + onLongPress={menu.present} + /> + <Separator inset={52} /> + <ListRow + leading="MessageSquare" + title="A very long thread title that should truncate at one line no matter what" + subtitle="2 minutes ago" + trailing={ + <Pill variant="secondary" size="sm"> + running + </Pill> + } + selected + onPress={() => undefined} + /> + <Separator inset={52} /> + <ListRow + leading="Trash2" + title="Delete thread" + destructive + onPress={menu.present} + /> + <Separator inset={52} /> + <ListRow + leading="Lock" + title="Disabled row" + disabled + onPress={() => undefined} + /> + </View> + <Text variant="caption"> + Long-press the first row for an ActionSheet. + </Text> + </Section> + + <Section title="Voice bar (synthetic levels)"> + <View + className="rounded-2xl border border-border bg-card" + testID="dev-ui-voice-bar" + > + <VoiceBar voice={voice} /> + </View> + <Text variant="caption"> + Check → transcribing (frozen, breathing); X → back to recording. + </Text> + </Section> + + <Section title="Skeleton + Spinner + EmptyState"> + <View className="gap-2"> + <Skeleton className="h-4 w-2/3" /> + <Skeleton className="h-4 w-1/2" /> + <Skeleton className="h-10 w-full" /> + </View> + <View className="flex-row items-center gap-3"> + <Spinner /> + <Spinner size="large" /> + <Text variant="caption">Spinner</Text> + </View> + <EmptyState icon="Archive" message="No archived threads." /> + <EmptyStatePanel>Nothing here yet.</EmptyStatePanel> + </Section> + + <Section title="Sheets"> + <View className="flex-row flex-wrap gap-2"> + <Button variant="outline" onPress={sheet.present}> + Sheet + </Button> + <Button variant="outline" onPress={scrollSheet.present}> + Scroll sheet + </Button> + <Button variant="outline" onPress={menu.present}> + ActionSheet + </Button> + </View> + </Section> + + <Section title={`Icons (${ICON_NAMES.length})`}> + <View className="flex-row flex-wrap gap-3"> + {ICON_NAMES.map((name) => ( + <View key={name} className="w-16 items-center gap-1"> + <Icon name={name} /> + <Text variant="chrome" numberOfLines={1}> + {name} + </Text> + </View> + ))} + </View> + </Section> + + <Sheet controller={sheet} title="Sheet title"> + <View className="gap-3 p-4"> + <Text>Content realized two frames after presenting.</Text> + <Input placeholder="Type here (keyboard-aware)" /> + <Button onPress={sheet.dismiss}>Done</Button> + </View> + </Sheet> + + <Sheet + controller={scrollSheet} + title="Scroll sheet" + layout="scroll" + snapPoints={["50%", "90%"]} + > + <View className="gap-2 p-4"> + {Array.from({ length: 40 }, (_, index) => ( + <Text key={index}>Row {index + 1}</Text> + ))} + </View> + </Sheet> + + <ActionSheet + controller={menu} + title="Thread" + message="bb · main" + actions={[ + { + key: "open", + label: "Open", + icon: "ArrowUpRight", + onPress: () => toast.message("Open"), + }, + { + key: "pin", + label: "Pin", + icon: "Pin", + onPress: () => toast.message("Pin"), + }, + { + key: "rename", + label: "Rename", + icon: "Edit", + onPress: () => toast.message("Rename"), + }, + { + key: "archive", + label: "Archive", + icon: "Archive", + onPress: () => toast.message("Archive"), + }, + { + key: "delete", + label: "Delete", + icon: "Trash2", + destructive: true, + onPress: () => toast.error("Deleted"), + }, + ]} + /> + </ScrollView> + ); +} + +// Dev-only route: inert in production bundles (see app/e2e/reset.tsx). +export default function UiGalleryRoute() { + if (!e2eModeEnabled) return <Redirect href="/" />; + return <UiGalleryScreen />; +} diff --git a/apps/mobile/app/dev/work-rows.tsx b/apps/mobile/app/dev/work-rows.tsx new file mode 100644 index 0000000000..bc01ec6435 --- /dev/null +++ b/apps/mobile/app/dev/work-rows.tsx @@ -0,0 +1,9 @@ +// Dev-only showcase: inert in production bundles (see app/e2e/reset.tsx). +import { Redirect } from "expo-router"; +import { e2eModeEnabled } from "@/app-shell"; +import { WorkRowsShowcaseScreen } from "@/screens/dev/WorkRowsShowcaseScreen"; + +export default function WorkRowsRoute() { + if (!e2eModeEnabled) return <Redirect href="/" />; + return <WorkRowsShowcaseScreen />; +} diff --git a/apps/mobile/app/e2e/reset.tsx b/apps/mobile/app/e2e/reset.tsx new file mode 100644 index 0000000000..24a289f0eb --- /dev/null +++ b/apps/mobile/app/e2e/reset.tsx @@ -0,0 +1,45 @@ +// e2e/dev-only entry (`bb://e2e/reset`): wipe local state and go home, so +// Maestro flows and developers can return the simulator to first run +// without reinstalling. Inert in production bundles. +import { Redirect, useRouter } from "expo-router"; +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { e2eModeEnabled, resetLocalState } from "@/app-shell"; +import { Spinner, Text } from "@/ui"; + +export default function E2eResetRoute() { + const router = useRouter(); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (!e2eModeEnabled) return; + let cancelled = false; + resetLocalState() + .then(() => { + if (!cancelled) router.dismissTo("/"); + }) + .catch((cause: unknown) => { + if (!cancelled) setError(String(cause)); + }); + return () => { + cancelled = true; + }; + }, [router]); + + if (!e2eModeEnabled) return <Redirect href="/" />; + return ( + <View + className="flex-1 items-center justify-center gap-3 bg-background" + testID="e2e-reset-screen" + > + {error ? ( + <Text tone="destructive">{error}</Text> + ) : ( + <> + <Spinner /> + <Text variant="caption">Resetting local state…</Text> + </> + )} + </View> + ); +} diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx new file mode 100644 index 0000000000..374c40a608 --- /dev/null +++ b/apps/mobile/app/index.tsx @@ -0,0 +1,3 @@ +import { HomeScreen } from "@/screens"; + +export default HomeScreen; diff --git a/apps/mobile/app/projects/[id]/settings.tsx b/apps/mobile/app/projects/[id]/settings.tsx new file mode 100644 index 0000000000..5b855178da --- /dev/null +++ b/apps/mobile/app/projects/[id]/settings.tsx @@ -0,0 +1,3 @@ +import { ProjectSettingsScreen } from "@/screens"; + +export default ProjectSettingsScreen; diff --git a/apps/mobile/app/projects/[id]/threads/[threadId].tsx b/apps/mobile/app/projects/[id]/threads/[threadId].tsx new file mode 100644 index 0000000000..845df5da2a --- /dev/null +++ b/apps/mobile/app/projects/[id]/threads/[threadId].tsx @@ -0,0 +1,8 @@ +import { Redirect, useLocalSearchParams } from "expo-router"; +import { threadHref } from "@/screens/shell/hrefs"; + +/** Web deep-link alias (`/projects/:projectId/threads/:threadId`) → `/threads/:id`. */ +export default function ProjectThreadAlias() { + const { threadId } = useLocalSearchParams<{ threadId: string }>(); + return <Redirect href={threadHref(threadId)} />; +} diff --git a/apps/mobile/app/projects/new.tsx b/apps/mobile/app/projects/new.tsx new file mode 100644 index 0000000000..af16a77355 --- /dev/null +++ b/apps/mobile/app/projects/new.tsx @@ -0,0 +1,3 @@ +import { NewProjectScreen } from "@/screens"; + +export default NewProjectScreen; diff --git a/apps/mobile/app/settings/appearance.tsx b/apps/mobile/app/settings/appearance.tsx new file mode 100644 index 0000000000..6b76963b6f --- /dev/null +++ b/apps/mobile/app/settings/appearance.tsx @@ -0,0 +1,3 @@ +import { AppearanceSettingsScreen } from "@/screens"; + +export default AppearanceSettingsScreen; diff --git a/apps/mobile/app/settings/archived.tsx b/apps/mobile/app/settings/archived.tsx new file mode 100644 index 0000000000..a749692d50 --- /dev/null +++ b/apps/mobile/app/settings/archived.tsx @@ -0,0 +1,3 @@ +import { ArchivedThreadsScreen } from "@/screens"; + +export default ArchivedThreadsScreen; diff --git a/apps/mobile/app/settings/experiments.tsx b/apps/mobile/app/settings/experiments.tsx new file mode 100644 index 0000000000..82f795bf89 --- /dev/null +++ b/apps/mobile/app/settings/experiments.tsx @@ -0,0 +1,3 @@ +import { ExperimentsSettingsScreen } from "@/screens"; + +export default ExperimentsSettingsScreen; diff --git a/apps/mobile/app/settings/general.tsx b/apps/mobile/app/settings/general.tsx new file mode 100644 index 0000000000..a24731145c --- /dev/null +++ b/apps/mobile/app/settings/general.tsx @@ -0,0 +1,3 @@ +import { GeneralSettingsScreen } from "@/screens"; + +export default GeneralSettingsScreen; diff --git a/apps/mobile/app/settings/index.tsx b/apps/mobile/app/settings/index.tsx new file mode 100644 index 0000000000..601775f6fb --- /dev/null +++ b/apps/mobile/app/settings/index.tsx @@ -0,0 +1,3 @@ +import { SettingsScreen } from "@/screens"; + +export default SettingsScreen; diff --git a/apps/mobile/app/settings/machines/[hostId].tsx b/apps/mobile/app/settings/machines/[hostId].tsx new file mode 100644 index 0000000000..33873bbf2f --- /dev/null +++ b/apps/mobile/app/settings/machines/[hostId].tsx @@ -0,0 +1,3 @@ +import { MachineDetailScreen } from "@/screens"; + +export default MachineDetailScreen; diff --git a/apps/mobile/app/settings/machines/index.tsx b/apps/mobile/app/settings/machines/index.tsx new file mode 100644 index 0000000000..bddc501403 --- /dev/null +++ b/apps/mobile/app/settings/machines/index.tsx @@ -0,0 +1,3 @@ +import { MachinesScreen } from "@/screens"; + +export default MachinesScreen; diff --git a/apps/mobile/app/settings/marketplaces.tsx b/apps/mobile/app/settings/marketplaces.tsx new file mode 100644 index 0000000000..d1b2ff62b4 --- /dev/null +++ b/apps/mobile/app/settings/marketplaces.tsx @@ -0,0 +1,3 @@ +import { MarketplacesScreen } from "@/screens/plugins"; + +export default MarketplacesScreen; diff --git a/apps/mobile/app/settings/plugins/[pluginId]/index.tsx b/apps/mobile/app/settings/plugins/[pluginId]/index.tsx new file mode 100644 index 0000000000..c96903bb97 --- /dev/null +++ b/apps/mobile/app/settings/plugins/[pluginId]/index.tsx @@ -0,0 +1,3 @@ +import { PluginDetailScreen } from "@/screens/plugins"; + +export default PluginDetailScreen; diff --git a/apps/mobile/app/settings/plugins/[pluginId]/logs.tsx b/apps/mobile/app/settings/plugins/[pluginId]/logs.tsx new file mode 100644 index 0000000000..97d3066562 --- /dev/null +++ b/apps/mobile/app/settings/plugins/[pluginId]/logs.tsx @@ -0,0 +1,3 @@ +import { PluginLogsScreen } from "@/screens/plugins"; + +export default PluginLogsScreen; diff --git a/apps/mobile/app/settings/plugins/browse.tsx b/apps/mobile/app/settings/plugins/browse.tsx new file mode 100644 index 0000000000..bbb0d129c6 --- /dev/null +++ b/apps/mobile/app/settings/plugins/browse.tsx @@ -0,0 +1,3 @@ +import { PluginBrowseScreen } from "@/screens/plugins"; + +export default PluginBrowseScreen; diff --git a/apps/mobile/app/settings/plugins/index.tsx b/apps/mobile/app/settings/plugins/index.tsx new file mode 100644 index 0000000000..9baae55a26 --- /dev/null +++ b/apps/mobile/app/settings/plugins/index.tsx @@ -0,0 +1,3 @@ +import { PluginsScreen } from "@/screens/plugins"; + +export default PluginsScreen; diff --git a/apps/mobile/app/settings/server.tsx b/apps/mobile/app/settings/server.tsx new file mode 100644 index 0000000000..ef8ba361d0 --- /dev/null +++ b/apps/mobile/app/settings/server.tsx @@ -0,0 +1,3 @@ +import { ServerStatusScreen } from "@/screens"; + +export default ServerStatusScreen; diff --git a/apps/mobile/app/settings/servers/add.tsx b/apps/mobile/app/settings/servers/add.tsx new file mode 100644 index 0000000000..33a6e762b6 --- /dev/null +++ b/apps/mobile/app/settings/servers/add.tsx @@ -0,0 +1,3 @@ +import { AddServerScreen } from "@/screens"; + +export default AddServerScreen; diff --git a/apps/mobile/app/settings/servers/index.tsx b/apps/mobile/app/settings/servers/index.tsx new file mode 100644 index 0000000000..1ff5f6576e --- /dev/null +++ b/apps/mobile/app/settings/servers/index.tsx @@ -0,0 +1,3 @@ +import { ServersScreen } from "@/screens"; + +export default ServersScreen; diff --git a/apps/mobile/app/settings/skills/[skillId].tsx b/apps/mobile/app/settings/skills/[skillId].tsx new file mode 100644 index 0000000000..dce825d1d7 --- /dev/null +++ b/apps/mobile/app/settings/skills/[skillId].tsx @@ -0,0 +1,3 @@ +import { SkillDetailScreen } from "@/screens/extensions"; + +export default SkillDetailScreen; diff --git a/apps/mobile/app/settings/skills/index.tsx b/apps/mobile/app/settings/skills/index.tsx new file mode 100644 index 0000000000..f88194bd96 --- /dev/null +++ b/apps/mobile/app/settings/skills/index.tsx @@ -0,0 +1,3 @@ +import { SkillsLibraryScreen } from "@/screens/extensions"; + +export default SkillsLibraryScreen; diff --git a/apps/mobile/app/settings/skills/registry/[registrySkillId].tsx b/apps/mobile/app/settings/skills/registry/[registrySkillId].tsx new file mode 100644 index 0000000000..bc67785847 --- /dev/null +++ b/apps/mobile/app/settings/skills/registry/[registrySkillId].tsx @@ -0,0 +1,3 @@ +import { RegistrySkillDetailScreen } from "@/screens/extensions"; + +export default RegistrySkillDetailScreen; diff --git a/apps/mobile/app/settings/skills/registry/index.tsx b/apps/mobile/app/settings/skills/registry/index.tsx new file mode 100644 index 0000000000..ebbbb501a0 --- /dev/null +++ b/apps/mobile/app/settings/skills/registry/index.tsx @@ -0,0 +1,3 @@ +import { RegistrySkillsScreen } from "@/screens/extensions"; + +export default RegistrySkillsScreen; diff --git a/apps/mobile/app/settings/updates.tsx b/apps/mobile/app/settings/updates.tsx new file mode 100644 index 0000000000..7a8304ec13 --- /dev/null +++ b/apps/mobile/app/settings/updates.tsx @@ -0,0 +1,3 @@ +import { UpdatesScreen } from "@/screens"; + +export default UpdatesScreen; diff --git a/apps/mobile/app/settings/usage.tsx b/apps/mobile/app/settings/usage.tsx new file mode 100644 index 0000000000..e3cb930132 --- /dev/null +++ b/apps/mobile/app/settings/usage.tsx @@ -0,0 +1,3 @@ +import { UsageLimitsScreen } from "@/screens"; + +export default UsageLimitsScreen; diff --git a/apps/mobile/app/threads/[id].tsx b/apps/mobile/app/threads/[id].tsx new file mode 100644 index 0000000000..8b0b6c5ca6 --- /dev/null +++ b/apps/mobile/app/threads/[id].tsx @@ -0,0 +1,3 @@ +import { ThreadDetailScreen } from "@/screens"; + +export default ThreadDetailScreen; diff --git a/apps/mobile/app/threads/[id]/files.tsx b/apps/mobile/app/threads/[id]/files.tsx new file mode 100644 index 0000000000..e85e5e3e24 --- /dev/null +++ b/apps/mobile/app/threads/[id]/files.tsx @@ -0,0 +1,3 @@ +import { FilePreviewScreen } from "@/screens/files/FilePreviewScreen"; + +export default FilePreviewScreen; diff --git a/apps/mobile/app/threads/[id]/terminal/[terminalId].tsx b/apps/mobile/app/threads/[id]/terminal/[terminalId].tsx new file mode 100644 index 0000000000..d5bf91f4ec --- /dev/null +++ b/apps/mobile/app/threads/[id]/terminal/[terminalId].tsx @@ -0,0 +1,3 @@ +import { TerminalScreen } from "@/screens"; + +export default TerminalScreen; diff --git a/apps/mobile/app/threads/[id]/terminal/index.tsx b/apps/mobile/app/threads/[id]/terminal/index.tsx new file mode 100644 index 0000000000..5ef9dfef2e --- /dev/null +++ b/apps/mobile/app/threads/[id]/terminal/index.tsx @@ -0,0 +1,3 @@ +import { ThreadTerminalsScreen } from "@/screens"; + +export default ThreadTerminalsScreen; diff --git a/apps/mobile/app/threads/search.tsx b/apps/mobile/app/threads/search.tsx new file mode 100644 index 0000000000..97d1be897d --- /dev/null +++ b/apps/mobile/app/threads/search.tsx @@ -0,0 +1,3 @@ +import { ThreadSearchScreen } from "@/screens"; + +export default ThreadSearchScreen; diff --git a/apps/mobile/assets/android-icon-background.png b/apps/mobile/assets/android-icon-background.png new file mode 100644 index 0000000000..57ad0415c8 Binary files /dev/null and b/apps/mobile/assets/android-icon-background.png differ diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png new file mode 100644 index 0000000000..ec165c3906 Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png new file mode 100644 index 0000000000..66c5daab13 Binary files /dev/null and b/apps/mobile/assets/android-icon-monochrome.png differ diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png new file mode 100644 index 0000000000..211825b4d3 Binary files /dev/null and b/apps/mobile/assets/favicon.png differ diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png new file mode 100644 index 0000000000..a282ec89c9 Binary files /dev/null and b/apps/mobile/assets/icon.png differ diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png new file mode 100644 index 0000000000..be451cf7ed Binary files /dev/null and b/apps/mobile/assets/splash-icon.png differ diff --git a/apps/mobile/assets/terminal/index.html b/apps/mobile/assets/terminal/index.html new file mode 100644 index 0000000000..5c556d4a2c --- /dev/null +++ b/apps/mobile/assets/terminal/index.html @@ -0,0 +1,390 @@ +<!doctype html> +<!-- GENERATED FILE: run pnpm --filter @bb/mobile terminal:build (xterm 6.1.0-beta.292) --> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"> +<meta name="color-scheme" content="light dark"> +<style> +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + overflow-y: scroll; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; +} + +.xterm:not(.allow-transparency) .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility:not(.debug), +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; + pointer-events: none; +} + +.xterm .xterm-accessibility-tree:not(.debug) *::selection { + color: transparent; +} + +.xterm .xterm-accessibility-tree { + font-family: monospace; + user-select: text; + white-space: pre; +} + +.xterm .xterm-accessibility-tree > div { + transform-origin: left; + width: fit-content; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; +} + +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } + +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + +.xterm-decoration-overview-ruler { + z-index: 8; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} + + + +/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */ + +/* xterm.js customization: Override xterm's cursor style */ +.xterm .xterm-scrollable-element > .xterm-scrollbar { + cursor: default; +} + +/* Arrows */ + +.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-scra { + cursor: pointer; + background-color: var(--vscode-scrollbarSliderBackground, rgba(100, 100, 100, 0.4)); + mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 11'><path d='M2.5 8.5 L5.5 2.5 L8.5 8.5 Z' fill='black'/></svg>"); + -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 11'><path d='M2.5 8.5 L5.5 2.5 L8.5 8.5 Z' fill='black'/></svg>"); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-position: center center; + -webkit-mask-position: center center; + mask-size: 100% 100%; + -webkit-mask-size: 100% 100%; +} + +.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-scra.xterm-arrow-down { + transform: rotate(180deg); +} + +.xterm .xterm-scrollable-element > .xterm-visible { + opacity: 1; + + /* Background rule added for IE9 - to allow clicks on dom node */ + background:rgba(0,0,0,0); + + transition: opacity 100ms linear; + /* In front of peek view */ + z-index: 11; +} +.xterm .xterm-scrollable-element > .xterm-invisible { + opacity: 0; + pointer-events: none; +} +.xterm .xterm-scrollable-element > .xterm-invisible.xterm-fade { + transition: opacity 800ms linear; +} + +/* Scrollable Content Inset Shadow */ +.xterm .xterm-scrollable-element > .xterm-shadow { + position: absolute; + display: none; +} +.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-top { + display: block; + top: 0; + left: 3px; + height: 3px; + width: 100%; + box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset; +} +.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-left { + display: block; + top: 3px; + left: 0; + height: 100%; + width: 3px; + box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; +} +.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-top-left-corner { + display: block; + top: 0; + left: 0; + height: 3px; + width: 3px; +} +.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-top.xterm-shadow-left { + box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset; +} +/* The terminal WebView page (bundled into assets/terminal/index.html). */ +html, +body { + margin: 0; + padding: 0; + height: 100%; + width: 100%; + overflow: hidden; + background: var(--terminal-background, #000); + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; + overscroll-behavior: none; +} +#terminal { + position: absolute; + inset: 0; + padding: 6px 4px 4px 8px; + box-sizing: border-box; +} +#terminal .xterm { + height: 100%; +} +/* iOS zooms the page when a focused field's font is under 16px. */ +.xterm .xterm-helper-textarea { + font-size: 16px !important; +} +/* Touch: xterm's mouse selection fights with scrolling; keep native-ish scroll. */ +.xterm .xterm-viewport { + -webkit-overflow-scrolling: touch; +} +#error { + display: none; + position: absolute; + inset: 0; + padding: 16px; + color: #f66; + font: + 13px -apple-system, + system-ui, + sans-serif; + white-space: pre-wrap; +} +</style> +</head> +<body> +<div id="terminal"></div> +<div id="error"></div> +<script> +"use strict";(()=>{function Gr(e){return e?.ownerDocument?.defaultView?e.ownerDocument.defaultView:window}function ss(e){return Gr(e).getComputedStyle(e,null)}var rs=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows)||this._terminal.resize(e.cols,e.rows)}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let e=this._terminal.dimensions;if(!e||e.css.cell.width===0||e.css.cell.height===0)return;let t=this._terminal.options.scrollbar?.showScrollbar??!0,i=this._terminal.options.scrollback===0||!t?0:this._terminal.options.scrollbar?.width??14,s=ss(this._terminal.element.parentElement),r=Math.max(0,parseInt(s.getPropertyValue("height"),10)||0),n=Math.max(0,parseInt(s.getPropertyValue("width"),10)||0),o=ss(this._terminal.element),h={top:parseInt(o.getPropertyValue("padding-top"),10)||0,bottom:parseInt(o.getPropertyValue("padding-bottom"),10)||0,right:parseInt(o.getPropertyValue("padding-right"),10)||0,left:parseInt(o.getPropertyValue("padding-left"),10)||0},l=h.top+h.bottom,a=h.right+h.left,c=r-l,d=n-a-i;return{cols:Math.max(2,Math.floor(d/e.css.cell.width)),rows:Math.max(1,Math.floor(c/e.css.cell.height))}}};var Xt=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],Jr=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]],V;function Zr(e,t){let i=0,s=t.length-1,r;if(e<t[0][0]||e>t[s][1])return!1;for(;s>=i;)if(r=i+s>>1,e>t[r][1])i=r+1;else if(e<t[r][0])s=r-1;else return!0;return!1}var Qr=class{constructor(){if(this.version="6",!V){V=new Uint8Array(65536),V.fill(1),V[0]=0,V.fill(0,1,32),V.fill(0,127,160),V.fill(2,4352,4448),V[9001]=2,V[9002]=2,V.fill(2,11904,42192),V[12351]=1,V.fill(2,44032,55204),V.fill(2,63744,64256),V.fill(2,65040,65050),V.fill(2,65072,65136),V.fill(2,65280,65377),V.fill(2,65504,65511);for(let e=0;e<Xt.length;++e)V.fill(0,Xt[e][0],Xt[e][1]+1)}}wcwidth(e){return e<32?0:e<127?1:e<65536?V[e]:Zr(e,Jr)?0:e>=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),s=i===0&&t!==0;if(s){let r=kt.extractWidth(t);r===0?s=!1:r>i&&(i=r)}return kt.createPropertyValue(0,i,s)}},en=class{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout(()=>{throw e.stack?ns.isErrorNoTelemetry(e)?new ns(e.message+` + +`+e.stack):new Error(e.message+` + +`+e.stack):e},0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach(t=>{t(e)})}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}},tn=new en;function jt(e){sn(e)||tn.onUnexpectedError(e)}var Zt="Canceled";function sn(e){return e instanceof rn?!0:e instanceof Error&&e.name===Zt&&e.message===Zt}var rn=class extends Error{constructor(){super(Zt),this.name=this.message}},ns=class Qt extends Error{constructor(t){super(t),this.name="CodeExpectedError"}static fromError(t){if(t instanceof Qt)return t;let i=new Qt;return i.message=t.message,i.stack=t.stack,i}static isErrorNoTelemetry(t){return t.name==="CodeExpectedError"}};function nn(e,t){let i=this,s=!1,r;return function(){if(s)return r;if(s=!0,t)try{r=e.apply(i,arguments)}finally{t()}else r=e.apply(i,arguments);return r}}function on(e,t,i=0,s=e.length){let r=i,n=s;for(;r<n;){let o=Math.floor((r+n)/2);t(e[o])?r=o+1:n=o}return r-1}var an=class us{constructor(t){this._array=t,this._findLastMonotonousLastIdx=0}findLastMonotonous(t){if(us.assertInvariants){if(this._prevFindLastPredicate){for(let s of this._array)if(this._prevFindLastPredicate(s)&&!t(s))throw new Error("MonotonousArray: current predicate must be weaker than (or equal to) the previous predicate.")}this._prevFindLastPredicate=t}let i=on(this._array,t,this._findLastMonotonousLastIdx);return this._findLastMonotonousLastIdx=i+1,i===-1?void 0:this._array[i]}};an.assertInvariants=!1;var fs;(e=>{function t(n){return n<0}e.isLessThan=t;function i(n){return n<=0}e.isLessThanOrEqual=i;function s(n){return n>0}e.isGreaterThan=s;function r(n){return n===0}e.isNeitherLessOrGreaterThan=r,e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0})(fs||={});function hn(e,t){return(i,s)=>t(e(i),e(s))}var ln=(e,t)=>e-t,os=class ei{constructor(t){this.iterate=t}forEach(t){this.iterate(i=>(t(i),!0))}toArray(){let t=[];return this.iterate(i=>(t.push(i),!0)),t}filter(t){return new ei(i=>this.iterate(s=>t(s)?i(s):!0))}map(t){return new ei(i=>this.iterate(s=>i(t(s))))}some(t){let i=!1;return this.iterate(s=>(i=t(s),!i)),i}findFirst(t){let i;return this.iterate(s=>t(s)?(i=s,!1):!0),i}findLast(t){let i;return this.iterate(s=>(t(s)&&(i=s),!0)),i}findLastMaxBy(t){let i,s=!0;return this.iterate(r=>((s||fs.isGreaterThan(t(r,i)))&&(s=!1,i=r),!0)),i}};os.empty=new os(e=>{});function cn(e,t){let i=Object.create(null);for(let s of e){let r=t(s),n=i[r];n||(n=i[r]=[]),n.push(s)}return i}var as,hs,gh=class{constructor(e,t){this.toKey=t,this._map=new Map,this[as]="SetWithKey";for(let i of e)this.add(i)}get size(){return this._map.size}add(e){let t=this.toKey(e);return this._map.set(t,e),this}delete(e){return this._map.delete(this.toKey(e))}has(e){return this._map.has(this.toKey(e))}*entries(){for(let e of this._map.values())yield[e,e]}keys(){return this.values()}*values(){for(let e of this._map.values())yield e}clear(){this._map.clear()}forEach(e,t){this._map.forEach(i=>e.call(t,i,i,this))}[(hs=Symbol.iterator,as=Symbol.toStringTag,hs)](){return this.values()}},dn=class{constructor(){this.map=new Map}add(e,t){let i=this.map.get(e);i||(i=new Set,this.map.set(e,i)),i.add(t)}delete(e,t){let i=this.map.get(e);i&&(i.delete(t),i.size===0&&this.map.delete(e))}forEach(e,t){let i=this.map.get(e);i&&i.forEach(t)}get(e){return this.map.get(e)||new Set}},ps;(e=>{function t(B){return B&&typeof B=="object"&&typeof B[Symbol.iterator]=="function"}e.is=t;let i=Object.freeze([]);function s(){return i}e.empty=s;function*r(B){yield B}e.single=r;function n(B){return t(B)?B:r(B)}e.wrap=n;function o(B){return B||i}e.from=o;function*h(B){for(let T=B.length-1;T>=0;T--)yield B[T]}e.reverse=h;function l(B){return!B||B[Symbol.iterator]().next().done===!0}e.isEmpty=l;function a(B){return B[Symbol.iterator]().next().value}e.first=a;function c(B,T){let A=0;for(let ee of B)if(T(ee,A++))return!0;return!1}e.some=c;function d(B,T){for(let A of B)if(T(A))return A}e.find=d;function*u(B,T){for(let A of B)T(A)&&(yield A)}e.filter=u;function*f(B,T){let A=0;for(let ee of B)yield T(ee,A++)}e.map=f;function*_(B,T){let A=0;for(let ee of B)yield*T(ee,A++)}e.flatMap=_;function*p(...B){for(let T of B)yield*T}e.concat=p;function S(B,T,A){let ee=A;for(let Ce of B)ee=T(ee,Ce);return ee}e.reduce=S;function*k(B,T,A=B.length){for(T<0&&(T+=B.length),A<0?A+=B.length:A>B.length&&(A=B.length);T<A;T++)yield B[T]}e.slice=k;function R(B,T=Number.POSITIVE_INFINITY){let A=[];if(T===0)return[A,B];let ee=B[Symbol.iterator]();for(let Ce=0;Ce<T;Ce++){let Oe=ee.next();if(Oe.done)return[A,e.empty()];A.push(Oe.value)}return[A,{[Symbol.iterator](){return ee}}]}e.consume=R;async function E(B){let T=[];for await(let A of B)T.push(A);return Promise.resolve(T)}e.asyncToArray=E})(ps||={});var _n=!1,qe=null,un=class gs{constructor(){this.livingDisposables=new Map}getDisposableData(t){let i=this.livingDisposables.get(t);return i||(i={parent:null,source:null,isSingleton:!1,value:t,idx:gs.idx++},this.livingDisposables.set(t,i)),i}trackDisposable(t){let i=this.getDisposableData(t);i.source||(i.source=new Error().stack)}setParent(t,i){let s=this.getDisposableData(t);s.parent=i}markAsDisposed(t){this.livingDisposables.delete(t)}markAsSingleton(t){this.getDisposableData(t).isSingleton=!0}getRootParent(t,i){let s=i.get(t);if(s)return s;let r=t.parent?this.getRootParent(this.getDisposableData(t.parent),i):t;return i.set(t,r),r}getTrackedDisposables(){let t=new Map;return[...this.livingDisposables.entries()].filter(([,i])=>i.source!==null&&!this.getRootParent(i,t).isSingleton).flatMap(([i])=>i)}computeLeakingDisposables(t=10,i){let s;if(i)s=i;else{let l=new Map,a=[...this.livingDisposables.values()].filter(d=>d.source!==null&&!this.getRootParent(d,l).isSingleton);if(a.length===0)return;let c=new Set(a.map(d=>d.value));if(s=a.filter(d=>!(d.parent&&c.has(d.parent))),s.length===0)throw new Error("There are cyclic diposable chains!")}if(!s)return;function r(l){function a(d,u){for(;d.length>0&&u.some(f=>typeof f=="string"?f===d[0]:d[0].match(f));)d.shift()}let c=l.source.split(` +`).map(d=>d.trim().replace("at ","")).filter(d=>d!=="");return a(c,["Error",/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),c.reverse()}let n=new dn;for(let l of s){let a=r(l);for(let c=0;c<=a.length;c++)n.add(a.slice(0,c).join(` +`),l)}s.sort(hn(l=>l.idx,ln));let o="",h=0;for(let l of s.slice(0,t)){h++;let a=r(l),c=[];for(let d=0;d<a.length;d++){let u=a[d];u=`(shared with ${n.get(a.slice(0,d+1).join(` +`)).size}/${s.length} leaks) at ${u}`;let f=n.get(a.slice(0,d).join(` +`)),_=cn([...f].map(p=>r(p)[d]),p=>p);delete _[a[d]];for(let[p,S]of Object.entries(_))c.unshift(` - stacktraces of ${S.length} other leaks continue with ${p}`);c.unshift(u)}o+=` + + +==================== Leaking disposable ${h}/${s.length}: ${l.value.constructor.name} ==================== +${c.join(` +`)} +============================================================ + +`}return s.length>t&&(o+=` + + +... and ${s.length-t} more leaking disposables + +`),{leaks:s,details:o}}};un.idx=0;function fn(e){qe=e}if(_n){let e="__is_disposable_tracked__";fn(new class{trackDisposable(t){let i=new Error("Potentially leaked disposable").stack;setTimeout(()=>{t[e]||console.log(i)},3e3)}setParent(t,i){if(t&&t!==Ve.None)try{t[e]=!0}catch{}}markAsDisposed(t){if(t&&t!==Ve.None)try{t[e]=!0}catch{}}markAsSingleton(t){}})}function oi(e){return qe?.trackDisposable(e),e}function ai(e){qe?.markAsDisposed(e)}function ti(e,t){qe?.setParent(e,t)}function pn(e,t){if(qe)for(let i of e)qe.setParent(i,t)}function vs(e){if(ps.is(e)){let t=[];for(let i of e)if(i)try{i.dispose()}catch(s){t.push(s)}if(t.length===1)throw t[0];if(t.length>1)throw new AggregateError(t,"Encountered errors while disposing of store");return Array.isArray(e)?[]:e}else if(e)return e.dispose(),e}function gn(...e){let t=ms(()=>vs(e));return pn(e,t),t}function ms(e){let t=oi({dispose:nn(()=>{ai(t),e()})});return t}var Ss=class ws{constructor(){this._toDispose=new Set,this._isDisposed=!1,oi(this)}dispose(){this._isDisposed||(ai(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(this._toDispose.size!==0)try{vs(this._toDispose)}finally{this._toDispose.clear()}}add(t){if(!t)return t;if(t===this)throw new Error("Cannot register a disposable on itself!");return ti(t,this),this._isDisposed?ws.DISABLE_DISPOSED_WARNING||console.warn(new Error("Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!").stack):this._toDispose.add(t),t}delete(t){if(t){if(t===this)throw new Error("Cannot dispose a disposable on itself!");this._toDispose.delete(t),t.dispose()}}deleteAndLeak(t){t&&this._toDispose.has(t)&&(this._toDispose.delete(t),ti(t,null))}};Ss.DISABLE_DISPOSED_WARNING=!1;var hi=Ss,Ve=class{constructor(){this._store=new hi,oi(this),ti(this._store,this)}dispose(){ai(this),this._store.dispose()}_register(e){if(e===this)throw new Error("Cannot register a disposable on itself!");return this._store.add(e)}};Ve.None=Object.freeze({dispose(){}});var ls=class ii{constructor(t){this.element=t,this.next=ii.Undefined,this.prev=ii.Undefined}};ls.Undefined=new ls(void 0);var vn=globalThis.performance&&typeof globalThis.performance.now=="function",mn=class bs{static create(t){return new bs(t)}constructor(t){this._now=vn&&t===!1?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return this._stopTime!==-1?this._stopTime-this._startTime:this._now()-this._startTime}},Sn=!1,cs=!1,wn=!1,bn;(e=>{e.None=()=>Ve.None;function t(v){if(wn){let{onDidAddListener:m}=v,w=ni.create(),b=0;v.onDidAddListener=()=>{++b===2&&(console.warn("snapshotted emitter LIKELY used public and SHOULD HAVE BEEN created with DisposableStore. snapshotted here"),w.print()),m?.()}}}function i(v,m){return u(v,()=>{},0,void 0,!0,void 0,m)}e.defer=i;function s(v){return(m,w=null,b)=>{let x=!1,M;return M=v(P=>{if(!x)return M?M.dispose():x=!0,m.call(w,P)},null,b),x&&M.dispose(),M}}e.once=s;function r(v,m,w){return c((b,x=null,M)=>v(P=>b.call(x,m(P)),null,M),w)}e.map=r;function n(v,m,w){return c((b,x=null,M)=>v(P=>{m(P),b.call(x,P)},null,M),w)}e.forEach=n;function o(v,m,w){return c((b,x=null,M)=>v(P=>m(P)&&b.call(x,P),null,M),w)}e.filter=o;function h(v){return v}e.signal=h;function l(...v){return(m,w=null,b)=>{let x=gn(...v.map(M=>M(P=>m.call(w,P))));return d(x,b)}}e.any=l;function a(v,m,w,b){let x=w;return r(v,M=>(x=m(x,M),x),b)}e.reduce=a;function c(v,m){let w,b={onWillAddFirstListener(){w=v(x.fire,x)},onDidRemoveLastListener(){w?.dispose()}};m||t(b);let x=new Te(b);return m?.add(x),x.event}function d(v,m){return m instanceof Array?m.push(v):m&&m.add(v),v}function u(v,m,w=100,b=!1,x=!1,M,P){let C,q,ve,Me=0,ke,Ne={leakWarningThreshold:M,onWillAddFirstListener(){C=v(He=>{Me++,q=m(q,He),b&&!ve&&(te.fire(q),q=void 0),ke=()=>{let _e=q;q=void 0,ve=void 0,(!b||Me>1)&&te.fire(_e),Me=0},typeof w=="number"?(clearTimeout(ve),ve=setTimeout(ke,w)):ve===void 0&&(ve=0,queueMicrotask(ke))})},onWillRemoveListener(){x&&Me>0&&ke?.()},onDidRemoveLastListener(){ke=void 0,C.dispose()}};P||t(Ne);let te=new Te(Ne);return P?.add(te),te.event}e.debounce=u;function f(v,m=0,w){return e.debounce(v,(b,x)=>b?(b.push(x),b):[x],m,void 0,!0,void 0,w)}e.accumulate=f;function _(v,m=(b,x)=>b===x,w){let b=!0,x;return o(v,M=>{let P=b||!m(M,x);return b=!1,x=M,P},w)}e.latch=_;function p(v,m,w){return[e.filter(v,m,w),e.filter(v,b=>!m(b),w)]}e.split=p;function S(v,m=!1,w=[],b){let x=w.slice(),M=v(q=>{x?x.push(q):C.fire(q)});b&&b.add(M);let P=()=>{x?.forEach(q=>C.fire(q)),x=null},C=new Te({onWillAddFirstListener(){M||(M=v(q=>C.fire(q)),b&&b.add(M))},onDidAddFirstListener(){x&&(m?setTimeout(P):P())},onDidRemoveLastListener(){M&&M.dispose(),M=null}});return b&&b.add(C),C.event}e.buffer=S;function k(v,m){return(w,b,x)=>{let M=m(new E);return v(function(P){let C=M.evaluate(P);C!==R&&w.call(b,C)},void 0,x)}}e.chain=k;let R=Symbol("HaltChainable");class E{constructor(){this.steps=[]}map(m){return this.steps.push(m),this}forEach(m){return this.steps.push(w=>(m(w),w)),this}filter(m){return this.steps.push(w=>m(w)?w:R),this}reduce(m,w){let b=w;return this.steps.push(x=>(b=m(b,x),b)),this}latch(m=(w,b)=>w===b){let w=!0,b;return this.steps.push(x=>{let M=w||!m(x,b);return w=!1,b=x,M?x:R}),this}evaluate(m){for(let w of this.steps)if(m=w(m),m===R)break;return m}}function B(v,m,w=b=>b){let b=(...C)=>P.fire(w(...C)),x=()=>v.on(m,b),M=()=>v.removeListener(m,b),P=new Te({onWillAddFirstListener:x,onDidRemoveLastListener:M});return P.event}e.fromNodeEventEmitter=B;function T(v,m,w=b=>b){let b=(...C)=>P.fire(w(...C)),x=()=>v.addEventListener(m,b),M=()=>v.removeEventListener(m,b),P=new Te({onWillAddFirstListener:x,onDidRemoveLastListener:M});return P.event}e.fromDOMEventEmitter=T;function A(v){return new Promise(m=>s(v)(m))}e.toPromise=A;function ee(v){let m=new Te;return v.then(w=>{m.fire(w)},()=>{m.fire(void 0)}).finally(()=>{m.dispose()}),m.event}e.fromPromise=ee;function Ce(v,m){return v(w=>m.fire(w))}e.forward=Ce;function Oe(v,m,w){return m(w),v(b=>m(b))}e.runAndSubscribe=Oe;class Ue{constructor(m,w){this._observable=m,this._counter=0,this._hasChanged=!1;let b={onWillAddFirstListener:()=>{m.addObserver(this)},onDidRemoveLastListener:()=>{m.removeObserver(this)}};w||t(b),this.emitter=new Te(b),w&&w.add(this.emitter)}beginUpdate(m){this._counter++}handlePossibleChange(m){}handleChange(m,w){this._hasChanged=!0}endUpdate(m){this._counter--,this._counter===0&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}function Ie(v,m){return new Ue(v,m).emitter.event}e.fromObservable=Ie;function St(v){return(m,w,b)=>{let x=0,M=!1,P={beginUpdate(){x++},endUpdate(){x--,x===0&&(v.reportChanges(),M&&(M=!1,m.call(w)))},handlePossibleChange(){},handleChange(){M=!0}};v.addObserver(P),v.reportChanges();let C={dispose(){v.removeObserver(P)}};return b instanceof hi?b.add(C):Array.isArray(b)&&b.push(C),C}}e.fromObservableLight=St})(bn||={});var si=class ri{constructor(t){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${t}_${ri._idPool++}`,ri.all.add(this)}start(t){this._stopWatch=new mn,this.listenerCount=t}stop(){if(this._stopWatch){let t=this._stopWatch.elapsed();this.durations.push(t),this.elapsedOverall+=t,this.invocationCount+=1,this._stopWatch=void 0}}};si.all=new Set,si._idPool=0;var yn=si,ds=-1,ys=class Cs{constructor(t,i,s=(Cs._idPool++).toString(16).padStart(3,"0")){this._errorHandler=t,this.threshold=i,this.name=s,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(t,i){let s=this.threshold;if(s<=0||i<s)return;this._stacks||(this._stacks=new Map);let r=this._stacks.get(t.value)||0;if(this._stacks.set(t.value,r+1),this._warnCountdown-=1,this._warnCountdown<=0){this._warnCountdown=s*.5;let[n,o]=this.getMostFrequentStack(),h=`[${this.name}] potential listener LEAK detected, having ${i} listeners already. MOST frequent listener (${o}):`;console.warn(h),console.warn(n);let l=new kn(h,n);this._errorHandler(l)}return()=>{let n=this._stacks.get(t.value)||0;this._stacks.set(t.value,n-1)}}getMostFrequentStack(){if(!this._stacks)return;let t,i=0;for(let[s,r]of this._stacks)(!t||i<r)&&(t=[s,r],i=r);return t}};ys._idPool=1;var Cn=ys,ni=class ks{constructor(t){this.value=t}static create(){let t=new Error;return new ks(t.stack??"")}print(){console.warn(this.value.split(` +`).slice(2).join(` +`))}},kn=class extends Error{constructor(e,t){super(e),this.name="ListenerLeakError",this.stack=t}},xn=class extends Error{constructor(e,t){super(e),this.name="ListenerRefusalError",this.stack=t}},Bn=0,bt=class{constructor(e){this.value=e,this.id=Bn++}},En=2,Dn=(e,t)=>{if(e instanceof bt)t(e);else for(let i=0;i<e.length;i++){let s=e[i];s&&t(s)}},yt;if(Sn){let e=[];setInterval(()=>{e.length!==0&&(console.warn("[LEAKING LISTENERS] GC'ed these listeners that were NOT yet disposed:"),console.warn(e.join(` +`)),e.length=0)},3e3),yt=new FinalizationRegistry(t=>{typeof t=="string"&&e.push(t)})}var Te=class{constructor(e){this._size=0,this._options=e,this._leakageMon=ds>0||this._options?.leakWarningThreshold?new Cn(e?.onListenerError??jt,this._options?.leakWarningThreshold??ds):void 0,this._perfMon=this._options?._profName?new yn(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){if(!this._disposed){if(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners){if(cs){let e=this._listeners;queueMicrotask(()=>{Dn(e,t=>t.stack?.print())})}this._listeners=void 0,this._size=0}this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose()}}get event(){return this._event??=(e,t,i)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){let h=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(h);let l=this._leakageMon.getMostFrequentStack()??["UNKNOWN stack",-1],a=new xn(`${h}. HINT: Stack shows most frequent listener (${l[1]}-times)`,l[0]);return(this._options?.onListenerError||jt)(a),Ve.None}if(this._disposed)return Ve.None;t&&(e=e.bind(t));let s=new bt(e),r,n;this._leakageMon&&this._size>=Math.ceil(this._leakageMon.threshold*.2)&&(s.stack=ni.create(),r=this._leakageMon.check(s.stack,this._size+1)),cs&&(s.stack=n??ni.create()),this._listeners?this._listeners instanceof bt?(this._deliveryQueue??=new Mn,this._listeners=[this._listeners,s]):this._listeners.push(s):(this._options?.onWillAddFirstListener?.(this),this._listeners=s,this._options?.onDidAddFirstListener?.(this)),this._size++;let o=ms(()=>{yt?.unregister(o),r?.(),this._removeListener(s)});if(i instanceof hi?i.add(o):Array.isArray(i)&&i.push(o),yt){let h=new Error().stack.split(` +`).slice(2,3).join(` +`).trim(),l=/(file:|vscode-file:\/\/vscode-app)?(\/[^:]*:\d+:\d+)/.exec(h);yt.register(o,l?.[2]??h,o)}return o},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(this._size===1){this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),this._size=0;return}let t=this._listeners,i=t.indexOf(e);if(i===-1)throw console.log("disposed?",this._disposed),console.log("size?",this._size),console.log("arr?",JSON.stringify(this._listeners)),new Error("Attempted to dispose unknown listener");this._size--,t[i]=void 0;let s=this._deliveryQueue.current===this;if(this._size*En<=t.length){let r=0;for(let n=0;n<t.length;n++)t[n]?t[r++]=t[n]:s&&(this._deliveryQueue.end--,r<this._deliveryQueue.i&&this._deliveryQueue.i--);t.length=r}}_deliver(e,t){if(!e)return;let i=this._options?.onListenerError||jt;if(!i){e.value(t);return}try{e.value(t)}catch(s){i(s)}}_deliverQueue(e){let t=e.current._listeners;for(;e.i<e.end;)this._deliver(t[e.i++],e.value);e.reset()}fire(e){if(this._deliveryQueue?.current&&(this._deliverQueue(this._deliveryQueue),this._perfMon?.stop()),this._perfMon?.start(this._size),this._listeners)if(this._listeners instanceof bt)this._deliver(this._listeners,e);else{let t=this._deliveryQueue;t.enqueue(this,e,this._listeners.length),this._deliverQueue(t)}this._perfMon?.stop()}hasListeners(){return this._size>0}},Mn=class{constructor(){this.i=-1,this.end=0}enqueue(e,t,i){this.i=0,this.end=i,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}},kt=class Ct{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new Te,this.onChange=this._onChange.event;let t=new Qr;this.register(t),this._active=t.version,this._activeProvider=t}static extractShouldJoin(t){return(t&1)!==0}static extractWidth(t){return t>>1&3}static extractCharKind(t){return t>>3}static createPropertyValue(t,i,s=!1){return(t&16777215)<<3|(i&3)<<1|(s?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(t){if(!this._providers[t])throw new Error(`unknown Unicode version "${t}"`);this._active=t,this._activeProvider=this._providers[t],this._onChange.fire(t)}register(t){this._providers[t.version]=t}wcwidth(t){return this._activeProvider.wcwidth(t)}getStringCellWidth(t){let i=0,s=0,r=t.length;for(let n=0;n<r;++n){let o=t.charCodeAt(n);if(55296<=o&&o<=56319){if(++n>=r)return i+this.wcwidth(o);let a=t.charCodeAt(n);56320<=a&&a<=57343?o=(o-55296)*1024+a-56320+65536:i+=this.wcwidth(a)}let h=this.charProperties(o,s),l=Ct.extractWidth(h);Ct.extractShouldJoin(h)&&(l-=Ct.extractWidth(s)),i+=l,s=h}return i}charProperties(t,i){return this._activeProvider.charProperties(t,i)}},Gt=[[768,879],[1155,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1541],[1552,1562],[1564,1564],[1611,1631],[1648,1648],[1750,1757],[1759,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2045,2045],[2070,2073],[2075,2083],[2085,2087],[2089,2093],[2137,2139],[2259,2306],[2362,2362],[2364,2364],[2369,2376],[2381,2381],[2385,2391],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2558,2558],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2641,2641],[2672,2673],[2677,2677],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2810,2815],[2817,2817],[2876,2876],[2879,2879],[2881,2884],[2893,2893],[2902,2902],[2914,2915],[2946,2946],[3008,3008],[3021,3021],[3072,3072],[3076,3076],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3170,3171],[3201,3201],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3328,3329],[3387,3388],[3393,3396],[3405,3405],[3426,3427],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3981,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4151],[4153,4154],[4157,4158],[4184,4185],[4190,4192],[4209,4212],[4226,4226],[4229,4230],[4237,4237],[4253,4253],[4448,4607],[4957,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6158],[6277,6278],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6683,6683],[6742,6742],[6744,6750],[6752,6752],[6754,6754],[6757,6764],[6771,6780],[6783,6783],[6832,6846],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7040,7041],[7074,7077],[7080,7081],[7083,7085],[7142,7142],[7144,7145],[7149,7149],[7151,7153],[7212,7219],[7222,7223],[7376,7378],[7380,7392],[7394,7400],[7405,7405],[7412,7412],[7416,7417],[7616,7673],[7675,7679],[8203,8207],[8234,8238],[8288,8292],[8294,8303],[8400,8432],[11503,11505],[11647,11647],[11744,11775],[12330,12333],[12441,12442],[42607,42610],[42612,42621],[42654,42655],[42736,42737],[43010,43010],[43014,43014],[43019,43019],[43045,43046],[43204,43205],[43232,43249],[43263,43263],[43302,43309],[43335,43345],[43392,43394],[43443,43443],[43446,43449],[43452,43453],[43493,43493],[43561,43566],[43569,43570],[43573,43574],[43587,43587],[43596,43596],[43644,43644],[43696,43696],[43698,43700],[43703,43704],[43710,43711],[43713,43713],[43756,43757],[43766,43766],[44005,44005],[44008,44008],[44013,44013],[64286,64286],[65024,65039],[65056,65071],[65279,65279],[65529,65531]],Ln=[[66045,66045],[66272,66272],[66422,66426],[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[68325,68326],[68900,68903],[69446,69456],[69633,69633],[69688,69702],[69759,69761],[69811,69814],[69817,69818],[69821,69821],[69837,69837],[69888,69890],[69927,69931],[69933,69940],[70003,70003],[70016,70017],[70070,70078],[70089,70092],[70191,70193],[70196,70196],[70198,70199],[70206,70206],[70367,70367],[70371,70378],[70400,70401],[70459,70460],[70464,70464],[70502,70508],[70512,70516],[70712,70719],[70722,70724],[70726,70726],[70750,70750],[70835,70840],[70842,70842],[70847,70848],[70850,70851],[71090,71093],[71100,71101],[71103,71104],[71132,71133],[71219,71226],[71229,71229],[71231,71232],[71339,71339],[71341,71341],[71344,71349],[71351,71351],[71453,71455],[71458,71461],[71463,71467],[71727,71735],[71737,71738],[72148,72151],[72154,72155],[72160,72160],[72193,72202],[72243,72248],[72251,72254],[72263,72263],[72273,72278],[72281,72283],[72330,72342],[72344,72345],[72752,72758],[72760,72765],[72767,72767],[72850,72871],[72874,72880],[72882,72883],[72885,72886],[73009,73014],[73018,73018],[73020,73021],[73023,73029],[73031,73031],[73104,73105],[73109,73109],[73111,73111],[73459,73460],[78896,78904],[92912,92916],[92976,92982],[94031,94031],[94095,94098],[113821,113822],[113824,113827],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[121344,121398],[121403,121452],[121461,121461],[121476,121476],[121499,121503],[121505,121519],[122880,122886],[122888,122904],[122907,122913],[122915,122916],[122918,122922],[123184,123190],[123628,123631],[125136,125142],[125252,125258],[917505,917505],[917536,917631],[917760,917999]],Jt=[[4352,4447],[8986,8987],[9001,9002],[9193,9196],[9200,9200],[9203,9203],[9725,9726],[9748,9749],[9800,9811],[9855,9855],[9875,9875],[9889,9889],[9898,9899],[9917,9918],[9924,9925],[9934,9934],[9940,9940],[9962,9962],[9970,9971],[9973,9973],[9978,9978],[9981,9981],[9989,9989],[9994,9995],[10024,10024],[10060,10060],[10062,10062],[10067,10069],[10071,10071],[10133,10135],[10160,10160],[10175,10175],[11035,11036],[11088,11088],[11093,11093],[11904,11929],[11931,12019],[12032,12245],[12272,12283],[12288,12329],[12334,12350],[12353,12438],[12443,12543],[12549,12591],[12593,12686],[12688,12730],[12736,12771],[12784,12830],[12832,12871],[12880,19903],[19968,42124],[42128,42182],[43360,43388],[44032,55203],[63744,64255],[65040,65049],[65072,65106],[65108,65126],[65128,65131],[65281,65376],[65504,65510]],Rn=[[94176,94179],[94208,100343],[100352,101106],[110592,110878],[110928,110930],[110948,110951],[110960,111355],[126980,126980],[127183,127183],[127374,127374],[127377,127386],[127488,127490],[127504,127547],[127552,127560],[127568,127569],[127584,127589],[127744,127776],[127789,127797],[127799,127868],[127870,127891],[127904,127946],[127951,127955],[127968,127984],[127988,127988],[127992,128062],[128064,128064],[128066,128252],[128255,128317],[128331,128334],[128336,128359],[128378,128378],[128405,128406],[128420,128420],[128507,128591],[128640,128709],[128716,128716],[128720,128722],[128725,128725],[128747,128748],[128756,128762],[128992,129003],[129293,129393],[129395,129398],[129402,129442],[129445,129450],[129454,129482],[129485,129535],[129648,129651],[129656,129658],[129664,129666],[129680,129685],[131072,196605],[196608,262141]],xe;function _s(e,t){let i=0,s=t.length-1,r;if(e<t[0][0]||e>t[s][1])return!1;for(;s>=i;)if(r=i+s>>1,e>t[r][1])i=r+1;else if(e<t[r][0])s=r-1;else return!0;return!1}var Tn=class{constructor(){if(this.version="11",!xe){xe=new Uint8Array(65536),xe.fill(1),xe[0]=0,xe.fill(0,1,32),xe.fill(0,127,160);for(let e=0;e<Gt.length;++e)xe.fill(0,Gt[e][0],Gt[e][1]+1);for(let e=0;e<Jt.length;++e)xe.fill(2,Jt[e][0],Jt[e][1]+1)}}wcwidth(e){return e<32?0:e<127?1:e<65536?xe[e]:_s(e,Ln)?0:_s(e,Rn)?2:1}charProperties(e,t){let i=this.wcwidth(e),s=i===0&&t!==0;if(s){let r=kt.extractWidth(t);r===0?s=!1:r>i&&(i=r)}return kt.createPropertyValue(0,i,s)}},xs=class{activate(e){e.unicode.register(new Tn)}dispose(){}};var Pn=class{constructor(e,t,i,s={}){this._terminal=e,this._regex=t,this._handler=i,this._options=s}provideLinks(e,t){let i=On.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(i))}_addCallbacks(e){return e.map(t=>(t.leave=this._options.leave,t.hover=(i,s)=>{if(this._options.hover){let{range:r}=t;this._options.hover(i,s,r)}},t))}};function An(e){try{let t=new URL(e),i=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(i.toLocaleLowerCase())}catch{return!1}}var On=class xt{static computeLink(t,i,s,r){let n=i.flags.includes("g")?i.flags:`${i.flags}g`,o=new RegExp(i.source,n),[h,l]=xt._getWindowedLineStrings(t-1,s),a=h.join(""),c,d=[];for(;c=o.exec(a);){let u=c[0];if(!An(u))continue;let[f,_]=xt._mapStrIdx(s,l,0,c.index),[p,S]=xt._mapStrIdx(s,f,_,u.length);if(f===-1||_===-1||p===-1||S===-1)continue;let k={start:{x:_+1,y:f+1},end:{x:S,y:p+1}};d.push({range:k,text:u,activate:r})}return d}static _getWindowedLineStrings(t,i){let s,r=t,n=t,o,h,l=[];if(s=i.buffer.active.getLine(t)){let a=s.translateToString(!0);if(s.isWrapped&&a[0]!==" "){for(o=0;(s=i.buffer.active.getLine(--r))&&o<2048&&(h=s.translateToString(!0),o+=h.length,l.push(h),!(!s.isWrapped||h.indexOf(" ")!==-1)););l.reverse()}for(l.push(a),o=0;(s=i.buffer.active.getLine(++n))&&s.isWrapped&&o<2048&&(h=s.translateToString(!0),o+=h.length,l.push(h),h.indexOf(" ")===-1););}return[l,r]}static _mapStrIdx(t,i,s,r){let n=t.buffer.active,o=n.getNullCell(),h=s;for(;r;){let l=n.getLine(i);if(!l)return[-1,-1];for(let a=h;a<l.length;++a){l.getCell(a,o);let c=o.getChars();if(o.getWidth()&&(r-=c.length||1,a===l.length-1&&c==="")){let d=n.getLine(i+1);d&&d.isWrapped&&(d.getCell(0,o),o.getWidth()===2&&(r+=1))}if(r<0)return[i,a]}i++,h=0}return[i,h]}},In=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function Nn(e,t){let i=window.open();if(i){try{i.opener=null}catch{}i.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}var Bs=class{constructor(e=Nn,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;let t=this._options,i=t.urlRegex??In;this._linkProvider=this._terminal.registerLinkProvider(new Pn(this._terminal,i,this._handler,t))}dispose(){this._linkProvider?.dispose()}};var lr=Object.defineProperty,Hn=Object.getOwnPropertyDescriptor,Fn=(e,t)=>{for(var i in t)lr(e,i,{get:t[i],enumerable:!0})},F=(e,t,i,s)=>{for(var r=s>1?void 0:s?Hn(t,i):t,n=e.length-1,o;n>=0;n--)(o=e[n])&&(r=(s?o(t,i,r):o(r))||r);return s&&r&&lr(t,i,r),r},g=(e,t)=>(i,s)=>t(i,s,e),Es="Terminal input",mi={get:()=>Es,set:e=>Es=e},Ds="Too much output to announce, navigate to rows manually to read",Tt={get:()=>Ds,set:e=>Ds=e};function Wn(e){return e.replace(/\r?\n/g,"\r")}function zn(e,t){return t?`\x1B[200~${e.replace(/\x1b/g,"\u241B")}\x1B[201~`:e}function Kn(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()}function $n(e,t,i,s){if(e.stopPropagation(),e.clipboardData){let r=e.clipboardData.getData("text/plain");cr(r,t,i,s)}}function cr(e,t,i,s){e=Wn(e),e=zn(e,i.decPrivateModes.bracketedPasteMode&&s.rawOptions.ignoreBracketedPasteMode!==!0),i.triggerDataEvent(e,!0),t.value=""}function dr(e,t,i){let s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}function Ms(e,t,i,s,r){dr(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}function Ae(e){return e>65535?(e-=65536,String.fromCharCode((e>>10)+55296)+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)}function Ge(e,t=0,i=e.length){let s="";for(let r=t;r<i;++r){let n=e[r];n>65535?(n-=65536,s+=String.fromCharCode((n>>10)+55296)+String.fromCharCode(n%1024+56320)):s+=String.fromCharCode(n)}return s}var Un=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){let i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){let n=e.charCodeAt(r++);56320<=n&&n<=57343?t[s++]=(this._interim-55296)*1024+n-56320+65536:(t[s++]=this._interim,t[s++]=n),this._interim=0}for(let n=r;n<i;++n){let o=e.charCodeAt(n);if(55296<=o&&o<=56319){if(++n>=i)return this._interim=o,s;let h=e.charCodeAt(n);56320<=h&&h<=57343?t[s++]=(o-55296)*1024+h-56320+65536:(t[s++]=o,t[s++]=h);continue}o!==65279&&(t[s++]=o)}return s}},qn=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){let i=e.length;if(!i)return 0;let s=0,r,n,o,h,l,a=0;if(this.interim[0]){let u=!1,f=this.interim[0];f&=(f&224)===192?31:(f&240)===224?15:7;let _=0,p;for(;(p=this.interim[++_])&&_<4;)f<<=6,f|=p&63;let S=(this.interim[0]&224)===192?2:(this.interim[0]&240)===224?3:4,k=S-_;for(;a<k;){if(a>=i)return 0;if(p=e[a++],(p&192)!==128){a--,u=!0;break}else this.interim[_++]=p,f<<=6,f|=p&63}u||(S===2?f<128?a--:t[s++]=f:S===3?f<2048||f>=55296&&f<=57343||f===65279||(t[s++]=f):f<65536||f>1114111||(t[s++]=f)),this.interim.fill(0)}let c=i-4,d=a;for(;d<i;){for(;d<c&&!((r=e[d])&128)&&!((n=e[d+1])&128)&&!((o=e[d+2])&128)&&!((h=e[d+3])&128);)t[s++]=r,t[s++]=n,t[s++]=o,t[s++]=h,d+=4;if(r=e[d++],r<128)t[s++]=r;else if((r&224)===192){if(d>=i)return this.interim[0]=r,s;if(n=e[d++],(n&192)!==128){d--;continue}if(l=(r&31)<<6|n&63,l<128){d--;continue}t[s++]=l}else if((r&240)===224){if(d>=i)return this.interim[0]=r,s;if(n=e[d++],(n&192)!==128){d--;continue}if(d>=i)return this.interim[0]=r,this.interim[1]=n,s;if(o=e[d++],(o&192)!==128){d--;continue}if(l=(r&15)<<12|(n&63)<<6|o&63,l<2048||l>=55296&&l<=57343||l===65279)continue;t[s++]=l}else if((r&248)===240){if(d>=i)return this.interim[0]=r,s;if(n=e[d++],(n&192)!==128){d--;continue}if(d>=i)return this.interim[0]=r,this.interim[1]=n,s;if(o=e[d++],(o&192)!==128){d--;continue}if(d>=i)return this.interim[0]=r,this.interim[1]=n,this.interim[2]=o,s;if(h=e[d++],(h&192)!==128){d--;continue}if(l=(r&7)<<18|(n&63)<<12|(o&63)<<6|h&63,l<65536||l>1114111)continue;t[s++]=l}}return s}},vt=class _r{constructor(){this.fg=0,this.bg=0,this.extended=new Pt}static toColorRGB(t){return[t>>>16&255,t>>>8&255,t&255]}static fromColorRGB(t){return(t[0]&255)<<16|(t[1]&255)<<8|t[2]&255}clone(){let t=new _r;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)===50331648}isBgRGB(){return(this.bg&50331648)===50331648}isFgPalette(){return(this.fg&50331648)===16777216||(this.fg&50331648)===33554432}isBgPalette(){return(this.bg&50331648)===16777216||(this.bg&50331648)===33554432}isFgDefault(){return(this.fg&50331648)===0}isBgDefault(){return(this.bg&50331648)===0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===16777216||(this.extended.underlineColor&50331648)===33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},Pt=class ur{constructor(t=0,i=0){this._ext=0,this._urlId=0,this._ext=t,this._urlId=i}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(t){this._ext=t}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(t){this._ext&=-469762049,this._ext|=t<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(t){this._ext&=-67108864,this._ext|=t&67108863}get urlId(){return this._urlId}set urlId(t){this._urlId=t}get underlineVariantOffset(){let t=(this._ext&3758096384)>>29;return t<0?t^4294967288:t}set underlineVariantOffset(t){this._ext&=536870911,this._ext|=t<<29&3758096384}clone(){return new ur(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}},ge=class fr extends vt{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new Pt,this.combinedData=""}static fromCharData(t){let i=new fr;return i.setFromCharData(t),i}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?Ae(this.content&2097151):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(t){this.fg=t[0],this.bg=0;let i=!1;if(t[1].length>2)i=!0;else if(t[1].length===2){let s=t[1].charCodeAt(0);if(55296<=s&&s<=56319){let r=t[1].charCodeAt(1);56320<=r&&r<=57343?this.content=(s-55296)*1024+r-56320+65536|t[2]<<22:i=!0}else i=!0}else this.content=t[1].charCodeAt(0)|t[2]<<22;i&&(this.combinedData=t[1],this.content=2097152|t[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor()||this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor()||this.isInverse()!==t.isInverse()||this.isBold()!==t.isBold()||this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;let i=this.isUnderlineColorDefault(),s=t.isUnderlineColorDefault();if(!(i&&s)&&(i!==s||this.getUnderlineColor()!==t.getUnderlineColor()||this.getUnderlineColorMode()!==t.getUnderlineColorMode()))return!1}return!(this.isOverline()!==t.isOverline()||this.isBlink()!==t.isBlink()||this.isInvisible()!==t.isInvisible()||this.isItalic()!==t.isItalic()||this.isDim()!==t.isDim()||this.isStrikethrough()!==t.isStrikethrough())}},li=new Map;function Vn(e){return e.di$dependencies||[]}function U(e){if(li.has(e))return li.get(e);let t=function(i,s,r){if(arguments.length!==3)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");Yn(t,i,r)};return t._id=e,li.set(e,t),t}function Yn(e,t,i){t.di$target===t?t.di$dependencies.push({id:e,index:i}):(t.di$dependencies=[{id:e,index:i}],t.di$target=t)}var ne=U("BufferService"),Ft=U("MouseStateService"),Ee=U("CoreService"),Xn=U("CharsetService"),qi=U("InstantiationService"),Je=U("LogService"),oe=U("OptionsService"),pr=U("OscLinkService"),jn=U("UnicodeService"),mt=U("DecorationService"),Si=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i,this._workCell=new ge}provideLinks(e,t){let i=this._bufferService.buffer.lines.get(e-1);if(!i){t(void 0);return}let s=[],r=this._optionsService.rawOptions.linkHandler,n=this._workCell,o=i.getTrimmedLength(),h=-1,l=-1,a=!1;for(let c=0;c<o;c++)if(!(l===-1&&!i.hasContent(c))){if(i.loadCell(c,n),n.hasExtendedAttrs()&&n.extended.urlId)if(l===-1){l=c,h=n.extended.urlId;continue}else a=n.extended.urlId!==h;else l!==-1&&(a=!0);if(a||l!==-1&&c===o-1){let d=this._oscLinkService.getLinkData(h)?.uri;if(d){let u=c+(!a&&c===o-1?1:0),f=this._getRangeWithLineWrap(e,l,u,h),_=!1;if(!r?.allowNonHttpProtocols)try{let p=new URL(d);["http:","https:"].includes(p.protocol)||(_=!0)}catch{_=!0}_||s.push({text:d,range:f,activate:(p,S)=>r?r.activate(p,S,f):Gn(p,S),hover:(p,S)=>r?.hover?.(p,S,f),leave:(p,S)=>r?.leave?.(p,S,f)})}a=!1,n.hasExtendedAttrs()&&n.extended.urlId?(l=c,h=n.extended.urlId):(l=-1,h=-1)}}t(s)}_getRangeWithLineWrap(e,t,i,s){let r=e,n=t,o=e,h=i;for(;n===0&&this._bufferService.buffer.lines.get(r-1)?.isWrapped;){let l=this._bufferService.buffer.lines.get(r-2);if(!l)break;let a=l.getTrimmedLength();if(a===0||!this._hasUrlId(l,a-1,s))break;let c=a-1;for(;c>0&&this._hasUrlId(l,c-1,s);)c--;r--,n=c}for(;;){let l=this._bufferService.buffer.lines.get(o-1);if(!l)break;let a=l.getTrimmedLength();if(h!==a)break;let c=this._bufferService.buffer.lines.get(o);if(!c?.isWrapped)break;let d=c.getTrimmedLength();if(d===0||!this._hasUrlId(c,0,s))break;let u=1;for(;u<d&&this._hasUrlId(c,u,s);)u++;o++,h=u}return{start:{x:n+1,y:r},end:{x:h,y:o}}}_hasUrlId(e,t,i){let s=this._workCell;return e.loadCell(t,s),!!s.hasExtendedAttrs()&&s.extended.urlId===i}};Si=F([g(0,ne),g(1,oe),g(2,pr)],Si);function Gn(e,t){if(confirm(`Do you want to navigate to ${t}? + +WARNING: This link could potentially be dangerous`)){let i=window.open();if(i){try{i.opener=null}catch{}i.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}var Wt=U("CharSizeService"),be=U("CoreBrowserService"),zt=U("MouseCoordsService"),Jn=U("MouseService"),ye=U("RenderService"),gr=U("SelectionService"),vr=U("CharacterJoinerService"),Ze=U("ThemeService"),mr=U("LinkProviderService"),Zn=U("KeyboardService");function O(e){return{dispose:e}}function ut(e){if(!e)return e;if(Array.isArray(e)){for(let t of e)t.dispose();return[]}return e.dispose(),e}var Qe=class{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(let e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(let e of this._disposables)e.dispose();this._disposables.clear()}},L=class{constructor(){this._store=new Qe}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}};L.None=Object.freeze({dispose(){}});var le=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}};function Qn(e,t=0,i){let s=setTimeout(()=>{e(),i&&r.dispose()},t),r=O(()=>{clearTimeout(s)});return i?.add(r),r}var Kt=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._token!==-1&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");this._token===-1&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},eo=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},to=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();let s=i.setInterval(()=>{e()},t);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}};function pe(e){let t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView;let i=e;return i?.view?i.view:window}var io=class{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s,e.addEventListener(t,i,s)}dispose(){!this._node||!this._handler||(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}};function D(e,t,i,s){return new io(e,t,i,s)}function Ls(e,t,i,s){return D(e,t,i,s)}var we={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};function so(e){let t=e.getBoundingClientRect(),i=pe(e);return{left:t.left+i.scrollX,top:t.top+i.scrollY,width:t.width,height:t.height}}var Sr=class{constructor(e,t){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){console.error(e)}}static sort(e,t){return t.priority-e.priority}},Rs=new Map;function wr(e){let t=Rs.get(e);return t||(t={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},Rs.set(e,t)),t}function ro(e){let t=wr(e);for(t.animFrameRequested=!1,t.current=t.next,t.next=[],t.inAnimationFrameRunner=!0;t.current.length>0;)t.current.sort(Sr.sort),t.current.shift().execute();t.inAnimationFrameRunner=!1}function Vi(e,t,i=0){let s=wr(e),r=new Sr(t,i);return s.next.push(r),s.animFrameRequested||(s.animFrameRequested=!0,e.requestAnimationFrame(()=>ro(e))),r}var no=class extends to{constructor(e){super(),this._defaultTarget=e?pe(e):void 0}cancelAndSet(e,t,i){super.cancelAndSet(e,t,i??this._defaultTarget??window)}},_t=class{constructor(e){this.domNode=e,this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._className="",this._position="",this._layerHint=!1,this._contain="none"}setWidth(e){let t=Ye(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){let t=Ye(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){let t=Ye(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){let t=Ye(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){let t=Ye(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){let t=Ye(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,e?this.domNode.style.transform="translate3d(0px, 0px, 0px)":this.domNode.style.transform="")}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}};function Ye(e){return typeof e=="number"?`${e}px`:e}var br={};Fn(br,{getSafariVersion:()=>ao,getZoomFactor:()=>yr,isChrome:()=>ji,isChromeOS:()=>Cr,isFirefox:()=>At,isLegacyEdge:()=>oo,isLinux:()=>Ji,isMac:()=>fe,isNode:()=>Yi,isSafari:()=>Gi,isWindows:()=>$t});var Yi=!!(typeof process<"u"&&"title"in process&&(typeof navigator>"u"||navigator.userAgent.startsWith("Node.js/"))),et=Yi?"node":navigator.userAgent,Xi=Yi?"node":navigator.platform,At=et.includes("Firefox"),ji=et.includes("Chrome"),oo=et.includes("Edge"),Gi=/^((?!chrome|android).)*safari/i.test(et);function yr(e){return 1}function ao(){if(!Gi)return 0;let e=et.match(/Version\/(\d+)/);return e===null||e.length<2?0:parseInt(e[1],10)}var fe=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(Xi),$t=["Windows","Win16","Win32","WinCE"].includes(Xi),Ji=Xi.indexOf("Linux")>=0,Cr=/\bCrOS\b/.test(et),Ts=new WeakMap;function ho(e){if(!e.parent||e.parent===e)return null;try{let t=e.location,i=e.parent.location;if(t.origin!=="null"&&i.origin!=="null"&&t.origin!==i.origin)return null}catch{return null}return e.parent}var lo=class{static _getSameOriginWindowChain(e){let t=Ts.get(e);if(!t){t=[],Ts.set(e,t);let i=e,s;do s=ho(i),s?t.push({window:new WeakRef(i),iframeElement:i.frameElement??null}):t.push({window:new WeakRef(i),iframeElement:null}),i=s;while(i)}return t.slice(0)}static getPositionOfChildWindowRelativeToAncestorWindow(e,t){if(!t||e===t)return{top:0,left:0};let i=0,s=0,r=this._getSameOriginWindowChain(e);for(let n of r){let o=n.window.deref();if(i+=o?.scrollY??0,s+=o?.scrollX??0,o===t||!n.iframeElement)break;let h=n.iframeElement.getBoundingClientRect();i+=h.top,s+=h.left}return{top:i,left:s}}},ci=class{constructor(e,t){this.timestamp=Date.now(),this.browserEvent=t,this.leftButton=t.button===0,this.middleButton=t.button===1,this.rightButton=t.button===2,this.buttons=t.buttons,this.target=t.target,this.detail=t.detail??1,t.type==="dblclick"&&(this.detail=2),this.ctrlKey=t.ctrlKey,this.shiftKey=t.shiftKey,this.altKey=t.altKey,this.metaKey=t.metaKey,typeof t.pageX=="number"?(this.posx=t.pageX,this.posy=t.pageY):(this.posx=t.clientX+this.target.ownerDocument.body.scrollLeft+this.target.ownerDocument.documentElement.scrollLeft,this.posy=t.clientY+this.target.ownerDocument.body.scrollTop+this.target.ownerDocument.documentElement.scrollTop);let i=lo.getPositionOfChildWindowRelativeToAncestorWindow(e,t.view);this.posx-=i.left,this.posy-=i.top}preventDefault(){this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent.stopPropagation()}},Ps=class{constructor(e,t=0,i=0){this.browserEvent=e??null,this.target=e?e.target??e.targetNode??e.srcElement??null:null,this.deltaY=i,this.deltaX=t;let s=!1;if(ji){let r=navigator.userAgent.match(/Chrome\/(\d+)/);s=(r?parseInt(r[1],10):123)<=122}if(e){let r=e,n=e,o=e.view?.devicePixelRatio??1;if(typeof r.wheelDeltaY<"u")s?this.deltaY=r.wheelDeltaY/(120*o):this.deltaY=r.wheelDeltaY/120;else if(typeof n.VERTICAL_AXIS<"u"&&n.axis===n.VERTICAL_AXIS)this.deltaY=-n.detail/3;else if(e.type==="wheel"){let h=e;h.deltaMode===h.DOM_DELTA_LINE?At&&!fe?this.deltaY=-e.deltaY/3:this.deltaY=-e.deltaY:this.deltaY=-e.deltaY/40}if(typeof r.wheelDeltaX<"u")Gi&&$t?this.deltaX=-(r.wheelDeltaX/120):s?this.deltaX=r.wheelDeltaX/(120*o):this.deltaX=r.wheelDeltaX/120;else if(typeof n.HORIZONTAL_AXIS<"u"&&n.axis===n.HORIZONTAL_AXIS)this.deltaX=-e.detail/3;else if(e.type==="wheel"){let h=e;h.deltaMode===h.DOM_DELTA_LINE?At&&!fe?this.deltaX=-e.deltaX/3:this.deltaX=-e.deltaX:this.deltaX=-e.deltaX/40}this.deltaY===0&&this.deltaX===0&&e.wheelDelta&&(s?this.deltaY=e.wheelDelta/(120*o):this.deltaY=e.wheelDelta/120)}}preventDefault(){this.browserEvent?.preventDefault()}stopPropagation(){this.browserEvent?.stopPropagation()}},kr=class{constructor(){this._hooks=new Qe,this._pointerMoveCallback=null,this._onStopCallback=null}dispose(){this.stopMonitoring(!1),this._hooks.dispose()}stopMonitoring(e){if(!this.isMonitoring())return;this._hooks.clear(),this._pointerMoveCallback=null;let t=this._onStopCallback;this._onStopCallback=null,e&&t&&t()}isMonitoring(){return!!this._pointerMoveCallback}startMonitoring(e,t,i,s,r){this.isMonitoring()&&this.stopMonitoring(!1),this._pointerMoveCallback=s,this._onStopCallback=r;let n=e;try{e.setPointerCapture(t),this._hooks.add(O(()=>{try{e.releasePointerCapture(t)}catch{}}))}catch{n=pe(e)}this._hooks.add(D(n,we.POINTER_MOVE,o=>{if(o.buttons!==i){this.stopMonitoring(!0);return}o.preventDefault(),this._pointerMoveCallback(o)})),this._hooks.add(D(n,we.POINTER_UP,o=>this.stopMonitoring(!0)))}},Zi=class extends L{_onclick(e,t){this._register(D(e,we.CLICK,i=>t(new ci(pe(e),i))))}_onmouseover(e,t){this._register(D(e,we.MOUSE_OVER,i=>t(new ci(pe(e),i))))}_onmouseleave(e,t){this._register(D(e,we.MOUSE_LEAVE,i=>t(new ci(pe(e),i))))}},co=class extends Zi{constructor(e){super(),this._handleActivate=e.handleActivate,this.bgDomNode=document.createElement("div"),this.bgDomNode.className="xterm-arrow-background",this.bgDomNode.style.position="absolute",this.bgDomNode.style.width=e.bgWidth+"px",this.bgDomNode.style.height=e.bgHeight+"px",typeof e.top<"u"&&(this.bgDomNode.style.top="0px"),typeof e.left<"u"&&(this.bgDomNode.style.left="0px"),typeof e.bottom<"u"&&(this.bgDomNode.style.bottom="0px"),typeof e.right<"u"&&(this.bgDomNode.style.right="0px"),this.domNode=document.createElement("div"),this.domNode.className=e.className,this.domNode.style.position="absolute";let t=Math.min(e.bgWidth,e.bgHeight);this.domNode.style.width=t+"px",this.domNode.style.height=t+"px",typeof e.top<"u"&&(this.domNode.style.top=e.top+"px"),typeof e.left<"u"&&(this.domNode.style.left=e.left+"px"),typeof e.bottom<"u"&&(this.domNode.style.bottom=e.bottom+"px"),typeof e.right<"u"&&(this.domNode.style.right=e.right+"px"),this._pointerMoveMonitor=this._register(new kr),this._register(Ls(this.bgDomNode,we.POINTER_DOWN,i=>this._arrowPointerDown(i))),this._register(Ls(this.domNode,we.POINTER_DOWN,i=>this._arrowPointerDown(i))),this._pointerdownRepeatTimer=this._register(new no),this._pointerdownScheduleRepeatTimer=this._register(new Kt)}_arrowPointerDown(e){if(!e.target||!(e.target instanceof Element))return;let t=()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._handleActivate(),1e3/24,pe(e))};this._handleActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(t,200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,i=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),e.preventDefault()}},y=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event?this._event:(this._event=(e,t,i)=>{if(this._disposed)return O(()=>{});let s={fn:e,thisArgs:t};this._listeners.push(s);let r=O(()=>{let n=this._listeners.indexOf(s);n!==-1&&this._listeners.splice(n,1)});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r},this._event)}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{let{fn:t,thisArgs:i}=this._listeners[0];t.call(i,e);return}default:{let t=this._listeners.slice();for(let{fn:i,thisArgs:s}of t)i.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},he;(e=>{function t(n,o){return n(h=>o.fire(h))}e.forward=t;function i(n,o){return(h,l,a)=>n(c=>h.call(l,o(c)),void 0,a)}e.map=i;function s(...n){return(o,h,l)=>{let a=new Qe;for(let c of n)a.add(c(d=>o.call(h,d)));return l&&(Array.isArray(l)?l.push(a):l.add(a)),a}}e.any=s;function r(n,o,h){return o(h),n(l=>o(l))}e.runAndSubscribe=r})(he||={});var _o=class wi{constructor(t,i,s,r,n,o,h){this._forceIntegerValues=t,this._scrollStateBrand=void 0,this._forceIntegerValues&&(i=i|0,s=s|0,r=r|0,n=n|0,o=o|0,h=h|0),this.rawScrollLeft=r,this.rawScrollTop=h,i<0&&(i=0),r+i>s&&(r=s-i),r<0&&(r=0),n<0&&(n=0),h+n>o&&(h=o-n),h<0&&(h=0),this.width=i,this.scrollWidth=s,this.scrollLeft=r,this.height=n,this.scrollHeight=o,this.scrollTop=h}equals(t){return this.rawScrollLeft===t.rawScrollLeft&&this.rawScrollTop===t.rawScrollTop&&this.width===t.width&&this.scrollWidth===t.scrollWidth&&this.scrollLeft===t.scrollLeft&&this.height===t.height&&this.scrollHeight===t.scrollHeight&&this.scrollTop===t.scrollTop}withScrollDimensions(t,i){return new wi(this._forceIntegerValues,typeof t.width<"u"?t.width:this.width,typeof t.scrollWidth<"u"?t.scrollWidth:this.scrollWidth,i?this.rawScrollLeft:this.scrollLeft,typeof t.height<"u"?t.height:this.height,typeof t.scrollHeight<"u"?t.scrollHeight:this.scrollHeight,i?this.rawScrollTop:this.scrollTop)}withScrollPosition(t){return new wi(this._forceIntegerValues,this.width,this.scrollWidth,typeof t.scrollLeft<"u"?t.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,typeof t.scrollTop<"u"?t.scrollTop:this.rawScrollTop)}createScrollEvent(t,i){let s=this.width!==t.width,r=this.scrollWidth!==t.scrollWidth,n=this.scrollLeft!==t.scrollLeft,o=this.height!==t.height,h=this.scrollHeight!==t.scrollHeight,l=this.scrollTop!==t.scrollTop;return{inSmoothScrolling:i,oldWidth:t.width,oldScrollWidth:t.scrollWidth,oldScrollLeft:t.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:t.height,oldScrollHeight:t.scrollHeight,oldScrollTop:t.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:s,scrollWidthChanged:r,scrollLeftChanged:n,heightChanged:o,scrollHeightChanged:h,scrollTopChanged:l}}},xr=class extends L{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new y),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new _o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){let i=this._state.withScrollDimensions(e,t);this._setState(i,!!this._smoothScrolling),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){let t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(this._smoothScrollDuration===0){this.setScrollPositionNow(e);return}if(this._smoothScrolling){e={scrollLeft:typeof e.scrollLeft>"u"?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:typeof e.scrollTop>"u"?this._smoothScrolling.to.scrollTop:e.scrollTop};let i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;t?s=new _i(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):s=_i.start(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{let i=this._state.withScrollPosition(e);this._smoothScrolling=_i.start(this._state,i,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}hasPendingScrollAnimation(){return!!this._smoothScrolling}_performSmoothScrolling(){if(!this._smoothScrolling)return;let e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);if(this._setState(t,!0),!!this._smoothScrolling){if(e.isDone){this._smoothScrolling.dispose(),this._smoothScrolling=null;return}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}}_setState(e,t){let i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}},As=class{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}};function di(e,t){let i=t-e;return function(s){return e+i*po(s)}}function uo(e,t,i){return function(s){return s<i?e(s/i):t((s-i)/(1-i))}}var _i=class Br{constructor(t,i,s,r){this.from=t,this.to=i,this.duration=r,this.startTime=s,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this._scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this._scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(t,i,s){if(Math.abs(t-i)>2.5*s){let r,n;return t<i?(r=t+.75*s,n=i-.75*s):(r=t-.75*s,n=i+.75*s),uo(di(t,r),di(n,i),.33)}return di(t,i)}dispose(){this.animationFrameDisposable!==null&&(this.animationFrameDisposable.dispose(),this.animationFrameDisposable=null)}acceptScrollDimensions(t){this.to=t.withScrollPosition(this.to),this._initAnimations()}tick(){return this._tick(Date.now())}_tick(t){let i=(t-this.startTime)/this.duration;if(i<1){let s=this._scrollLeft(i),r=this._scrollTop(i);return new As(s,r,!1)}return new As(this.to.scrollLeft,this.to.scrollTop,!0)}static start(t,i,s){s=s+10;let r=Date.now()-10;return new Br(t,i,r,s)}};function fo(e){return Math.pow(e,3)}function po(e){return 1-fo(1-e)}var go=class extends L{constructor(e,t,i){super(),this._visibility=e,this._visibleClassName=t,this._invisibleClassName=i,this._domNode=null,this._isVisible=!1,this._isNeeded=!1,this._rawShouldBeVisible=!1,this._shouldBeVisible=!1,this._revealTimer=this._register(new Kt)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this._updateShouldBeVisible())}setShouldBeVisible(e){this._rawShouldBeVisible=e,this._updateShouldBeVisible()}_applyVisibilitySetting(){return this._visibility===2?!1:this._visibility===3?!0:this._rawShouldBeVisible}_updateShouldBeVisible(){let e=this._applyVisibilitySetting();this._shouldBeVisible!==e&&(this._shouldBeVisible=e,this.ensureVisibility())}setIsNeeded(e){this._isNeeded!==e&&(this._isNeeded=e,this.ensureVisibility())}setDomNode(e){this._domNode=e,this._domNode.setClassName(this._invisibleClassName),this.setShouldBeVisible(!1)}ensureVisibility(){if(!this._isNeeded){this._hide(!1);return}this._shouldBeVisible?this._reveal():this._hide(!0)}_reveal(){this._isVisible||(this._isVisible=!0,this._revealTimer.setIfNotSet(()=>{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" xterm-fade":"")))}},vo=140,Er=class extends Zi{constructor(e){super(),this._lazyRender=e.lazyRender,this._host=e.host,this._scrollable=e.scrollable,this._scrollByPage=e.scrollByPage,this._scrollbarState=e.scrollbarState,this._visibilityController=this._register(new go(e.visibility,"xterm-visible xterm-scrollbar "+e.extraScrollbarClassName,"xterm-invisible xterm-scrollbar "+e.extraScrollbarClassName)),this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._pointerMoveMonitor=this._register(new kr),this._shouldRender=!0,this.domNode=new _t(document.createElement("div")),this.domNode.setAttribute("role","presentation"),this.domNode.setAttribute("aria-hidden","true"),this._visibilityController.setDomNode(this.domNode),this.domNode.setPosition("absolute"),this._register(D(this.domNode.domNode,we.POINTER_DOWN,t=>this._domNodePointerDown(t)))}_createArrow(e){let t=this._register(new co(e));return this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode),t}_createSlider(e,t,i,s){this.slider=new _t(document.createElement("div")),this.slider.setClassName("xterm-slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),typeof i=="number"&&this.slider.setWidth(i),typeof s=="number"&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(D(this.slider.domNode,we.POINTER_DOWN,r=>{r.button===0&&(r.preventDefault(),this._sliderPointerDown(r))})),this._onclick(this.slider.domNode,r=>{r.leftButton&&r.stopPropagation()})}_handleElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._handlePointerDown(e)}delegatePointerDown(e){let t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?e.button===0&&(e.preventDefault(),this._sliderPointerDown(e)):this._handlePointerDown(e)}_handlePointerDown(e){let t,i;if(e.target===this.domNode.domNode&&typeof e.offsetX=="number"&&typeof e.offsetY=="number")t=e.offsetX,i=e.offsetY;else{let r=so(this.domNode.domNode);t=e.pageX-r.left,i=e.pageY-r.top}let s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),e.button===0&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!e.target||!(e.target instanceof Element))return;let t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("xterm-active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,r=>{let n=this._sliderOrthogonalPointerPosition(r),o=Math.abs(n-i);if($t&&o>vo){this._setDesiredScrollPositionNow(s.getScrollPosition());return}let h=this._sliderPointerPosition(r)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(h))},()=>{this.slider.toggleClassName("xterm-active",!1),this._host.handleDragEnd()}),this._host.handleDragStart()}_setDesiredScrollPositionNow(e){let t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}},Dr=class bi{constructor(t,i,s,r,n,o){this._scrollbarSize=Math.round(i),this._oppositeScrollbarSize=Math.round(s),this._arrowSize=Math.round(t),this._visibleSize=r,this._scrollSize=n,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new bi(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(t){let i=Math.round(t);return this._visibleSize!==i?(this._visibleSize=i,this._refreshComputedValues(),!0):!1}setScrollSize(t){let i=Math.round(t);return this._scrollSize!==i?(this._scrollSize=i,this._refreshComputedValues(),!0):!1}setScrollPosition(t){let i=Math.round(t);return this._scrollPosition!==i?(this._scrollPosition=i,this._refreshComputedValues(),!0):!1}setScrollbarSize(t){this._scrollbarSize=Math.round(t)}setArrowSize(t){let i=Math.round(t);this._arrowSize!==i&&(this._arrowSize=i,this._refreshComputedValues())}setOppositeScrollbarSize(t){this._oppositeScrollbarSize=Math.round(t)}static _computeValues(t,i,s,r,n){let o=Math.max(0,s-t),h=Math.max(0,o-2*i),l=r>0&&r>s;if(!l)return{computedAvailableSize:Math.round(o),computedIsNeeded:l,computedSliderSize:Math.round(h),computedSliderRatio:0,computedSliderPosition:0};let a=Math.round(Math.max(20,Math.floor(s*h/r))),c=(h-a)/(r-s),d=n*c;return{computedAvailableSize:Math.round(o),computedIsNeeded:l,computedSliderSize:Math.round(a),computedSliderRatio:c,computedSliderPosition:Math.round(d)}}_refreshComputedValues(){let t=bi._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=t.computedAvailableSize,this._computedIsNeeded=t.computedIsNeeded,this._computedSliderSize=t.computedSliderSize,this._computedSliderRatio=t.computedSliderRatio,this._computedSliderPosition=t.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(t){if(!this._computedIsNeeded)return 0;let i=t-this._arrowSize-this._computedSliderSize/2;return Math.round(i/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(t){if(!this._computedIsNeeded)return 0;let i=t-this._arrowSize,s=this._scrollPosition;return i<this._computedSliderPosition?s-=this._visibleSize:s+=this._visibleSize,s}getDesiredScrollPositionFromDelta(t){if(!this._computedIsNeeded)return 0;let i=this._computedSliderPosition+t;return Math.round(i/this._computedSliderRatio)}},mo=class extends Er{constructor(e,t,i){let s=e.getScrollDimensions(),r=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new Dr(t.horizontalHasArrows?t.horizontalScrollbarSize:0,t.horizontal===2?0:t.horizontalScrollbarSize,t.vertical===2?0:t.verticalScrollbarSize,s.width,s.scrollWidth,r.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"xterm-horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._handleElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(e.horizontal===2?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(e.vertical===2?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}},So=class extends Er{constructor(e,t,i){let s=e.getScrollDimensions(),r=e.getCurrentScrollPosition(),n=t.verticalHasArrows;super({lazyRender:t.lazyRender,host:i,scrollbarState:new Dr(n?t.verticalScrollbarSize:0,t.vertical===2?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,r.scrollTop),visibility:t.vertical,extraScrollbarClassName:"xterm-vertical",scrollable:e,scrollByPage:t.scrollByPage}),this._arrowScrollDelta=0,this._setArrows(n,t.verticalScrollbarSize),this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._handleElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}_arrowScroll(e){let t=this._scrollable.getCurrentScrollPosition();this._scrollable.setScrollPositionNow({scrollTop:t.scrollTop+e})}_setArrows(e,t){if(this._arrowScrollDelta=t,(!this._arrowUp||!this._arrowDown)&&(this._arrowUp=this._createArrow({className:"xterm-scra xterm-arrow-up",top:0,left:0,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(-this._arrowScrollDelta)}),this._arrowDown=this._createArrow({className:"xterm-scra xterm-arrow-down",bottom:0,left:0,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(this._arrowScrollDelta)})),this._updateArrowSize(this._arrowUp,t),this._updateArrowSize(this._arrowDown,t),!this._arrowUp||!this._arrowDown)return;let i=e?"":"none";this._arrowUp.bgDomNode.style.display=i,this._arrowUp.domNode.style.display=i,this._arrowDown.bgDomNode.style.display=i,this._arrowDown.domNode.style.display=i}_updateArrowSize(e,t){e&&(e.bgDomNode.style.width=`${t}px`,e.bgDomNode.style.height=`${t}px`,e.domNode.style.width=`${t}px`,e.domNode.style.height=`${t}px`)}updateOptions(e){let t=e.verticalHasArrows?e.verticalScrollbarSize:0;this._scrollbarState.setArrowSize(t),this._setArrows(e.verticalHasArrows,e.verticalScrollbarSize),this.updateScrollbarSize(e.vertical===2?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}},wo=class{constructor(e,t,i){this.timestamp=e,this.deltaX=t,this.deltaY=i,this.score=0}},yi=class{constructor(){this._capacity=5,this._memory=[],this._front=-1,this._rear=-1}isPhysicalMouseWheel(){if(this._front===-1&&this._rear===-1)return!1;let t=1,i=0,s=1,r=this._rear;for(;r!==-1;){let n=r===this._front?t:Math.pow(2,-s);if(t-=n,i+=this._memory[r].score*n,r===this._front)break;r=(this._capacity+r-1)%this._capacity,s++}return i<=.5}acceptStandardWheelEvent(t){if(ji){let i=pe(t.browserEvent),s=yr(i);this.accept(Date.now(),t.deltaX*s,t.deltaY*s)}else this.accept(Date.now(),t.deltaX,t.deltaY)}accept(t,i,s){let r=null,n=new wo(t,i,s);this._front===-1&&this._rear===-1?(this._memory[0]=n,this._front=0,this._rear=0):(r=this._memory[this._rear],this._rear=(this._rear+1)%this._capacity,this._rear===this._front&&(this._front=(this._front+1)%this._capacity),this._memory[this._rear]=n),n.score=this._computeScore(n,r)}_computeScore(t,i){if(Math.abs(t.deltaX)>0&&Math.abs(t.deltaY)>0)return 1;let s=.5;if((!this._isAlmostInt(t.deltaX)||!this._isAlmostInt(t.deltaY))&&(s+=.25),i){let r=Math.abs(t.deltaX),n=Math.abs(t.deltaY),o=Math.abs(i.deltaX),h=Math.abs(i.deltaY),l=Math.max(Math.min(r,o),1),a=Math.max(Math.min(n,h),1),c=Math.max(r,o),d=Math.max(n,h);c%l===0&&d%a===0&&(s-=.5)}return Math.min(Math.max(s,0),1)}_isAlmostInt(t){return Math.abs(Math.round(t)-t)<.01}};yi.INSTANCE=new yi;var bo=yi,yo=class extends Zi{constructor(e,t,i){super(),this._onScroll=this._register(new y),this.onScroll=this._onScroll.event,t=t??{};let s,r=!i;i?s=i:(t.mouseWheelSmoothScroll=!1,s=new xr({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:o=>Vi(pe(e),o)})),this._options=Co(t),this._scrollable=s,this._register(this._scrollable.onScroll(o=>{this._handleScroll(o),this._onScroll.fire(o)})),r&&this._register(this._scrollable);let n={handleMouseWheel:o=>this._handleMouseWheel(o),handleDragStart:()=>this._handleDragStart(),handleDragEnd:()=>this._handleDragEnd()};this._verticalScrollbar=this._register(new So(this._scrollable,this._options,n)),this._horizontalScrollbar=this._register(new mo(this._scrollable,this._options,n)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=new _t(document.createElement("div")),this._leftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=new _t(document.createElement("div")),this._topShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=new _t(document.createElement("div")),this._topLeftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode??this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this._onmouseover(this._listenOnDomNode,o=>this._handleMouseOver(o)),this._onmouseleave(this._listenOnDomNode,o=>this._handleMouseLeave(o)),this._hideTimeout=this._register(new Kt),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}get options(){return this._options}dispose(){this._mouseWheelToDispose=ut(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}updateClassName(e){this._options.className=e,fe&&(this._options.className+=" xterm-mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){typeof e.handleMouseWheel<"u"&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),typeof e.mouseWheelScrollSensitivity<"u"&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),typeof e.fastScrollSensitivity<"u"&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),typeof e.scrollPredominantAxis<"u"&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),typeof e.horizontal<"u"&&(this._options.horizontal=e.horizontal),typeof e.vertical<"u"&&(this._options.vertical=e.vertical),typeof e.horizontalHasArrows<"u"&&(this._options.horizontalHasArrows=e.horizontalHasArrows),typeof e.verticalHasArrows<"u"&&(this._options.verticalHasArrows=e.verticalHasArrows),typeof e.horizontalScrollbarSize<"u"&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),typeof e.verticalScrollbarSize<"u"&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),typeof e.scrollByPage<"u"&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}delegateScrollFromMouseWheelEvent(e){this._handleMouseWheel(new Ps(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=ut(this._mouseWheelToDispose),e)){let t=i=>{this._handleMouseWheel(new Ps(i))};this._mouseWheelToDispose.push(D(this._listenOnDomNode,we.MOUSE_WHEEL,t,{passive:!1}))}}_handleMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;let t=bo.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let r=e.deltaY*this._options.mouseWheelScrollSensitivity,n=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&n+r===0?n=r=0:Math.abs(r)>=Math.abs(n)?n=0:r=0),this._options.flipAxes&&([r,n]=[n,r]);let o=!fe&&e.browserEvent&&e.browserEvent.shiftKey;(this._options.scrollYToX||o)&&!n&&(n=r,r=0),e.browserEvent&&e.browserEvent.altKey&&(n=n*this._options.fastScrollSensitivity,r=r*this._options.fastScrollSensitivity);let h=this._scrollable.getFutureScrollPosition(),l={};if(r){let a=50*r,c=h.scrollTop-(a<0?Math.floor(a):Math.ceil(a));this._verticalScrollbar.writeScrollPosition(l,c)}if(n){let a=50*n,c=h.scrollLeft-(a<0?Math.floor(a):Math.ceil(a));this._horizontalScrollbar.writeScrollPosition(l,c)}l=this._scrollable.validateScrollPosition(l),(h.scrollLeft!==l.scrollLeft||h.scrollTop!==l.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(l):this._scrollable.setScrollPositionNow(l),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_handleScroll(e){this._shouldRender=this._horizontalScrollbar.handleScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.handleScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){let e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" xterm-shadow-left":"",r=t?" xterm-shadow-top":"",n=i||t?" xterm-shadow-top-left-corner":"";this._leftShadowDomNode.setClassName(`xterm-shadow${s}`),this._topShadowDomNode.setClassName(`xterm-shadow${r}`),this._topLeftShadowDomNode.setClassName(`xterm-shadow${n}${r}${s}`)}}_handleDragStart(){this._isDragging=!0,this._reveal()}_handleDragEnd(){this._isDragging=!1,this._hide()}_handleMouseLeave(e){this._mouseIsOver=!1,this._hide()}_handleMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){!this._mouseIsOver&&!this._isDragging&&(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){!this._mouseIsOver&&!this._isDragging&&this._hideTimeout.cancelAndSet(()=>this._hide(),500)}};function Co(e){let t={lazyRender:typeof e.lazyRender<"u"?e.lazyRender:!1,className:typeof e.className<"u"?e.className:"",useShadows:typeof e.useShadows<"u"?e.useShadows:!0,handleMouseWheel:typeof e.handleMouseWheel<"u"?e.handleMouseWheel:!0,flipAxes:typeof e.flipAxes<"u"?e.flipAxes:!1,consumeMouseWheelIfScrollbarIsNeeded:typeof e.consumeMouseWheelIfScrollbarIsNeeded<"u"?e.consumeMouseWheelIfScrollbarIsNeeded:!1,alwaysConsumeMouseWheel:typeof e.alwaysConsumeMouseWheel<"u"?e.alwaysConsumeMouseWheel:!1,scrollYToX:typeof e.scrollYToX<"u"?e.scrollYToX:!1,mouseWheelScrollSensitivity:typeof e.mouseWheelScrollSensitivity<"u"?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:typeof e.fastScrollSensitivity<"u"?e.fastScrollSensitivity:5,scrollPredominantAxis:typeof e.scrollPredominantAxis<"u"?e.scrollPredominantAxis:!0,mouseWheelSmoothScroll:typeof e.mouseWheelSmoothScroll<"u"?e.mouseWheelSmoothScroll:!0,listenOnDomNode:typeof e.listenOnDomNode<"u"?e.listenOnDomNode:null,horizontal:typeof e.horizontal<"u"?e.horizontal:1,horizontalScrollbarSize:typeof e.horizontalScrollbarSize<"u"?e.horizontalScrollbarSize:10,horizontalSliderSize:typeof e.horizontalSliderSize<"u"?e.horizontalSliderSize:0,horizontalHasArrows:typeof e.horizontalHasArrows<"u"?e.horizontalHasArrows:!1,vertical:typeof e.vertical<"u"?e.vertical:1,verticalScrollbarSize:typeof e.verticalScrollbarSize<"u"?e.verticalScrollbarSize:10,verticalHasArrows:typeof e.verticalHasArrows<"u"?e.verticalHasArrows:!1,verticalSliderSize:typeof e.verticalSliderSize<"u"?e.verticalSliderSize:0,scrollByPage:typeof e.scrollByPage<"u"?e.scrollByPage:!1};return t.horizontalSliderSize=typeof e.horizontalSliderSize<"u"?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=typeof e.verticalSliderSize<"u"?e.verticalSliderSize:t.verticalScrollbarSize,fe&&(t.className+=" xterm-mac"),t}var Ci=class extends L{constructor(e,t,i,s,r,n,o,h,l){super(),this._bufferService=i,this._coreService=r,this._optionsService=h,this._renderService=l,this._onRequestScrollLines=this._register(new y),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1,this._needsSyncOnRender=!1;let a=this._register(new xr({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:c=>Vi(s.window,c)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{a.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new yo(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,verticalHasArrows:this._optionsService.rawOptions.scrollbar?.showArrows??!1,...this._getChangeOptions()},a)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","scrollbar"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(n.onProtocolChange(c=>{this._scrollableElement.updateOptions({handleMouseWheel:!(c&16)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(he.runAndSubscribe(o.onChangeColors,()=>{e.style.backgroundColor=o.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=o.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register(O(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register(O(()=>this._styleElement.remove())),this._register(he.runAndSubscribe(o.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {",` background: ${o.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {",` background: ${o.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {",` background: ${o.colors.scrollbarSliderActiveBackground.css};`,"}"].join(` +`)})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(c=>this._handleScroll(c)))}scrollLines(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){let e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,i=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:i,verticalHasArrows:t}}queueSync(e){e!==void 0&&(this._latestYDisp=e),this._queuedAnimationFrame===void 0&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){if(!(!this._renderService||this._isSyncing)){if(this._coreService.decPrivateModes.synchronizedOutput){this._needsSyncOnRender=!0;return}this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1}}_handleScroll(e){if(!this._renderService||this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;let t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;i!==0&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}handleTouchScroll(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};Ci=F([g(2,ne),g(3,be),g(4,Ee),g(5,Ft),g(6,Ze),g(7,oe),g(8,ye)],Ci);var ki=class extends L{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(n=>this._removeDecoration(n))),this._register(O(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){this._animationFrame===void 0&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(let e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){let t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer",e?.options?.layer==="top"),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,t.style.top=`${(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height}px`,t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;let i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){let t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose(()=>{this._decorationElements.delete(e),i.remove()})),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,i.style.top=`${t*this._renderService.dimensions.css.cell.height}px`,i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;let i=e.options.x??0;(e.options.anchor||"left")==="right"?t.style.right=i?`${i*this._renderService.dimensions.css.cell.width}px`:"":t.style.left=i?`${i*this._renderService.dimensions.css.cell.width}px`:""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};ki=F([g(1,ne),g(2,be),g(3,mt),g(4,ye)],ki);var ko=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(let t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position)){this._addLineToZone(t,e.marker.line);return}}if(this._zonePoolIndex<this._zonePool.length){this._zonePool[this._zonePoolIndex].color=e.options.overviewRulerOptions.color,this._zonePool[this._zonePoolIndex].position=e.options.overviewRulerOptions.position,this._zonePool[this._zonePoolIndex].startBufferLine=e.marker.line,this._zonePool[this._zonePoolIndex].endBufferLine=e.marker.line,this._zones.push(this._zonePool[this._zonePoolIndex++]);return}this._zones.push({color:e.options.overviewRulerOptions.color,position:e.options.overviewRulerOptions.position,startBufferLine:e.marker.line,endBufferLine:e.marker.line}),this._zonePool.push(this._zones[this._zones.length-1]),this._zonePoolIndex++}}setPadding(e){this._linePadding=e}_lineIntersectsZone(e,t){return t>=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}},me={full:0,left:0,center:0,right:0},Pe={full:0,left:0,center:0,right:0},st={full:0,left:0,center:0,right:0},Ot=class extends L{constructor(e,t,i,s,r,n,o,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=n,this._themeService=o,this._coreBrowserService=h,this._colorZoneStore=new ko,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register(O(()=>this._canvas?.remove()));let l=this._canvas.getContext("2d");if(l)this._ctx=l;else throw new Error("Ctx cannot be null");this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register(O(()=>{this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}get _width(){let e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??!0?e?.width??0:0}_refreshDrawConstants(){let e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);Pe.full=this._canvas.width,Pe.left=e,Pe.center=t,Pe.right=e,this._refreshDrawHeightConstants(),st.full=1,st.left=1,st.center=1+Pe.left,st.right=1+Pe.left+Pe.center}_refreshDrawHeightConstants(){me.full=Math.round(2*this._coreBrowserService.dpr);let e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);me.left=t,me.center=t,me.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*me.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*me.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*me.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*me.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;let e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(let t of this._decorationService.decorations)this._colorZoneStore.addDecoration(t);this._ctx.lineWidth=1,this._renderRulerOutline();let e=this._colorZoneStore.zones;for(let t of e)t.position!=="full"&&this._renderColorZone(t);for(let t of e)t.position==="full"&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(st[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-me[e.position||"full"]/2),Pe[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+me[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};Ot=F([g(2,ne),g(3,mt),g(4,ye),g(5,oe),g(6,Ze),g(7,be)],Ot);var xi=class{constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent=""}get isComposing(){return this._isComposing}compositionstart(){this._isComposing=!0;let e=this._textarea.selectionStart??this._textarea.value.length,t=this._textarea.selectionEnd??e;this._compositionPosition.start=Math.min(e,t),this._compositionPosition.end=Math.max(e,t),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=`\u200E${e.data}\u200E`,this.updateCompositionElements(),setTimeout(()=>{let t=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,t)},0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(e.keyCode===20||e.keyCode===229||e.keyCode===16||e.keyCode===17||e.keyCode===18)return!1;this._finalizeComposition(!1)}return e.keyCode===229?(this._handleAnyTextareaChanges(),!1):!0}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){let t={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._compositionSuffix;this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){this._isSendingComposition=!1;let s;if(t.start+=this._dataAlreadySent.length,this._isComposing)s=this._textarea.value.substring(t.start,this._compositionPosition.start);else{let r=this._textarea.value,n=i.length>0&&r.endsWith(i)?r.length-i.length:r.length;s=r.substring(t.start,Math.max(t.start,n))}s.length>0&&this._coreService.triggerDataEvent(s,!0)}},0)}else{this._isSendingComposition=!1;let t=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(t,!0)}}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;let e=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){let t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent("\x7F",!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}},0)}updateCompositionElements(e){if(this._isComposing){if(this._bufferService.buffer.isCursorInViewport){let t=Math.min(this._bufferService.buffer.x,this._bufferService.cols-1),i=this._renderService.dimensions.css.cell.height,s=this._bufferService.buffer.y*this._renderService.dimensions.css.cell.height,r=t*this._renderService.dimensions.css.cell.width;this._compositionView.style.left=r+"px",this._compositionView.style.top=s+"px",this._compositionView.style.height=i+"px",this._compositionView.style.lineHeight=i+"px",this._compositionView.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._compositionView.style.fontSize=this._optionsService.rawOptions.fontSize+"px";let n=this._bufferService.cols*this._renderService.dimensions.css.cell.width-r;this._compositionView.style.maxWidth=n+"px",this._compositionView.style.overflow="hidden",this._compositionView.style.direction="rtl";let o=this._compositionView.getBoundingClientRect();this._textarea.style.left=r+"px",this._textarea.style.top=s+"px",this._textarea.style.width=Math.max(o.width,1)+"px",this._textarea.style.height=Math.max(o.height,1)+"px",this._textarea.style.lineHeight=o.height+"px"}e||setTimeout(()=>this.updateCompositionElements(!0),0)}}};xi=F([g(2,ne),g(3,oe),g(4,Ee),g(5,ye)],xi);var J=0,Z=0,Q=0,z=0,Os={css:"#00000000",rgba:0},$;(e=>{function t(r,n,o,h){return h!==void 0?`#${Fe(r)}${Fe(n)}${Fe(o)}${Fe(h)}`:`#${Fe(r)}${Fe(n)}${Fe(o)}`}e.toCss=t;function i(r,n,o,h=255){return(r<<24|n<<16|o<<8|h)>>>0}e.toRgba=i;function s(r,n,o,h){return{css:e.toCss(r,n,o,h),rgba:e.toRgba(r,n,o,h)}}e.toColor=s})($||={});var H;(e=>{function t(l,a){if(z=(a.rgba&255)/255,z===1)return{css:a.css,rgba:a.rgba};let c=a.rgba>>24&255,d=a.rgba>>16&255,u=a.rgba>>8&255,f=l.rgba>>24&255,_=l.rgba>>16&255,p=l.rgba>>8&255;J=f+Math.round((c-f)*z),Z=_+Math.round((d-_)*z),Q=p+Math.round((u-p)*z);let S=$.toCss(J,Z,Q),k=$.toRgba(J,Z,Q);return{css:S,rgba:k}}e.blend=t;function i(l){return(l.rgba&255)===255}e.isOpaque=i;function s(l,a,c){let d=Lt.ensureContrastRatio(l.rgba,a.rgba,c);if(d)return $.toColor(d>>24&255,d>>16&255,d>>8&255)}e.ensureContrastRatio=s;function r(l){let a=(l.rgba|255)>>>0;return[J,Z,Q]=Lt.toChannels(a),{css:$.toCss(J,Z,Q),rgba:a}}e.opaque=r;function n(l,a){return z=Math.round(a*255),[J,Z,Q]=Lt.toChannels(l.rgba),{css:$.toCss(J,Z,Q,z),rgba:$.toRgba(J,Z,Q,z)}}e.opacity=n;function o(l,a){return z=l.rgba&255,n(l,z*a/255)}e.multiplyOpacity=o;function h(l){return[l.rgba>>24&255,l.rgba>>16&255,l.rgba>>8&255]}e.toColorRGB=h})(H||={});var W;(e=>{let t,i;try{let r=document.createElement("canvas");r.width=1,r.height=1;let n=r.getContext("2d",{willReadFrequently:!0});n&&(t=n,t.globalCompositeOperation="copy",i=t.createLinearGradient(0,0,1,1))}catch{}function s(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return J=parseInt(r.slice(1,2).repeat(2),16),Z=parseInt(r.slice(2,3).repeat(2),16),Q=parseInt(r.slice(3,4).repeat(2),16),$.toColor(J,Z,Q);case 5:return J=parseInt(r.slice(1,2).repeat(2),16),Z=parseInt(r.slice(2,3).repeat(2),16),Q=parseInt(r.slice(3,4).repeat(2),16),z=parseInt(r.slice(4,5).repeat(2),16),$.toColor(J,Z,Q,z);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let n=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(n)return J=parseInt(n[1],10),Z=parseInt(n[2],10),Q=parseInt(n[3],10),z=Math.round((n[5]===void 0?1:parseFloat(n[5]))*255),$.toColor(J,Z,Q,z);if(r==="transparent")return{css:"transparent",rgba:0};if(!t||!i)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=i,t.fillStyle=r,typeof t.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[J,Z,Q,z]=t.getImageData(0,0,1,1).data,z!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:$.toRgba(J,Z,Q,z),css:r}}e.toColor=s})(W||={});var ie;(e=>{function t(s){return i(s>>16&255,s>>8&255,s&255)}e.relativeLuminance=t;function i(s,r,n){let o=s/255,h=r/255,l=n/255,a=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4),c=h<=.03928?h/12.92:Math.pow((h+.055)/1.055,2.4),d=l<=.03928?l/12.92:Math.pow((l+.055)/1.055,2.4);return a*.2126+c*.7152+d*.0722}e.relativeLuminance2=i})(ie||={});var Lt;(e=>{function t(o,h){if(z=(h&255)/255,z===1)return h;let l=h>>24&255,a=h>>16&255,c=h>>8&255,d=o>>24&255,u=o>>16&255,f=o>>8&255;return J=d+Math.round((l-d)*z),Z=u+Math.round((a-u)*z),Q=f+Math.round((c-f)*z),$.toRgba(J,Z,Q)}e.blend=t;function i(o,h,l){let a=ie.relativeLuminance(o>>8),c=ie.relativeLuminance(h>>8);if(Be(a,c)<l){if(c<a){let f=s(o,h,l),_=Be(a,ie.relativeLuminance(f>>8));if(_<l){let p=r(o,h,l),S=Be(a,ie.relativeLuminance(p>>8));return _>S?f:p}return f}let d=r(o,h,l),u=Be(a,ie.relativeLuminance(d>>8));if(u<l){let f=s(o,h,l),_=Be(a,ie.relativeLuminance(f>>8));return u>_?d:f}return d}}e.ensureContrastRatio=i;function s(o,h,l){let a=o>>24&255,c=o>>16&255,d=o>>8&255,u=h>>24&255,f=h>>16&255,_=h>>8&255,p=Be(ie.relativeLuminance2(u,f,_),ie.relativeLuminance2(a,c,d));for(;p<l&&(u>0||f>0||_>0);)u-=Math.max(0,Math.ceil(u*.1)),f-=Math.max(0,Math.ceil(f*.1)),_-=Math.max(0,Math.ceil(_*.1)),p=Be(ie.relativeLuminance2(u,f,_),ie.relativeLuminance2(a,c,d));return(u<<24|f<<16|_<<8|255)>>>0}e.reduceLuminance=s;function r(o,h,l){let a=o>>24&255,c=o>>16&255,d=o>>8&255,u=h>>24&255,f=h>>16&255,_=h>>8&255,p=Be(ie.relativeLuminance2(u,f,_),ie.relativeLuminance2(a,c,d));for(;p<l&&(u<255||f<255||_<255);)u=Math.min(255,u+Math.ceil((255-u)*.1)),f=Math.min(255,f+Math.ceil((255-f)*.1)),_=Math.min(255,_+Math.ceil((255-_)*.1)),p=Be(ie.relativeLuminance2(u,f,_),ie.relativeLuminance2(a,c,d));return(u<<24|f<<16|_<<8|255)>>>0}e.increaseLuminance=r;function n(o){return[o>>24&255,o>>16&255,o>>8&255,o&255]}e.toChannels=n})(Lt||={});function Fe(e){let t=e.toString(16);return t.length<2?"0"+t:t}function Be(e,t){return e<t?(t+.05)/(e+.05):(e+.05)/(t+.05)}var xo=class extends vt{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}},It=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new ge}register(e){let t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t<this._characterJoiners.length;t++)if(this._characterJoiners[t].id===e)return this._characterJoiners.splice(t,1),!0;return!1}getJoinedCharacters(e){if(this._characterJoiners.length===0)return[];let t=this._bufferService.buffer.lines.get(e);if(!t||t.length===0)return[];let i=[],s=t.translateToString(!0),r=t.getTrimmedLength(),n=0,o=0,h=0,l=t.getFg(0),a=t.getBg(0);for(let c=0;c<r;c++)if(t.loadCell(c,this._workCell),this._workCell.getWidth()!==0){if(this._workCell.fg!==l||this._workCell.bg!==a){if(c-n>1){let d=this._getJoinedRanges(s,h,o,t,n);for(let u=0;u<d.length;u++)i.push(d[u])}n=c,h=o,l=this._workCell.fg,a=this._workCell.bg}o+=this._workCell.getChars().length||1}if(r-n>1){let c=this._getJoinedRanges(s,h,o,t,n);for(let d=0;d<c.length;d++)i.push(c[d])}return i}_getJoinedRanges(e,t,i,s,r){let n=e.substring(t,i),o=[];try{o=this._characterJoiners[0].handler(n)}catch(h){console.error(h)}for(let h=1;h<this._characterJoiners.length;h++)try{let l=this._characterJoiners[h].handler(n);for(let a=0;a<l.length;a++)It._mergeRanges(o,l[a])}catch(l){console.error(l)}return this._stringRangesToCellRanges(o,s,r),o}_stringRangesToCellRanges(e,t,i){let s=0,r=!1,n=0,o=e[s];if(!o)return;let h=t.getTrimmedLength();for(let l=i;l<h;l++){let a=t.getWidth(l),c=t.getString(l).length||1;if(a!==0){if(!r&&o[0]<=n&&(o[0]=l,r=!0),o[1]<=n){if(o[1]=l,o=e[++s],!o)break;o[0]<=n?(o[0]=l,r=!0):r=!1}n+=c}}o&&(o[1]=h)}static _mergeRanges(e,t){let i=!1;for(let s=0;s<e.length;s++){let r=e[s];if(i){if(t[1]<=r[0])return e[s-1][1]=t[1],e;if(t[1]<=r[1])return e[s-1][1]=Math.max(t[1],r[1]),e.splice(s,1),e;e.splice(s,1),s--}else{if(t[1]<=r[0])return e.splice(s,0,t),e;if(t[1]<=r[1])return r[0]=Math.min(t[0],r[0]),e;t[0]<r[1]&&(r[0]=Math.min(t[0],r[0]),i=!0);continue}}return i?e[e.length-1][1]=t[1]:e.push(t),e}};It=F([g(0,ne)],It);function Is(e){if(!e)throw new Error("value must not be falsy");return e}function Bo(e){return 57508<=e&&e<=57558}function Eo(e){return 9472<=e&&e<=9631}function Do(e){return Bo(e)||Eo(e)}function Mo(){return{css:{canvas:Bt(),cell:Bt()},device:{canvas:Bt(),cell:Bt(),char:{width:0,height:0,left:0,top:0}}}}function Bt(){return{width:0,height:0}}var Bi=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new ge,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,n,o,h,l,a,c,d,u){let f=[];u&&(u.hasBlinkingCells=!1);let _=this._characterJoinerService.getJoinedCharacters(t),p=this._themeService.colors,S=e.getNoBgTrimmedLength();i&&S<n+1&&(S=n+1);let k,R=0,E="",B,T=0,A=0,ee=0,Ce=!1,Oe=0,Ue=!1,Ie,St=0,v=[],m=c!==-1&&d!==-1;for(let w=0;w<S;w++){e.loadCell(w,this._workCell);let b=this._workCell.getWidth();if(b===0)continue;let x=!1,M=w>=St,P=w,C=this._workCell;if(_.length>0&&w===_[0][0]&&M){let I=_.shift(),Yt=this._isCellInSelection(I[0],t);for(B=I[0]+1;B<I[1];B++)M&&=Yt===this._isCellInSelection(B,t);M&&=!i||n<I[0]||n>=I[1],M?(x=!0,C=new xo(this._workCell,e.translateToString(!0,I[0],I[1]),I[1]-I[0]),P=I[1]-1,b=C.getWidth()):St=I[1]}let q=this._isCellInSelection(w,t),ve=i&&w===n,Me=m&&w>=c&&w<=d;u&&C.isBlink()&&(u.hasBlinkingCells=!0),!h&&C.isBlink()&&v.push("xterm-blink-hidden");let ke=!1;this._decorationService.forEachDecorationAtCell(w,t,void 0,I=>{ke=!0});let Ne=C.getChars()||" ";if(Ne===" "&&(C.isUnderline()||C.isOverline())&&(Ne="\xA0"),Ie=b*l-a.get(Ne,C.isBold(),C.isItalic()),!k)k=this._document.createElement("span");else if(R&&(q&&Ue||!q&&!Ue&&C.bg===T)&&(q&&Ue&&p.selectionForeground||C.fg===A)&&C.extended.ext===ee&&Me===Ce&&Ie===Oe&&!ve&&!x&&!ke&&M){C.isInvisible()?E+=" ":E+=Ne,R++;continue}else R&&(k.textContent=E),k=this._document.createElement("span"),R=0,E="";if(T=C.bg,A=C.fg,ee=C.extended.ext,Ce=Me,Oe=Ie,Ue=q,x&&n>=w&&n<=P&&(n=w),!this._coreService.isCursorHidden&&ve&&this._coreService.isCursorInitialized){if(v.push("xterm-cursor"),this._coreBrowserService.isFocused)o&&v.push("xterm-cursor-blink"),v.push(s==="bar"?"xterm-cursor-bar":s==="underline"?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":v.push("xterm-cursor-outline");break;case"block":v.push("xterm-cursor-block");break;case"bar":v.push("xterm-cursor-bar");break;case"underline":v.push("xterm-cursor-underline");break;default:break}}if(C.isBold()&&v.push("xterm-bold"),C.isItalic()&&v.push("xterm-italic"),C.isDim()&&v.push("xterm-dim"),C.isInvisible()?E=" ":E=C.getChars()||" ",C.isUnderline()&&(v.push(`xterm-underline-${C.extended.underlineStyle}`),E===" "&&(E="\xA0"),!C.isUnderlineColorDefault()))if(C.isUnderlineColorRGB())k.style.textDecorationColor=`rgb(${vt.toColorRGB(C.getUnderlineColor()).join(",")})`;else{let I=C.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&C.isBold()&&I<8&&(I+=8),k.style.textDecorationColor=p.ansi[I].css}C.isOverline()&&(v.push("xterm-overline"),E===" "&&(E="\xA0")),C.isStrikethrough()&&v.push("xterm-strikethrough"),Me&&(k.style.textDecoration="underline");let te=C.getFgColor(),He=C.getFgColorMode(),_e=C.getBgColor(),tt=C.getBgColorMode(),Vt=!!C.isInverse();if(Vt){let I=te;te=_e,_e=I;let Yt=He;He=tt,tt=Yt}let Le,wt,it=!1;this._decorationService.forEachDecorationAtCell(w,t,void 0,I=>{I.options.layer!=="top"&&it||(I.backgroundColorRGB&&(tt=50331648,_e=I.backgroundColorRGB.rgba>>8&16777215,Le=I.backgroundColorRGB),I.foregroundColorRGB&&(He=50331648,te=I.foregroundColorRGB.rgba>>8&16777215,wt=I.foregroundColorRGB),it=I.options.layer==="top")}),!it&&q&&(Le=this._coreBrowserService.isFocused?p.selectionBackgroundOpaque:p.selectionInactiveBackgroundOpaque,_e=Le.rgba>>8&16777215,tt=50331648,it=!0,p.selectionForeground&&(He=50331648,te=p.selectionForeground.rgba>>8&16777215,wt=p.selectionForeground)),it&&v.push("xterm-decoration-top");let Re;switch(tt){case 16777216:case 33554432:Re=p.ansi[_e],v.push(`xterm-bg-${_e}`);break;case 50331648:Re=$.toColor(_e>>16,_e>>8&255,_e&255),this._addStyle(k,`background-color:#${(_e>>>0).toString(16).padStart(6,"0")}`);break;default:Vt?(Re=p.foreground,v.push("xterm-bg-257")):Re=p.background}switch(Le||C.isDim()&&(Le=H.multiplyOpacity(Re,.5)),He){case 16777216:case 33554432:C.isBold()&&te<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(te+=8),this._applyMinimumContrast(k,Re,p.ansi[te],C,Le,void 0)||v.push(`xterm-fg-${te}`);break;case 50331648:let I=$.toColor(te>>16&255,te>>8&255,te&255);this._applyMinimumContrast(k,Re,I,C,Le,wt)||this._addStyle(k,`color:#${te.toString(16).padStart(6,"0")}`);break;default:this._applyMinimumContrast(k,Re,p.foreground,C,Le,wt)||Vt&&v.push("xterm-fg-257")}v.length&&(k.className=v.join(" "),v.length=0),!ve&&!x&&!ke&&M?R++:k.textContent=E,Ie!==this.defaultSpacing&&(k.style.letterSpacing=`${Ie}px`),f.push(k),w=P}return k&&R&&(k.textContent=E),f}_applyMinimumContrast(e,t,i,s,r,n){if(this._optionsService.rawOptions.minimumContrastRatio===1||Do(s.getCode()))return!1;let o=this._getContrastCache(s),h;if(!r&&!n&&(h=o.getColor(t.rgba,i.rgba)),h===void 0){let l=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);h=H.ensureContrastRatio(r??t,n??i,l),o.setColor((r??t).rgba,(n??i).rgba,h??null)}return h?(this._addStyle(e,`color:${h.css}`),!0):!1}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){let i=this._selectionStart,s=this._selectionEnd;return!i||!s?!1:this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e<s[0]&&t<=s[1]:e<i[0]&&t>=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t<s[1]||i[1]===s[1]&&t===i[1]&&e>=i[0]&&e<s[0]||i[1]<s[1]&&t===s[1]&&e<s[0]||i[1]<s[1]&&t===i[1]&&e>=i[0]}};Bi=F([g(1,vr),g(2,oe),g(3,be),g(4,Ee),g(5,mt),g(6,Ze)],Bi);var Lo=class{constructor(e=()=>new Ro){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._canvasElements=[],this._canvasElements=[e(),e(),e(),e()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._canvasElements[0].setFont(e,t,i,!1),this._canvasElements[1].setFont(e,t,s,!1),this._canvasElements[2].setFont(e,t,i,!0),this._canvasElements[3].setFont(e,t,s,!0),this.clear())}get(e,t,i){let s;if(!t&&!i&&e.length===1&&(s=e.charCodeAt(0))<256){if(this._flat[s]!==-9999)return this._flat[s];let o=this._measure(e,0);return o>0&&(this._flat[s]=o),o}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(n===void 0){let o=0;t&&(o|=1),i&&(o|=2),n=this._measure(e,o),n>0&&this._holey.set(r,n)}return n}_measure(e,t){return this._canvasElements[t].measure(e)}},Ro=class{constructor(){typeof OffscreenCanvas<"u"?(this._canvas=new OffscreenCanvas(1,1),this._ctx=Is(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=Is(this._canvas.getContext("2d")))}setFont(e,t,i,s){let r=s?"italic":"";this._ctx.font=`${r} ${i} ${t}px ${e}`.trim()}measure(e){return this._ctx.measureText(e).width}},To=class{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1]){this.clear();return}let r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,h=Math.max(n,0),l=Math.min(o,e.rows-1);if(h>=e.rows||l<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=h,this.viewportCappedEndRow=l,this.startCol=t[0],this.endCol=i[0]}isCellSelected(e,t,i){return this.hasSelection?(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t<this.endCol&&i<=this.viewportCappedEndRow:t<this.startCol&&i>=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i<this.viewportEndRow||this.viewportStartRow===this.viewportEndRow&&i===this.viewportStartRow&&t>=this.startCol&&t<this.endCol||this.viewportStartRow<this.viewportEndRow&&i===this.viewportEndRow&&t<this.endCol||this.viewportStartRow<this.viewportEndRow&&i===this.viewportStartRow&&t>=this.startCol):!1}};function Po(){return new To}var Ao=class extends L{constructor(e,t,i){super(),this._renderCallback=e,this._coreBrowserService=t,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",s=>{this.setIntervalDuration(s)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(O(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let e=this._blinkOn;this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),e||this._renderCallback();return}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}},Oo=1,Ei=class extends L{constructor(e,t,i,s,r,n,o,h,l,a,c,d,u,f){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=n,this._linkifier2=o,this._charSizeService=l,this._optionsService=a,this._bufferService=c,this._coreService=d,this._coreBrowserService=u,this._themeService=f,this._terminalClass=Oo++,this._rowElements=[],this._selectionRenderModel=Po(),this._lastSelectionColumnMode=!1,this._rowHasBlinkingCells=[],this._rowHasBlinkingCellsCount=0,this._onRequestRedraw=this._register(new y),this.onRequestRedraw=this._onRequestRedraw.event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=Mo(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(_=>this._injectCss(_))),this._injectCss(this._themeService.colors),this._rowFactory=h.createInstance(Bi,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(_=>this._handleLinkHover(_))),this._register(this._linkifier2.onHideLinkUnderline(_=>this._handleLinkLeave(_))),this._cursorBlinkStateManager=new Io(this._rowContainer,this._coreBrowserService),this._register(D(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register(O(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new Ao(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register(O(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new Lo,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){let e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(let i of this._rowElements)i.style.width=`${this.dimensions.css.canvas.width}px`,i.style.height=`${this.dimensions.css.cell.height}px`,i.style.lineHeight=`${this.dimensions.css.cell.height}px`,i.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));let t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${H.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;let i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(let[n,o]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${n} { color: ${o.css}; }${this._terminalSelector} .xterm-fg-${n}.xterm-dim { color: ${H.multiplyOpacity(o,.5).css}; }${this._terminalSelector} .xterm-bg-${n} { background-color: ${o.css}; }`;t+=`${this._terminalSelector} .xterm-fg-257 { color: ${H.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-257.xterm-dim { color: ${H.multiplyOpacity(H.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-257 { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){let e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let i=this._rowElements.length;i<=t;i++){let s=this._document.createElement("div");this._rowContainer.appendChild(s),this._rowElements.push(s),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,i){let s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i);let r=0,n=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(r=this._selectionRenderModel.viewportCappedStartRow,n=this._selectionRenderModel.viewportCappedEndRow));let o=0,h=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),this._selectionRenderModel.hasSelection){let c=this._selectionRenderModel.viewportStartRow,d=this._selectionRenderModel.viewportEndRow,u=this._selectionRenderModel.viewportCappedStartRow,f=this._selectionRenderModel.viewportCappedEndRow;o=u,h=f;let _=this._document.createDocumentFragment();if(i){let p=e[0]>t[0];_.appendChild(this._createSelectionElement(u,p?t[0]:e[0],p?e[0]:t[0],f-u+1))}else{let p=c===u?e[0]:0,S=u===d?t[0]:this._bufferService.cols;_.appendChild(this._createSelectionElement(u,p,S));let k=f-u-1;if(_.appendChild(this._createSelectionElement(u+1,0,this._bufferService.cols,k)),u!==f){let R=d===f?t[0]:this._bufferService.cols;_.appendChild(this._createSelectionElement(f,0,R))}}this._selectionContainer.appendChild(_)}let l=Math.min(r,o),a=Math.max(n,h);if(a>=0){l=Math.max(l,0),a=Math.min(a,s-1);let c=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&c>=0&&c<s&&(l=Math.min(l,c),a=Math.max(a,c)),this.renderRows(l,a)}this._lastSelectionStart=e,this._lastSelectionEnd=t,this._lastSelectionColumnMode=i}_createSelectionElement(e,t,i,s=1){let r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width,o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=`${s*this.dimensions.css.cell.height}px`,r.style.top=`${e*this.dimensions.css.cell.height}px`,r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(let e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){let i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,o=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,h=this._optionsService.rawOptions.cursorInactiveStyle,l={hasBlinkingCells:!1};for(let a=e;a<=t;a++){let c=a+i.ydisp,d=this._rowElements[a];if(!d)continue;let u=i.lines.get(c);if(!u){d.replaceChildren(),this._setRowBlinkState(a,!1);continue}d.replaceChildren(...this._rowFactory.createRow(u,c,c===s,o,h,r,n,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,l)),this._setRowBlinkState(a,l.hasBlinkingCells)}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);let o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);let h=this._bufferService.buffer,l=h.ybase+h.y,a=Math.min(h.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,u=this._optionsService.rawOptions.cursorInactiveStyle,f={hasBlinkingCells:!1};for(let _=i;_<=s;++_){let p=_+h.ydisp,S=this._rowElements[_];if(!S)continue;let k=h.lines.get(p);if(!k){S.replaceChildren(),this._setRowBlinkState(_,!1);continue}S.replaceChildren(...this._rowFactory.createRow(k,p,p===l,d,u,a,c,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,n?_===i?e:0:-1,n?(_===s?t:r)-1:-1,f)),this._setRowBlinkState(_,f.hasBlinkingCells)}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};Ei=F([g(7,qi),g(8,Wt),g(9,oe),g(10,ne),g(11,Ee),g(12,be),g(13,Ze)],Ei);var Io=class{constructor(e,t){this._rowContainer=e,this._coreBrowserService=t,this._isIdlePaused=!1,this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){this._idleTimeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}},Di=class extends L{constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new y),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new Ho(this._optionsService))}catch{this._measureStrategy=this._register(new No(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}get hasValidSize(){return this.width>0&&this.height>0}measure(){let e=this._measureStrategy.measure();(e.width!==this.width||e.height!==this.height)&&(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};Di=F([g(2,oe)],Di);var Mr=class extends L{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){e!==void 0&&e>0&&t!==void 0&&t>0&&(this._result.width=e,this._result.height=t)}},No=class extends Mr{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}},Ho=class extends Mr{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");let t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;let e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}},Fo=class extends L{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._onDprChange=this._register(new y),this.onDprChange=this._onDprChange.event,this._onWindowChange=this._register(new y),this.onWindowChange=this._onWindowChange.event,this._screenDprMonitor=this._register(new Wo(this._window)),this._register(this.onWindowChange(s=>this._screenDprMonitor.setWindow(s))),this._register(he.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register(D(this._textarea,"focus",()=>this._isFocused=!0)),this._register(D(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return this._cachedIsFocused===void 0&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}},Wo=class extends L{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new le),this._onDprChange=this._register(new y),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register(O(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=D(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){!this._resolutionMediaMatchList||!this._outerListener||(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}},zo=class extends L{constructor(){super(),this.linkProviders=[],this._register(O(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{let t=this.linkProviders.indexOf(e);t!==-1&&this.linkProviders.splice(t,1)}}}};function Qi(e,t,i){let s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left"),10),o=parseInt(r.getPropertyValue("padding-top"),10);return[t.clientX-s.left-n,t.clientY-s.top-o]}function Ko(e,t,i,s,r,n,o,h,l){if(!n)return;let a=Qi(e,t,i);return a[0]=Math.ceil((a[0]+(l?o/2:0))/o),a[1]=Math.ceil(a[1]/h),a[0]=Math.min(Math.max(a[0],1),s+(l?1:0)),a[1]=Math.min(Math.max(a[1],1),r),a}var Mi=class{constructor(e,t){this._charSizeService=e,this._renderService=t}getCoords(e,t,i,s,r){return Ko(pe(t),e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){let i=Qi(pe(t),e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};Mi=F([g(0,Wt),g(1,ye)],Mi);var Ns=typeof window=="object"?window:globalThis;function ce(e,t=0){return e[e.length-(1+t)]}function $o(e,t,i){let s=null,r=null;if(typeof i.value=="function"?(s="value",r=i.value,r.length!==0&&console.warn("Memoize should only be used in functions with zero parameters")):typeof i.get=="function"&&(s="get",r=i.get),!r||!s)throw new Error("not supported");let n=`$memoize$${t}`,o=i;o[s]=function(...h){return this.hasOwnProperty(n)||Object.defineProperty(this,n,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,h)}),this[n]}}var Li=class Ri{constructor(t){this.element=t,this.next=Ri.Undefined,this.prev=Ri.Undefined}};Li.Undefined=new Li(void 0);var ae=Li,Hs=class{constructor(){this._first=ae.Undefined,this._last=ae.Undefined}push(e){return this._insert(e,!0)}_insert(e,t){let i=new ae(e);if(this._first===ae.Undefined)this._first=i,this._last=i;else if(t){let r=this._last;this._last=i,i.prev=r,r.next=i}else{let r=this._first;this._first=i,i.next=r,r.prev=i}let s=!1;return()=>{s||(s=!0,this._remove(i))}}_remove(e){if(e.prev!==ae.Undefined&&e.next!==ae.Undefined){let t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===ae.Undefined&&e.next===ae.Undefined?(this._first=ae.Undefined,this._last=ae.Undefined):e.next===ae.Undefined?(this._last=this._last.prev,this._last.next=ae.Undefined):e.prev===ae.Undefined&&(this._first=this._first.next,this._first.prev=ae.Undefined)}*[Symbol.iterator](){let e=this._first;for(;e!==ae.Undefined;)yield e.element,e=e.next}},ue;(e=>(e.TAP="-xterm-gesturetap",e.CHANGE="-xterm-gesturechange",e.START="-xterm-gesturestart",e.END="-xterm-gesturesend",e.CONTEXT_MENU="-xterm-gesturecontextmenu"))(ue||={});var ht=class se extends L{constructor(){super(),this._dispatched=!1,this._targets=new Hs,this._ignoreTargets=new Hs,this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;let t=Ns;this._register(D(t.document,"touchstart",i=>this._handleTouchStart(i),{passive:!1})),this._register(D(t.document,"touchend",i=>this._handleTouchEnd(t,i))),this._register(D(t.document,"touchmove",i=>this._handleTouchMove(i),{passive:!1}))}static addTarget(t){if(!se.isTouchDevice())return L.None;se._instance||(se._instance=new se);let i=se._instance._targets.push(t);return O(i)}static ignoreTarget(t){if(!se.isTouchDevice())return L.None;se._instance||(se._instance=new se);let i=se._instance._ignoreTargets.push(t);return O(i)}static isTouchDevice(){return"ontouchstart"in Ns||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(t){let i=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let s=0,r=t.targetTouches.length;s<r;s++){let n=t.targetTouches.item(s);this._activeTouches[n.identifier]={id:n.identifier,initialTarget:n.target,initialTimeStamp:i,initialPageX:n.pageX,initialPageY:n.pageY,rollingTimestamps:[i],rollingPageX:[n.pageX],rollingPageY:[n.pageY]};let o=this._newGestureEvent(ue.START,n.target);o.pageX=n.pageX,o.pageY=n.pageY,this._dispatchEvent(o)}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_handleTouchEnd(t,i){let s=Date.now(),r=Object.keys(this._activeTouches).length;for(let n=0,o=i.changedTouches.length;n<o;n++){let h=i.changedTouches.item(n);if(!this._activeTouches.hasOwnProperty(String(h.identifier))){console.warn("move of an UNKNOWN touch",h);continue}let l=this._activeTouches[h.identifier],a=Date.now()-l.initialTimeStamp;if(a<se._holdDelay&&Math.abs(l.initialPageX-ce(l.rollingPageX))<30&&Math.abs(l.initialPageY-ce(l.rollingPageY))<30){let c=this._newGestureEvent(ue.TAP,l.initialTarget);c.pageX=ce(l.rollingPageX),c.pageY=ce(l.rollingPageY),this._dispatchEvent(c)}else if(a>=se._holdDelay&&Math.abs(l.initialPageX-ce(l.rollingPageX))<30&&Math.abs(l.initialPageY-ce(l.rollingPageY))<30){let c=this._newGestureEvent(ue.CONTEXT_MENU,l.initialTarget);c.pageX=ce(l.rollingPageX),c.pageY=ce(l.rollingPageY),this._dispatchEvent(c)}else if(r===1){let c=ce(l.rollingPageX),d=ce(l.rollingPageY),u=ce(l.rollingTimestamps)-l.rollingTimestamps[0],f=c-l.rollingPageX[0],_=d-l.rollingPageY[0],p=[...this._targets].filter(S=>l.initialTarget instanceof Node&&S.contains(l.initialTarget));this._inertia(t,p,s,Math.abs(f)/u,f>0?1:-1,c,Math.abs(_)/u,_>0?1:-1,d)}this._dispatchEvent(this._newGestureEvent(ue.END,l.initialTarget)),delete this._activeTouches[h.identifier]}this._dispatched&&(i.preventDefault(),i.stopPropagation(),this._dispatched=!1)}_newGestureEvent(t,i){let s=document.createEvent("CustomEvent");return s.initEvent(t,!1,!0),s.initialTarget=i,s.tapCount=0,s}_dispatchEvent(t){if(t.type===ue.TAP){let i=new Date().getTime(),s;i-this._lastSetTapCountTime>se._clearTapCountTime?s=1:s=2,this._lastSetTapCountTime=i,t.tapCount=s}else(t.type===ue.CHANGE||t.type===ue.CONTEXT_MENU)&&(this._lastSetTapCountTime=0);if(t.initialTarget instanceof Node){for(let s of this._ignoreTargets)if(s.contains(t.initialTarget))return;let i=[];for(let s of this._targets)if(s.contains(t.initialTarget)){let r=0,n=t.initialTarget;for(;n&&n!==s;)r++,n=n.parentElement;i.push([r,s])}i.sort((s,r)=>s[0]-r[0]);for(let[,s]of i)s.dispatchEvent(t),this._dispatched=!0}}_inertia(t,i,s,r,n,o,h,l,a){this._handle=Vi(t,()=>{let c=Date.now(),d=c-s,u=0,f=0,_=!0;r+=se._scrollFriction*d,h+=se._scrollFriction*d,r>0&&(_=!1,u=n*r*d),h>0&&(_=!1,f=l*h*d);let p=this._newGestureEvent(ue.CHANGE);p.translationX=u,p.translationY=f,i.forEach(S=>S.dispatchEvent(p)),_||this._inertia(t,i,c,r,n,o+u,h,l,a+f)})}_handleTouchMove(t){let i=Date.now();for(let s=0,r=t.changedTouches.length;s<r;s++){let n=t.changedTouches.item(s);if(!this._activeTouches.hasOwnProperty(String(n.identifier))){console.warn("end of an UNKNOWN touch",n);continue}let o=this._activeTouches[n.identifier],h=this._newGestureEvent(ue.CHANGE,o.initialTarget);h.translationX=n.pageX-ce(o.rollingPageX),h.translationY=n.pageY-ce(o.rollingPageY),h.pageX=n.pageX,h.pageY=n.pageY,h.clientX=n.clientX,h.clientY=n.clientY,this._dispatchEvent(h),o.rollingPageX.length>3&&(o.rollingPageX.shift(),o.rollingPageY.shift(),o.rollingTimestamps.shift()),o.rollingPageX.push(n.pageX),o.rollingPageY.push(n.pageY),o.rollingTimestamps.push(i)}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}};ht._scrollFriction=-.005,ht._holdDelay=700,ht._clearTapCountTime=400,F([$o],ht,"isTouchDevice",1);var Uo=ht,Ti=class{constructor(e,t,i,s,r,n,o,h,l){this._renderService=e,this._mouseCoordsService=t,this._mouseStateService=i,this._coreService=s,this._bufferService=r,this._optionsService=n,this._selectionService=o,this._logService=h,this._coreBrowserService=l,this._lastEvent=null,this._wheelPartialScroll=0,this._touchScrollAccumulator=0}bindMouse(e,t,i){let{element:s,document:r}=e,n={mouseup:null,wheel:null,mousedrag:null,mousemove:null},o=new le,h=new le;t(o),t(h);let l={target:e,focus:i,requestedEvents:n,mouseupListener:o,mousedragListener:h},a={mouseup:c=>this._handleMouseUp(l,c),wheel:c=>this._handleWheel(l,c),mousedrag:c=>this._handleMouseDrag(l,c),mousemove:c=>this._handleMouseMove(l,c)};this._altMouseCursor=new qo(s,r,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),t(this._altMouseCursor),t(this._mouseStateService.onProtocolChange(c=>{this._handleProtocolChange(l,a,c)})),t(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(s),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,t(D(s,"mousedown",c=>this._handleMouseDown(l,c))),t(D(s,"wheel",c=>this._handlePassiveWheel(l,c),{passive:!1})),t(Uo.addTarget(e.screenElement)),t(D(e.screenElement,ue.START,()=>this._handleTouchStart())),t(D(e.screenElement,ue.CHANGE,c=>this._handleTouchChange(l,c)))}_sendEvent(e,t){let i=this._mouseCoordsService.getMouseReportCoords(t,e.target.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,t.buttons===void 0?(s=3,t.button!==void 0&&(s=t.button<3?t.button:3)):s=t.buttons&1?0:t.buttons&4?1:t.buttons&2?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;let o=t.deltaY;if(o===0||this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return!1;r=o<0?0:1,s=4;break;default:return!1}if(r===void 0||s===void 0||s>4||s!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!t.altKey)return!1;let n=s!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:n?!1:t.altKey,shift:t.shiftKey})}_handleMouseUp(e,t){this._sendEvent(e,t),t.buttons||(e.mouseupListener.clear(),e.mousedragListener.clear())}_handleWheel(e,t){return this._sendEvent(e,t),t.preventDefault(),t.stopPropagation(),!1}_handleMouseDrag(e,t){t.buttons&&this._sendEvent(e,t)}_handleMouseMove(e,t){t.buttons||this._sendEvent(e,t)}_handleMouseDown(e,t){if(t.preventDefault(),e.focus(),!this._mouseStateService.areMouseEventsActive||this._selectionService.shouldForceSelection(t))return;this._sendEvent(e,t);let{element:i,document:s}=e.target,r=i.ownerDocument??s;e.requestedEvents.mouseup&&(e.mouseupListener.value=D(r,"mouseup",e.requestedEvents.mouseup)),e.requestedEvents.mousedrag&&(e.mousedragListener.value=D(r,"mousemove",e.requestedEvents.mousedrag))}_handlePassiveWheel(e,t){if(!e.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;if(!this._bufferService.buffer.hasScrollback){if(t.deltaY===0)return!1;if(this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return t.preventDefault(),t.stopPropagation(),!1;let i="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(i,!0),t.preventDefault(),t.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(e,t){if(t.preventDefault(),t.stopPropagation(),e.requestedEvents.wheel){this._handleTouchScrollAsWheel(e,t);return}if(!this._bufferService.buffer.hasScrollback){this._handleTouchScrollAsKeys(t);return}e.target.handleTouchScroll?.(t.translationY)}_handleTouchScrollAsKeys(e){let t=this._renderService?.dimensions.css.cell.height;if(!t)return;this._touchScrollAccumulator-=e.translationY;let i=Math.trunc(this._touchScrollAccumulator/t);if(i===0)return;this._touchScrollAccumulator-=i*t;let s="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(i<0?"A":"B");for(let r=0;r<Math.abs(i);r++)this._coreService.triggerDataEvent(s,!0)}_handleTouchScrollAsWheel(e,t){let i=this._renderService?.dimensions.css.cell.height;if(!i)return;this._touchScrollAccumulator-=t.translationY;let s=Math.trunc(this._touchScrollAccumulator/i);if(s===0)return;this._touchScrollAccumulator-=s*i;let r=this._mouseCoordsService.getMouseReportCoords(t,e.target.screenElement);if(r)for(let n=0;n<Math.abs(s);n++)this._triggerMouseEvent({col:r.col,row:r.row,x:r.x,y:r.y,button:4,action:s<0?0:1,ctrl:!1,alt:!1,shift:!1})}reset(){this._lastEvent=null,this._wheelPartialScroll=0,this._touchScrollAccumulator=0}_syncMouseModeState(e){this._mouseStateService.areMouseEventsActive?this._optionsService.rawOptions.mouseEventsRequireAlt?(this._altMouseCursor?.resetClass(),this._selectionService.enable()):(e.classList.add("enable-mouse-events"),this._selectionService.disable()):(e.classList.remove("enable-mouse-events"),this._selectionService.enable())}_handleProtocolChange(e,t,i){let{element:s}=e.target,{requestedEvents:r}=e;i?this._optionsService.rawOptions.logLevel==="debug"&&this._logService.debug("Binding to mouse events:",this._explainEvents(i)):this._logService.debug("Unbinding from mouse events."),this._syncMouseModeState(s),this._altMouseCursor?.sync(),i&8?r.mousemove||(s.addEventListener("mousemove",t.mousemove),r.mousemove=t.mousemove):(r.mousemove&&s.removeEventListener("mousemove",r.mousemove),r.mousemove=null),i&16?r.wheel||(s.addEventListener("wheel",t.wheel,{passive:!1}),r.wheel=t.wheel):(r.wheel&&s.removeEventListener("wheel",r.wheel),r.wheel=null),i&2?r.mouseup??=t.mouseup:(e.mouseupListener.clear(),r.mouseup=null),i&4?r.mousedrag??=t.mousedrag:(e.mousedragListener.clear(),r.mousedrag=null)}_applyScrollModifier(e,t){return t.altKey||t.ctrlKey||t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}_consumeWheelEvent(e,t,i){if(e.deltaY===0||e.shiftKey||t===void 0||i===void 0)return 0;let s=t/i,r=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(r/=s+0,Math.abs(e.deltaY)<50&&(r*=.3),this._wheelPartialScroll+=r,r=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows||e.button===4&&e.action===32||e.button===3&&e.action!==32||e.button!==4&&(e.action===2||e.action===3)||(e.col++,e.row++,e.action===32&&this._lastEvent&&this._equalEvents(this._lastEvent,e,this._mouseStateService.isPixelEncoding))||!this._mouseStateService.restrictMouseEvent(e))return!1;let t=this._mouseStateService.encodeMouseEvent(e);return t&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}_explainEvents(e){return{down:!!(e&1),up:!!(e&2),drag:!!(e&4),move:!!(e&8),wheel:!!(e&16)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x||e.y!==t.y)return!1}else if(e.col!==t.col||e.row!==t.row)return!1;return!(e.button!==t.button||e.action!==t.action||e.ctrl!==t.ctrl||e.alt!==t.alt||e.shift!==t.shift)}};Ti=F([g(0,ye),g(1,zt),g(2,Ft),g(3,Ee),g(4,ne),g(5,oe),g(6,gr),g(7,Je),g(8,be)],Ti);var qo=class{constructor(e,t,i){this._element=e,this._document=t,this._isActive=i,this._listeners=new le}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;let e=new Qe,t=s=>this.syncFromModifier(s);e.add(D(this._document,"keydown",t)),e.add(D(this._document,"keyup",t)),e.add(D(this._element,"mousemove",t));let i=this._element.ownerDocument?.defaultView;i&&e.add(D(i,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=e}resetClass(){this._updateClass(!1)}syncFromModifier(e){this._isActive()&&this._updateClass(e.getModifierState("Alt"))}_updateClass(e){e?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}},Vo=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame??=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,e):e,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,t):t,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0){this._runRefreshCallbacks();return}let e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(let e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}},Lr=class{constructor(e){this._tasks=[],this._i=0,this._logService=e}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._i<this._tasks.length;)this._tasks[this._i]()||this._i++;this.clear()}clear(){this._idleCallback&&(this._cancelCallback(this._idleCallback),this._idleCallback=void 0),this._i=0,this._tasks.length=0}_start(){this._idleCallback||(this._idleCallback=this._requestCallback(this._process.bind(this)))}_process(e){this._idleCallback=void 0;let t,i=0,s=e.timeRemaining(),r;for(;this._i<this._tasks.length;){if(t=performance.now(),this._tasks[this._i]()||this._i++,t=Math.max(1,performance.now()-t),i=Math.max(t,i),r=e.timeRemaining(),i*1.5>r){s-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),this._start();return}s=r}this.clear()}},Yo=class extends Lr{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){let t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}},Xo=class extends Lr{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}},Nt="requestIdleCallback"in globalThis?Xo:Yo,jo=class{constructor(e){this._queue=new Nt(e)}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}dispose(){this._queue.clear()}},Pi=class extends L{constructor(e,t,i,s,r,n,o,h,l,a){super(),this._rowCount=e,this._optionsService=i,this._logService=s,this._charSizeService=r,this._coreService=n,this._coreBrowserService=l,this._renderer=this._register(new le),this._observerDisposable=this._register(new le),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new y),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new y),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new y),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new y),this.onRefreshRequest=this._onRefreshRequest.event,this._pausedResizeTask=this._register(new jo(this._logService)),this._renderDebouncer=new Vo((c,d)=>this._renderRows(c,d),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new Go(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register(O(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(h.onResize(()=>this._fullRefresh())),this._register(h.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(o.onDecorationRegistered(()=>this._fullRefresh())),this._register(o.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(h.buffer.y,h.buffer.y,void 0,!0))),this._register(a.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(c=>this._registerIntersectionObserver(c,t)))}get dimensions(){return this._renderer.value.dimensions}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){let i=new e.IntersectionObserver(s=>this._handleIntersectionChange(s[s.length-1]),{threshold:0});this._observerDisposable.value=O(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=i,i.observe(t)}}_handleIntersectionChange(e){this._isPaused=e.isIntersecting===void 0?e.intersectionRatio===0:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),!this._isPaused&&!this._charSizeService.hasValidSize&&this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){if(this._renderer.value){if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0}}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(t=>this.refreshRows(t.start,t.end,t.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};Pi=F([g(2,oe),g(3,Je),g(4,Wt),g(5,Ee),g(6,mt),g(7,ne),g(8,be),g(9,Ze)],Pi);var Go=class{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;let e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}};function Jo(e,t,i,s){let r=i.buffer.x,n=i.buffer.y;if(!i.buffer.hasScrollback)return ea(r,n,e,t,i,s)+Ut(n,t,i,s)+ta(r,n,e,t,i,s);let o;if(n===t)return o=r>e?"D":"C",pt(Math.abs(r-e),ft(o,s));o=n>t?"D":"C";let h=Math.abs(n-t),l=Qo(n>t?e:r,i)+(h-1)*i.cols+1+Zo(n>t?r:e,i);return pt(l,ft(o,s))}function Zo(e,t){return e-1}function Qo(e,t){return t.cols-e}function ea(e,t,i,s,r,n){return Ut(t,s,r,n).length===0?"":pt(Tr(e,t,e,t-$e(t,r),!1,r).length,ft("D",n))}function Ut(e,t,i,s){let r=e-$e(e,i),n=t-$e(t,i),o=Math.abs(r-n)-ia(e,t,i);return pt(o,ft(Rr(e,t),s))}function ta(e,t,i,s,r,n){let o;Ut(t,s,r,n).length>0?o=s-$e(s,r):o=t;let h=s,l=sa(e,t,i,s,r,n);return pt(Tr(e,o,i,h,l==="C",r).length,ft(l,n))}function ia(e,t,i){let s=0,r=e-$e(e,i),n=t-$e(t,i);for(let o=0;o<Math.abs(r-n);o++){let h=Rr(e,t)==="A"?-1:1;i.buffer.lines.get(r+h*o)?.isWrapped&&s++}return s}function $e(e,t){let i=0,s=t.buffer.lines.get(e),r=s?.isWrapped;for(;r&&e>=0&&e<t.rows;)i++,s=t.buffer.lines.get(--e),r=s?.isWrapped;return i}function sa(e,t,i,s,r,n){let o;return Ut(t,s,r,n).length>0?o=s-$e(s,r):o=t,e<i&&o<=s||e>=i&&o<s?"C":"D"}function Rr(e,t){return e>t?"A":"B"}function Tr(e,t,i,s,r,n){let o=e,h=t,l="";for(;(o!==i||h!==s)&&h>=0&&h<n.buffer.lines.length;)o+=r?1:-1,r&&o>n.cols-1?(l+=n.buffer.translateBufferLineToString(h,!1,e,o),o=0,e=0,h++):!r&&o<0&&(l+=n.buffer.translateBufferLineToString(h,!1,0,e+1),o=n.cols-1,e=o,h--);return l+n.buffer.translateBufferLineToString(h,!1,e,o)}function ft(e,t){return"\x1B"+(t?"O":"[")+e}function pt(e,t){e=Math.floor(e);let i="";for(let s=0;s<e;s++)i+=t;return i}var ra=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:!this.selectionEnd||!this.selectionStart?this.selectionStart:this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){let e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){let e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){let e=this.selectionStart,t=this.selectionEnd;return!e||!t?!1:e[1]>t[1]||e[1]===t[1]&&e[0]>t[0]}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):this.selectionStart&&this.selectionStart[1]<0?(this.selectionStart=[0,0],!0):!1}};function Fs(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}var na="\xA0",oa=new RegExp(na,"g"),Ai=class extends L{constructor(e,t,i,s,r,n,o,h,l,a){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseCoordsService=n,this._optionsService=o,this._mouseStateService=h,this._renderService=l,this._coreBrowserService=a,this._dragScrollAmount=0,this._enabled=!0,this._trimListener=this._register(new le),this._workCell=new ge,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this._register(new y),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this._register(new y),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this._register(new y),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this._register(new y),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=c=>this._handleMouseMove(c),this._mouseUpListener=c=>this._handleMouseUp(c),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(c=>this._handleTrim(c)),this._register(this._bufferService.buffers.onBufferActivate(c=>this._handleBufferActivate(c))),this.enable(),this._model=new ra(this._bufferService),this._activeSelectionMode=0,this._register(O(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(c=>{c.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!e||!t?!1:e[0]!==t[0]||e[1]!==t[1]}get selectionText(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";let i=this._bufferService.buffer,s=[];if(this._activeSelectionMode===3){if(e[0]===t[0])return"";let r=e[0]<t[0]?e[0]:t[0],n=e[0]<t[0]?t[0]:e[0];for(let o=e[1];o<=t[1];o++){let h=i.translateBufferLineToString(o,!0,r,n);s.push(h)}}else{let r=e[1]===t[1]?t[0]:void 0;s.push(i.translateBufferLineToString(e[1],!0,e[0],r));for(let n=e[1]+1;n<=t[1]-1;n++){let o=i.lines.get(n),h=i.translateBufferLineToString(n,!0);o?.isWrapped?s[s.length-1]+=h:s.push(h)}if(e[1]!==t[1]){let n=i.lines.get(t[1]),o=i.translateBufferLineToString(t[1],!0,0,t[0]);n&&n.isWrapped?s[s.length-1]+=o:s.push(o)}}return s.map(r=>r.replace(oa," ")).join($t?`\r +`:` +`)}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),Ji&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:this._activeSelectionMode===3})}_isClickInSelection(e){let t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!i||!s||!t?!1:this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){let i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!i||!s?!1:this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]<i[1]||t[1]===i[1]&&e[1]===t[1]&&e[0]>=t[0]&&e[0]<i[0]||t[1]<i[1]&&e[1]===i[1]&&e[0]<i[0]||t[1]<i[1]&&e[1]===t[1]&&e[0]>=t[0]}_selectWordAtCursor(e,t){let i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=Fs(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;let s=this._getMouseBufferCoords(e);return s?(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0):!1}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){let t=this._mouseCoordsService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=Qi(this._coreBrowserService.window,e,this._screenElement)[1],i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(t*14))}shouldForceSelection(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!e.altKey:fe?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(e.button===2&&this.hasSelection)&&e.button===0&&!(this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&e.altKey)){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):e.detail===1?this._handleSingleClick(e):e.detail===2?this._handleDoubleClick(e):e.detail===3&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){let t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);let i=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);i&&i.length!==this._model.selectionStart[0]&&i.hasWidth(this._model.selectionStart[0])===0&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){let t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!1:e.altKey&&!(fe&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;let t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd){this.refresh(!0);return}this._activeSelectionMode===2?this._model.selectionEnd[1]<this._model.selectionStart[1]?this._model.selectionEnd[0]=0:this._model.selectionEnd[0]=this._bufferService.cols:this._activeSelectionMode===1&&this._selectToWordAt(this._model.selectionEnd),this._dragScrollAmount=this._getMouseEventScrollAmount(e),this._activeSelectionMode!==3&&(this._dragScrollAmount>0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));let i=this._bufferService.buffer;if(this._model.selectionEnd[1]<i.lines.length){let s=i.lines.get(this._model.selectionEnd[1]);s&&s.hasWidth(this._model.selectionEnd[0])===0&&this._model.selectionEnd[0]<this._bufferService.cols&&this._model.selectionEnd[0]++}(!t||t[0]!==this._model.selectionEnd[0]||t[1]!==this._model.selectionEnd[1])&&this.refresh(!0)}_dragScroll(){if(!(!this._model.selectionEnd||!this._model.selectionStart)&&this._dragScrollAmount){this._onRequestScrollLines.fire({amount:this._dragScrollAmount,suppressScrollEvent:!1});let e=this._bufferService.buffer;this._dragScrollAmount>0?(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows-1,e.lines.length-1)):(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){let t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){let i=this._mouseCoordsService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(i&&i[0]!==void 0&&i[1]!==void 0){let s=Jo(i[0]-1,i[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(s,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!!e&&!!t&&(e[0]!==t[0]||e[1]!==t[1]);if(!i){this._oldHasSelection&&this._fireOnSelectionChange(e,t,i);return}!e||!t||(!this._oldSelectionStart||!this._oldSelectionEnd||e[0]!==this._oldSelectionStart[0]||e[1]!==this._oldSelectionStart[1]||t[0]!==this._oldSelectionEnd[0]||t[1]!==this._oldSelectionEnd[1])&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.value=e.activeBuffer.lines.onTrim(t=>this._handleTrim(t))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){let r=e.loadCell(s,this._workCell).getChars().length;this._workCell.getWidth()===0?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;let r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;let o=r.translateBufferLineToString(e[1],!1),h=this._convertViewportColToCharacterIndex(n,e[0]),l=h,a=e[0]-h,c=0,d=0,u=0,f=0;if(o.charAt(h)===" "){for(;h>0&&o.charAt(h-1)===" ";)h--;for(;l<o.length&&o.charAt(l+1)===" ";)l++}else{let S=e[0],k=e[0];n.getWidth(S)===0&&(c++,S--),n.getWidth(k)===2&&(d++,k++);let R=n.getString(k).length;for(R>1&&(f+=R-1,l+=R-1);S>0&&h>0&&!this._isCharWordSeparator(n.loadCell(S-1,this._workCell));){n.loadCell(S-1,this._workCell);let E=this._workCell.getChars().length;this._workCell.getWidth()===0?(c++,S--):E>1&&(u+=E-1,h-=E-1),h--,S--}for(;k<n.length&&l+1<o.length&&!this._isCharWordSeparator(n.loadCell(k+1,this._workCell));){n.loadCell(k+1,this._workCell);let E=this._workCell.getChars().length;this._workCell.getWidth()===2?(d++,k++):E>1&&(f+=E-1,l+=E-1),l++,k++}}l++;let _=h+a-c+u,p=Math.min(this._bufferService.cols,l-h+c+d-u-f);if(!(!t&&o.slice(h,l).trim()==="")){if(i&&_===0&&n.getCodePoint(0)!==32){let S=r.lines.get(e[1]-1);if(S&&n.isWrapped&&S.getCodePoint(this._bufferService.cols-1)!==32){let k=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(k){let R=this._bufferService.cols-k.start;_-=R,p+=R}}}if(s&&_+p===this._bufferService.cols&&n.getCodePoint(this._bufferService.cols-1)!==32){let S=r.lines.get(e[1]+1);if(S?.isWrapped&&S.getCodePoint(0)!==32){let k=this._getWordAt([0,e[1]+1],!1,!1,!0);k&&(p+=k.length)}}return{start:_,length:p}}}_selectWordAt(e,t){let i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){let t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return e.getWidth()===0?!1:this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){let t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=Fs(i,this._bufferService.cols)}};Ai=F([g(3,ne),g(4,Ee),g(5,zt),g(6,oe),g(7,Ft),g(8,ye),g(9,be)],Ai);var Ws=class{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}},zs=class{constructor(){this._color=new Ws,this._css=new Ws}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}},Y=Object.freeze((()=>{let e=[W.toColor("#2e3436"),W.toColor("#cc0000"),W.toColor("#4e9a06"),W.toColor("#c4a000"),W.toColor("#3465a4"),W.toColor("#75507b"),W.toColor("#06989a"),W.toColor("#d3d7cf"),W.toColor("#555753"),W.toColor("#ef2929"),W.toColor("#8ae234"),W.toColor("#fce94f"),W.toColor("#729fcf"),W.toColor("#ad7fa8"),W.toColor("#34e2e2"),W.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){let s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:$.toCss(s,r,n),rgba:$.toRgba(s,r,n)})}for(let i=0;i<24;i++){let s=8+i*10;e.push({css:$.toCss(s,s,s),rgba:$.toRgba(s,s,s)})}return e})()),We=W.toColor("#ffffff"),lt=W.toColor("#000000"),Ks=W.toColor("#ffffff"),$s=lt,rt={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},aa=We,Oi=class extends L{constructor(e){super(),this._optionsService=e,this._contrastCache=new zs,this._halfContrastCache=new zs,this._onChangeColors=this._register(new y),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:We,background:lt,cursor:Ks,cursorAccent:$s,selectionForeground:void 0,selectionBackgroundTransparent:rt,selectionBackgroundOpaque:H.blend(lt,rt),selectionInactiveBackgroundTransparent:rt,selectionInactiveBackgroundOpaque:H.blend(lt,rt),scrollbarSliderBackground:H.opacity(We,.2),scrollbarSliderHoverBackground:H.opacity(We,.4),scrollbarSliderActiveBackground:H.opacity(We,.5),overviewRulerBorder:We,ansi:Y.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}get colors(){return this._colors}_setTheme(e={}){let t=this._colors;if(t.foreground=N(e.foreground,We),t.background=N(e.background,lt),t.cursor=H.blend(t.background,N(e.cursor,Ks)),t.cursorAccent=H.blend(t.background,N(e.cursorAccent,$s)),t.selectionBackgroundTransparent=N(e.selectionBackground,rt),t.selectionBackgroundOpaque=H.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=N(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=H.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?N(e.selectionForeground,Os):void 0,t.selectionForeground===Os&&(t.selectionForeground=void 0),H.isOpaque(t.selectionBackgroundTransparent)&&(t.selectionBackgroundTransparent=H.opacity(t.selectionBackgroundTransparent,.3)),H.isOpaque(t.selectionInactiveBackgroundTransparent)&&(t.selectionInactiveBackgroundTransparent=H.opacity(t.selectionInactiveBackgroundTransparent,.3)),t.scrollbarSliderBackground=N(e.scrollbarSliderBackground,H.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=N(e.scrollbarSliderHoverBackground,H.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=N(e.scrollbarSliderActiveBackground,H.opacity(t.foreground,.5)),t.overviewRulerBorder=N(e.overviewRulerBorder,aa),t.ansi=Y.slice(),t.ansi[0]=N(e.black,Y[0]),t.ansi[1]=N(e.red,Y[1]),t.ansi[2]=N(e.green,Y[2]),t.ansi[3]=N(e.yellow,Y[3]),t.ansi[4]=N(e.blue,Y[4]),t.ansi[5]=N(e.magenta,Y[5]),t.ansi[6]=N(e.cyan,Y[6]),t.ansi[7]=N(e.white,Y[7]),t.ansi[8]=N(e.brightBlack,Y[8]),t.ansi[9]=N(e.brightRed,Y[9]),t.ansi[10]=N(e.brightGreen,Y[10]),t.ansi[11]=N(e.brightYellow,Y[11]),t.ansi[12]=N(e.brightBlue,Y[12]),t.ansi[13]=N(e.brightMagenta,Y[13]),t.ansi[14]=N(e.brightCyan,Y[14]),t.ansi[15]=N(e.brightWhite,Y[15]),e.extendedAnsi){let i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;s<i;s++)t.ansi[s+16]=N(e.extendedAnsi[s],Y[s+16])}this._contrastCache.clear(),this._halfContrastCache.clear(),this._updateRestoreColors(),this._onChangeColors.fire(this.colors)}restoreColor(e){this._restoreColor(e),this._onChangeColors.fire(this.colors)}_restoreColor(e){if(e===void 0){for(let t=0;t<this._restoreColors.ansi.length;++t)this._colors.ansi[t]=this._restoreColors.ansi[t];return}switch(e){case 256:this._colors.foreground=this._restoreColors.foreground;break;case 257:this._colors.background=this._restoreColors.background;break;case 258:this._colors.cursor=this._restoreColors.cursor;break;default:this._colors.ansi[e]=this._restoreColors.ansi[e]}}modifyColors(e){e(this._colors),this._onChangeColors.fire(this.colors)}_updateRestoreColors(){this._restoreColors={foreground:this._colors.foreground,background:this._colors.background,cursor:this._colors.cursor,ansi:this._colors.ansi.slice()}}};Oi=F([g(0,oe)],Oi);function N(e,t){if(e!==void 0)try{return W.toColor(e)}catch{}return t}var ha={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};function la(e,t,i,s){let r={type:0,cancel:!1,key:void 0},n=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:e.key==="UIKeyInputUpArrow"?t?r.key="\x1BOA":r.key="\x1B[A":e.key==="UIKeyInputLeftArrow"?t?r.key="\x1BOD":r.key="\x1B[D":e.key==="UIKeyInputRightArrow"?t?r.key="\x1BOC":r.key="\x1B[C":e.key==="UIKeyInputDownArrow"&&(t?r.key="\x1BOB":r.key="\x1B[B");break;case 8:r.key=e.ctrlKey?"\b":"\x7F",e.altKey&&(r.key="\x1B"+r.key);break;case 9:if(e.shiftKey){r.key="\x1B[Z";break}r.key=" ",r.cancel=!0;break;case 13:e.key==="c"&&e.ctrlKey?r.key="":r.key=e.altKey?"\x1B\r":"\r",r.cancel=!0;break;case 27:r.key="\x1B",e.altKey&&(r.key="\x1B\x1B"),r.cancel=!0;break;case 37:if(e.metaKey)break;n?r.key="\x1B[1;"+(n+1)+"D":t?r.key="\x1BOD":r.key="\x1B[D";break;case 39:if(e.metaKey)break;n?r.key="\x1B[1;"+(n+1)+"C":t?r.key="\x1BOC":r.key="\x1B[C";break;case 38:if(e.metaKey)break;n?r.key="\x1B[1;"+(n+1)+"A":t?r.key="\x1BOA":r.key="\x1B[A";break;case 40:if(e.metaKey)break;n?r.key="\x1B[1;"+(n+1)+"B":t?r.key="\x1BOB":r.key="\x1B[B";break;case 45:!e.shiftKey&&!e.ctrlKey&&(r.key="\x1B[2~");break;case 46:n?r.key="\x1B[3;"+(n+1)+"~":r.key="\x1B[3~";break;case 36:n?r.key="\x1B[1;"+(n+1)+"H":t?r.key="\x1BOH":r.key="\x1B[H";break;case 35:n?r.key="\x1B[1;"+(n+1)+"F":t?r.key="\x1BOF":r.key="\x1B[F";break;case 33:e.shiftKey?r.type=2:e.ctrlKey?r.key="\x1B[5;"+(n+1)+"~":r.key="\x1B[5~";break;case 34:e.shiftKey?r.type=3:e.ctrlKey?r.key="\x1B[6;"+(n+1)+"~":r.key="\x1B[6~";break;case 112:n?r.key="\x1B[1;"+(n+1)+"P":r.key="\x1BOP";break;case 113:n?r.key="\x1B[1;"+(n+1)+"Q":r.key="\x1BOQ";break;case 114:n?r.key="\x1B[1;"+(n+1)+"R":r.key="\x1BOR";break;case 115:n?r.key="\x1B[1;"+(n+1)+"S":r.key="\x1BOS";break;case 116:n?r.key="\x1B[15;"+(n+1)+"~":r.key="\x1B[15~";break;case 117:n?r.key="\x1B[17;"+(n+1)+"~":r.key="\x1B[17~";break;case 118:n?r.key="\x1B[18;"+(n+1)+"~":r.key="\x1B[18~";break;case 119:n?r.key="\x1B[19;"+(n+1)+"~":r.key="\x1B[19~";break;case 120:n?r.key="\x1B[20;"+(n+1)+"~":r.key="\x1B[20~";break;case 121:n?r.key="\x1B[21;"+(n+1)+"~":r.key="\x1B[21~";break;case 122:n?r.key="\x1B[23;"+(n+1)+"~":r.key="\x1B[23~";break;case 123:n?r.key="\x1B[24;"+(n+1)+"~":r.key="\x1B[24~";break;default:if(e.ctrlKey&&!e.shiftKey&&!e.altKey&&!e.metaKey)e.keyCode>=65&&e.keyCode<=90?r.key=String.fromCharCode(e.keyCode-64):e.keyCode===32?r.key="\0":e.keyCode>=51&&e.keyCode<=55?r.key=String.fromCharCode(e.keyCode-51+27):e.keyCode===56?r.key="\x7F":e.key==="/"?r.key="":e.keyCode===219?r.key="\x1B":e.keyCode===220?r.key="":e.keyCode===221&&(r.key="");else if((!i||s)&&e.altKey&&!e.metaKey){let o=ha[e.keyCode]?.[e.shiftKey?1:0];if(o)r.key="\x1B"+o;else if(e.keyCode>=65&&e.keyCode<=90){let h=e.ctrlKey?e.keyCode-64:e.keyCode+32,l=String.fromCharCode(h);e.shiftKey&&(l=l.toUpperCase()),r.key="\x1B"+l}else if(e.keyCode===32)r.key="\x1B"+(e.ctrlKey?"\0":" ");else if(e.key==="Dead"&&e.code.startsWith("Key")){let h=e.code.slice(3,4);e.shiftKey||(h=h.toLowerCase()),r.key="\x1B"+h,r.cancel=!0}}else if(i&&!e.altKey&&!e.ctrlKey&&!e.shiftKey&&e.metaKey)e.keyCode===65&&(r.type=1);else if(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&e.key.length===1)r.key=e.key;else if(e.key&&e.ctrlKey&&e.shiftKey)switch(e.code){case"Minus":r.key="";break;case"Digit2":r.key="\0";break;case"Digit6":r.key="";break}break}return r}var Us=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440},this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24},this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"},this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(e){if(e.code.startsWith("Numpad")){let t=e.code.slice(6);if(t>="0"&&t<="9")return 57399+parseInt(t,10);switch(t){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(e){switch(e.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(e){let t=0;return e.shiftKey&&(t|=1),e.altKey&&(t|=2),e.ctrlKey&&(t|=4),e.metaKey&&(t|=8),t>0?t+1:0}_getKeyCode(e,t){let i=this._getNumpadKeyCode(e);if(i!==void 0)return i;let s=this._getModifierKeyCode(e);if(s!==void 0)return s;let r=this._functionalKeyCodes[e.key];if(r!==void 0)return r;if((e.shiftKey||t&&e.altKey)&&e.code){if(e.code.startsWith("Digit")&&e.code.length===6){let n=e.code.charAt(5);if(n>="0"&&n<="9")return n.charCodeAt(0)}if(e.code.startsWith("Key")&&e.code.length===4)return e.code.charAt(3).toLowerCase().charCodeAt(0)}if(e.key.length===1){let n=e.key.codePointAt(0);return n>=65&&n<=90?n+32:n}}_isModifierKey(e){return e.key==="Shift"||e.key==="Control"||e.key==="Alt"||e.key==="Meta"}_isLockKey(e){return e.key==="CapsLock"||e.key==="NumLock"||e.key==="ScrollLock"}_buildCsiLetterSequence(e,t,i,s){let r=s&&i!==1;if(t>0||r){let n="\x1B[1;"+(t>0?t:"1");return r&&(n+=":"+i),n+=e,n}return"\x1B["+e}_buildSs3Sequence(e,t,i,s){let r=s&&i!==1;if(t>0||r){let n="\x1B[1;"+(t>0?t:"1");return r&&(n+=":"+i),n+=e,n}return"\x1BO"+e}_buildCsiTildeSequence(e,t,i,s){let r=s&&i!==1,n="\x1B["+e;return(t>0||r)&&(n+=";"+(t>0?t:"1"),r&&(n+=":"+i)),n+="~",n}_buildCsiUSequence(e,t,i,s,r,n,o){let h=!!(r&2),l=!!(r&4),a="\x1B["+t,c;l&&e.shiftKey&&e.key.length===1&&!n&&!o&&(c=e.key.codePointAt(0),a+=":"+c);let d=r&16&&s!==3&&e.key.length===1&&!n&&!o&&!e.ctrlKey?e.key.codePointAt(0):void 0,u=h&&s!==1&&(s===3||d===void 0);return(i>0||u||d!==void 0)&&(a+=";",i>0?a+=i:u&&(a+="1"),u&&(a+=":"+s)),d!==void 0&&(a+=";"+d),a+="u",a}evaluate(e,t,i=1,s=!1){let r={type:0,cancel:!1,key:void 0},n=this._encodeModifiers(e),o=this._isModifierKey(e),h=!!(t&2);if(!h&&i===3||o&&!(t&8)||this._isLockKey(e)&&!(t&8))return r;let l=this._csiLetterKeys[e.key];if(l)return r.key=this._buildCsiLetterSequence(l,n,i,h),r.cancel=!0,r;let a=this._ss3FunctionKeys[e.key];if(a)return r.key=this._buildSs3Sequence(a,n,i,h),r.cancel=!0,r;let c=this._csiTildeKeys[e.key];if(c!==void 0)return r.key=this._buildCsiTildeSequence(c,n,i,h),r.cancel=!0,r;let d=this._getKeyCode(e,s);if(d===void 0)return r;let u=d===13||d===9||d===127;if(u&&i===3&&!(t&8))return r;let f=this._functionalKeyCodes[e.key]!==void 0||this._getNumpadKeyCode(e)!==void 0;if(t&8||h&&i===3||(t&1||h)&&(f&&!u||n>0&&e.key.length!==1||n-1>1))r.key=this._buildCsiUSequence(e,d,n,i,t,f,o),r.cancel=!0;else{let _=d===13?"\r":d===9?" ":d===127?"\x7F":void 0;_?r.key=_:e.key.length===1&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&(r.key=e.key)}return r}static shouldUseProtocol(e){return e>0}},ca=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226},this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40},this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]),this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(e){let t=this._codeToVk[e.code];return t!==void 0?t:e.keyCode||0}_getScanCode(e){return this._codeToScancode[e.code]||0}_getUnicodeChar(e){if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(e.key==="Enter")return 10;if(e.key==="Backspace")return 127}let t=this._keyToControlChar[e.key];if(t!==void 0)return t;if(e.key.length===1){let i=e.key.codePointAt(0)||0;if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(i>=65&&i<=90)return i-64;if(i>=97&&i<=122)return i-96}return i}return 0}_getControlKeyState(e){let t=0;return e.shiftKey&&(t|=16),e.ctrlKey&&(e.code==="ControlRight"?t|=4:t|=8),e.altKey&&(e.code==="AltRight"?t|=1:t|=2),this._enhancedKeyCodes.has(e.code)&&(t|=256),t}evaluateKeyboardEvent(e,t){let i=this._getVirtualKeyCode(e),s=this._getScanCode(e),r=this._getUnicodeChar(e),n=t?1:0,o=this._getControlKeyState(e);return{type:0,cancel:!0,key:`\x1B[${i};${s};${r};${n};${o};1_`}}},Ii=class{constructor(e,t){this._coreService=e,this._optionsService=t}_getWin32InputMode(){return this._win32InputMode??=new ca,this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??=new Us,this._kittyKeyboard}evaluateKeyDown(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!0);let t=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(e,t,e.repeat?2:1,fe&&this._optionsService.rawOptions.macOptionIsMeta):la(e,this._coreService.decPrivateModes.applicationCursorKeys,fe,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!1);let t=this._coreService.kittyKeyboard.flags;if(this.useKitty&&t&2)return this._getKittyKeyboard().evaluate(e,t,3,fe&&this._optionsService.rawOptions.macOptionIsMeta)}get useKitty(){let e=this._coreService.kittyKeyboard.flags;return!!(this._optionsService.rawOptions.vtExtensions?.kittyKeyboard&&Us.shouldUseProtocol(e))}get useWin32InputMode(){return!!(this._optionsService.rawOptions.vtExtensions?.win32InputMode&&this._coreService.decPrivateModes.win32InputMode)}};Ii=F([g(0,Ee),g(1,oe)],Ii);var da=class{constructor(...e){this._entries=new Map;for(let[t,i]of e)this.set(t,i)}set(e,t){let i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(let[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}},_a=class{constructor(){this._services=new da,this._services.set(qi,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){let i=Vn(e).sort((n,o)=>n.index-o.index),s=[];for(let n of i){let o=this._services.get(n.id);if(!o)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${n.id._id}.`);s.push(o)}let r=i.length>0?i[0].index:t.length;if(t.length!==r)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${r+1} conflicts with ${t.length} static arguments`);return new e(...t,...s)}},ua={trace:0,debug:1,info:2,warn:3,error:4,off:5},fa="xterm.js: ",Ni=class extends L{constructor(e){super(),this._optionsService=e,this._logLevel=5,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}get logLevel(){return this._logLevel}_updateLogLevel(){this._logLevel=ua[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t<e.length;t++)typeof e[t]=="function"&&(e[t]=e[t]())}_log(e,t,i){this._evalLazyOptionalParams(i),e.call(console,(this._optionsService.options.logger?"":fa)+t,...i)}trace(e,...t){this._logLevel<=0&&this._log(this._optionsService.options.logger?.trace.bind(this._optionsService.options.logger)??console.log,e,t)}debug(e,...t){this._logLevel<=1&&this._log(this._optionsService.options.logger?.debug.bind(this._optionsService.options.logger)??console.log,e,t)}info(e,...t){this._logLevel<=2&&this._log(this._optionsService.options.logger?.info.bind(this._optionsService.options.logger)??console.info,e,t)}warn(e,...t){this._logLevel<=3&&this._log(this._optionsService.options.logger?.warn.bind(this._optionsService.options.logger)??console.warn,e,t)}error(e,...t){this._logLevel<=4&&this._log(this._optionsService.options.logger?.error.bind(this._optionsService.options.logger)??console.error,e,t)}};Ni=F([g(0,oe)],Ni);var qs=class extends L{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new y),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new y),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new y),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;let t=new Array(e);for(let i=0;i<Math.min(e,this.length);i++)t[i]=this._array[this._getCyclicIndex(i)];this._array=t,this._maxLength=e,this._startIndex=0}get length(){return this._length}set length(e){if(e>this._length)for(let t=this._length;t<e;t++)this._array[t]=void 0;this._length=e}get(e){return this._array[this._getCyclicIndex(e)]}set(e,t){this._array[this._getCyclicIndex(e)]=t}push(e){this._array[this._getCyclicIndex(this._length)]=e,this._length===this._maxLength?(this._startIndex=++this._startIndex%this._maxLength,this.onTrimEmitter.fire(1)):this._length++}recycle(){if(this._length!==this._maxLength)throw new Error("Can only recycle when the buffer is full");return this._startIndex=++this._startIndex%this._maxLength,this.onTrimEmitter.fire(1),this._array[this._getCyclicIndex(this._length-1)]}get isFull(){return this._length===this._maxLength}pop(){return this._array[this._getCyclicIndex(this._length---1)]}splice(e,t,...i){if(t){for(let s=e;s<this._length-t;s++)this._array[this._getCyclicIndex(s)]=this._array[this._getCyclicIndex(s+t)];this._length-=t,this.onDeleteEmitter.fire({index:e,amount:t})}for(let s=this._length-1;s>=e;s--)this._array[this._getCyclicIndex(s+i.length)]=this._array[this._getCyclicIndex(s)];for(let s=0;s<i.length;s++)this._array[this._getCyclicIndex(e+s)]=i[s];if(i.length&&this.onInsertEmitter.fire({index:e,amount:i.length}),this._length+i.length>this._maxLength){let s=this._length+i.length-this._maxLength;this._startIndex+=s,this._length=this._maxLength,this.onTrimEmitter.fire(s)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let r=t-1;r>=0;r--)this.set(e+r+i,this.get(e+r));let s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s<t;s++)this.set(e+s+i,this.get(e+s))}}_getCyclicIndex(e){return(this._startIndex+e)%this._maxLength}},Pr=class{constructor(){this._chunks=[],this._length=0}get length(){return this._length}reset(){this._chunks.length=0,this._length=0}append(e){this._chunks.push(e),this._length+=e.length}toString(){return this._chunks.join("")}},es=class{constructor(e){this._limit=e,this._builder=new Pr}get length(){return this._builder.length}get limit(){return this._limit}reset(){this._builder.reset()}append(e){return this._builder.append(e),this._builder.length>this._limit?(this._builder.reset(),!0):!1}toString(){return this._builder.toString()}},j=Object.freeze(new vt),Et=0,Vs=new ge,Dt=new Pr,ct=class Ar{constructor(t,i,s,r=!1){this._stringCache=t,this.isWrapped=r,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(i*3);let n=s??ge.fromCharData([0,"",1,0]);for(let o=0;o<i;++o)this.setCell(o,n);this.length=i}get(t){let i=this._data[t*3+0],s=i&2097151;return[this._data[t*3+1],i&2097152?this._combined[t]:s?Ae(s):"",i>>22,i&2097152?this._combined[t].charCodeAt(this._combined[t].length-1):s]}set(t,i){this._invalidateStringCache(),this._data[t*3+1]=i[0],i[1].length>1?(this._combined[t]=i[1],this._data[t*3+0]=t|2097152|i[2]<<22):this._data[t*3+0]=i[1].charCodeAt(0)|i[2]<<22}getWidth(t){return this._data[t*3+0]>>22}hasWidth(t){return this._data[t*3+0]&12582912}getFg(t){return this._data[t*3+1]}getBg(t){return this._data[t*3+2]}hasContent(t){return this._data[t*3+0]&4194303}getCodePoint(t){let i=this._data[t*3+0];return i&2097152?this._combined[t].charCodeAt(this._combined[t].length-1):i&2097151}isCombined(t){return this._data[t*3+0]&2097152}getString(t){let i=this._data[t*3+0];return i&2097152?this._combined[t]:i&2097151?Ae(i&2097151):""}isProtected(t){return this._data[t*3+2]&536870912}loadCell(t,i){return Et=t*3,i.content=this._data[Et+0],i.fg=this._data[Et+1],i.bg=this._data[Et+2],i.content&2097152?i.combinedData=this._combined[t]:i.combinedData="",i.bg&268435456?i.extended=this._extendedAttrs[t]:i.extended=j.extended.clone(),i}setCell(t,i){this._invalidateStringCache(),i.content&2097152&&(this._combined[t]=i.combinedData),i.bg&268435456&&(this._extendedAttrs[t]=i.extended),this._data[t*3+0]=i.content,this._data[t*3+1]=i.fg,this._data[t*3+2]=i.bg}setCellFromCodepoint(t,i,s,r){this._invalidateStringCache(),r.bg&268435456&&(this._extendedAttrs[t]=r.extended),this._data[t*3+0]=i|s<<22,this._data[t*3+1]=r.fg,this._data[t*3+2]=r.bg}addCodepointToCell(t,i,s){this._invalidateStringCache();let r=this._data[t*3+0];r&2097152?this._combined[t]+=Ae(i):r&2097151?(this._combined[t]=Ae(r&2097151)+Ae(i),r&=-2097152,r|=2097152):r=i|1<<22,s&&(r&=-12582913,r|=s<<22),this._data[t*3+0]=r}insertCells(t,i,s){if(this._invalidateStringCache(),t%=this.length,t&&this.getWidth(t-1)===2&&this.setCellFromCodepoint(t-1,0,1,s),i<this.length-t){for(let r=this.length-t-i-1;r>=0;--r)this.setCell(t+i+r,this.loadCell(t+r,Vs));for(let r=0;r<i;++r)this.setCell(t+r,s)}else for(let r=t;r<this.length;++r)this.setCell(r,s);this.getWidth(this.length-1)===2&&this.setCellFromCodepoint(this.length-1,0,1,s)}deleteCells(t,i,s){if(this._invalidateStringCache(),t%=this.length,i<this.length-t){for(let r=0;r<this.length-t-i;++r)this.setCell(t+r,this.loadCell(t+i+r,Vs));for(let r=this.length-i;r<this.length;++r)this.setCell(r,s)}else for(let r=t;r<this.length;++r)this.setCell(r,s);t&&this.getWidth(t-1)===2&&this.setCellFromCodepoint(t-1,0,1,s),this.getWidth(t)===0&&!this.hasContent(t)&&this.setCellFromCodepoint(t,0,1,s)}replaceCells(t,i,s,r=!1){if(this._invalidateStringCache(),r){for(t&&this.getWidth(t-1)===2&&!this.isProtected(t-1)&&this.setCellFromCodepoint(t-1,0,1,s),i<this.length&&this.getWidth(i-1)===2&&!this.isProtected(i)&&this.setCellFromCodepoint(i,0,1,s);t<i&&t<this.length;)this.isProtected(t)||this.setCell(t,s),t++;return}for(t&&this.getWidth(t-1)===2&&this.setCellFromCodepoint(t-1,0,1,s),i<this.length&&this.getWidth(i-1)===2&&this.setCellFromCodepoint(i,0,1,s);t<i&&t<this.length;)this.setCell(t++,s)}resize(t,i){if(this._invalidateStringCache(),t===this.length)return this._data.length*4*2<this._data.buffer.byteLength;let s=t*3;if(t>this.length){if(this._data.buffer.byteLength>=s*4)this._data=new Uint32Array(this._data.buffer,0,s);else{let r=new Uint32Array(s);r.set(this._data),this._data=r}for(let r=this.length;r<t;++r)this.setCell(r,i)}else{this._data=this._data.subarray(0,s);let r=Object.keys(this._combined);for(let o=0;o<r.length;o++){let h=parseInt(r[o],10);h>=t&&delete this._combined[h]}let n=Object.keys(this._extendedAttrs);for(let o=0;o<n.length;o++){let h=parseInt(n[o],10);h>=t&&delete this._extendedAttrs[h]}}return this.length=t,s*4*2<this._data.buffer.byteLength}cleanupMemory(){if(this._data.length*4*2<this._data.buffer.byteLength){let t=new Uint32Array(this._data.length);return t.set(this._data),this._data=t,1}return 0}fill(t,i=!1){if(this._invalidateStringCache(),i){for(let s=0;s<this.length;++s)this.isProtected(s)||this.setCell(s,t);return}this._combined={},this._extendedAttrs={};for(let s=0;s<this.length;++s)this.setCell(s,t)}copyFrom(t){this._invalidateStringCache(),this.length!==t.length?this._data=new Uint32Array(t._data):this._data.set(t._data),this.length=t.length,this._copySparseMapsFrom(t),this.isWrapped=t.isWrapped}clone(){let t=new Ar(this._stringCache,0,void 0,!1);return t._data=new Uint32Array(this._data),t.length=this.length,t._copySparseMapsFrom(this),t.isWrapped=this.isWrapped,t}getTrimmedLength(){for(let t=this.length-1;t>=0;--t)if(this._data[t*3+0]&4194303)return t+(this._data[t*3+0]>>22);return 0}getNoBgTrimmedLength(){for(let t=this.length-1;t>=0;--t)if(this._data[t*3+0]&4194303||this._data[t*3+2]&50331648)return t+(this._data[t*3+0]>>22);return 0}copyCellsFrom(t,i,s,r,n){this._invalidateStringCache();let o=t._data;if(n)for(let h=r-1;h>=0;h--){for(let l=0;l<3;l++)this._data[(s+h)*3+l]=o[(i+h)*3+l];this._copyCellMapsFrom(t,i+h,s+h)}else for(let h=0;h<r;h++){for(let l=0;l<3;l++)this._data[(s+h)*3+l]=o[(i+h)*3+l];this._copyCellMapsFrom(t,i+h,s+h)}}translateToString(t,i,s,r){let n=(i===void 0||i===0)&&s===void 0&&r===void 0;n&&this._stringCache.touch?.();let o=n?this._getStringCacheEntry(!1):void 0;if(n&&o?.value!==void 0){if(t)return o.isTrimmed?o.value:o.value.trimEnd();if(!o.isTrimmed)return o.value}for(i=i??0,s=s??this.length,t&&(s=Math.min(s,this.getTrimmedLength())),r&&(r.length=0),Dt.reset();i<s;){let l=this._data[i*3+0],a=l&2097151,c=l&2097152?this._combined[i]:a?Ae(a):" ";if(Dt.append(c),r)for(let d=0;d<c.length;++d)r.push(i);i+=l>>22||1}r&&r.push(i);let h=Dt.toString();if(Dt.reset(),n){let l=this._getStringCacheEntry(!0);l.value=h,l.isTrimmed=!!t}return h}_getStringCacheEntry(t){let i=this._stringCacheEntryRef?.deref();if(i&&i.generation===this._stringCache.generation)return i;if(!t)return;let s=this._stringCache.allocateEntry();return this._stringCacheEntryRef=new WeakRef(s),s}_invalidateStringCache(){let t=this._getStringCacheEntry(!1);t&&(t.value=void 0,t.isTrimmed=!1)}_copyCellMapsFrom(t,i,s){let r=i*3;t._data[r+0]&2097152&&(this._combined[s]=t._combined[i]),t._data[r+2]&268435456&&(this._extendedAttrs[s]=t._extendedAttrs[i])}_copySparseMapsFrom(t){this._combined={},this._extendedAttrs={};for(let i=0;i<t.length;i++)this._copyCellMapsFrom(t,i,i)}},pa=class extends L{constructor(){super(),this.generation=0,this.entries=new Set,this._clearTimeout=this._register(new le),this._lastAccessTimestamp=0,this._register(O(()=>this.entries.clear()))}touch(){this._scheduleClear()}allocateEntry(){let e={value:void 0,isTrimmed:!1,generation:this.generation};return this.entries.add(e),this._scheduleClear(),e}clear(){this._clearTimeout.clear(),this._lastAccessTimestamp=0,this.generation++;for(let e of this.entries)e.value=void 0,e.isTrimmed=!1;this.entries.clear()}_scheduleClear(){this._lastAccessTimestamp=Date.now(),!this._clearTimeout.value&&this._scheduleClearTimeout(15e3)}_scheduleClearTimeout(e){this._clearTimeout.value=Qn(()=>{let t=Date.now()-this._lastAccessTimestamp;if(t>=15e3){this.clear();return}this._scheduleClearTimeout(15e3-t)},e)}};function ga(e,t,i,s,r,n){let o=[];for(let h=0;h<e.length-1;h++){let l=h,a=e.get(++l);if(!a.isWrapped)continue;let c=[e.get(h)];for(;l<e.length&&a.isWrapped;)c.push(a),a=e.get(++l);if(!n&&s>=h&&s<l){h+=c.length-1;continue}let d=0,u=gt(c,d,t),f=1,_=0;for(;f<c.length;){let S=gt(c,f,t),k=S-_,R=i-u,E=Math.min(k,R);c[d].copyCellsFrom(c[f],_,u,E,!1),u+=E,u===i&&(d++,u=0),_+=E,_===S&&(f++,_=0),u===0&&d!==0&&c[d-1].getWidth(i-1)===2&&(c[d].copyCellsFrom(c[d-1],i-1,u++,1,!1),c[d-1].setCell(i-1,r))}c[d].replaceCells(u,i,r);let p=0;for(let S=c.length-1;S>0&&(S>d||c[S].getTrimmedLength()===0);S--)p++;p>0&&(o.push(h+c.length-p),o.push(p)),h+=c.length-1}return o}function va(e,t){let i=[],s=0,r=t[s],n=0;for(let o=0;o<e.length;o++)if(r===o){let h=t[++s];e.onDeleteEmitter.fire({index:o-n,amount:h}),o+=h-1,n+=h,r=t[++s]}else i.push(o);return{layout:i,countRemoved:n}}function ma(e,t){let i=[];for(let s=0;s<t.length;s++)i.push(e.get(t[s]));for(let s=0;s<i.length;s++)e.set(s,i[s]);e.length=t.length}function Sa(e,t,i){let s=[],r=0;for(let l=0;l<e.length;l++)r+=gt(e,l,t);let n=0,o=0,h=0;for(;h<r;){if(r-h<i){s.push(r-h);break}n+=i;let l=gt(e,o,t);n>l&&(n-=l,o++);let a=e[o].getWidth(n-1)===2;a&&n--;let c=a?i-1:i;s.push(c),h+=c}return s}function gt(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();let s=!e[t].hasContent(i-1)&&e[t].getWidth(i-1)===1,r=e[t+1].getWidth(0)===2;return s&&r?i-1:i}var Or=class Ir{constructor(t){this.line=t,this.isDisposed=!1,this._disposables=[],this._id=Ir._nextId++,this._onDispose=this.register(new y),this.onDispose=this._onDispose.event}get id(){return this._id}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),ut(this._disposables),this._disposables.length=0)}register(t){return this._disposables.push(t),t}};Or._nextId=1;var wa=Or,G={},ze=G.B;G[0]={"`":"\u25C6",a:"\u2592",b:"\u2409",c:"\u240C",d:"\u240D",e:"\u240A",f:"\xB0",g:"\xB1",h:"\u2424",i:"\u240B",j:"\u2518",k:"\u2510",l:"\u250C",m:"\u2514",n:"\u253C",o:"\u23BA",p:"\u23BB",q:"\u2500",r:"\u23BC",s:"\u23BD",t:"\u251C",u:"\u2524",v:"\u2534",w:"\u252C",x:"\u2502",y:"\u2264",z:"\u2265","{":"\u03C0","|":"\u2260","}":"\xA3","~":"\xB7"};G.A={"#":"\xA3"};G.B=void 0;G[4]={"#":"\xA3","@":"\xBE","[":"ij","\\":"\xBD","]":"|","{":"\xA8","|":"f","}":"\xBC","~":"\xB4"};G.C=G[5]={"[":"\xC4","\\":"\xD6","]":"\xC5","^":"\xDC","`":"\xE9","{":"\xE4","|":"\xF6","}":"\xE5","~":"\xFC"};G.R={"#":"\xA3","@":"\xE0","[":"\xB0","\\":"\xE7","]":"\xA7","{":"\xE9","|":"\xF9","}":"\xE8","~":"\xA8"};G.Q={"@":"\xE0","[":"\xE2","\\":"\xE7","]":"\xEA","^":"\xEE","`":"\xF4","{":"\xE9","|":"\xF9","}":"\xE8","~":"\xFB"};G.K={"@":"\xA7","[":"\xC4","\\":"\xD6","]":"\xDC","{":"\xE4","|":"\xF6","}":"\xFC","~":"\xDF"};G.Y={"#":"\xA3","@":"\xA7","[":"\xB0","\\":"\xE7","]":"\xE9","`":"\xF9","{":"\xE0","|":"\xF2","}":"\xE8","~":"\xEC"};G.E=G[6]={"@":"\xC4","[":"\xC6","\\":"\xD8","]":"\xC5","^":"\xDC","`":"\xE4","{":"\xE6","|":"\xF8","}":"\xE5","~":"\xFC"};G.Z={"#":"\xA3","@":"\xA7","[":"\xA1","\\":"\xD1","]":"\xBF","{":"\xB0","|":"\xF1","}":"\xE7"};G.H=G[7]={"@":"\xC9","[":"\xC4","\\":"\xD6","]":"\xC5","^":"\xDC","`":"\xE9","{":"\xE4","|":"\xF6","}":"\xE5","~":"\xFC"};G["="]={"#":"\xF9","@":"\xE0","[":"\xE9","\\":"\xE7","]":"\xEA","^":"\xEE",_:"\xE8","`":"\xF4","{":"\xE4","|":"\xF6","}":"\xFC","~":"\xFB"};var Ys=4294967295,Xs=class extends L{constructor(e,t,i,s){super(),this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this._logService=s,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=j.clone(),this.savedCharset=ze,this.savedCharsets=[],this.savedGlevel=0,this.savedOriginMode=!1,this.savedWraparoundMode=!0,this.markers=[],this._nullCell=ge.fromCharData([0,"",1,0]),this._whitespaceCell=ge.fromCharData([0," ",1,32]),this._isClearing=!1,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new qs(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops(),this._memoryCleanupQueue=new Nt(this._logService),this._register(O(()=>this._memoryCleanupQueue.clear())),this._register(O(()=>this.clearAllMarkers())),this._stringCache=this._register(new pa)}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new Pt),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new Pt),this._whitespaceCell}getBlankLine(e,t){return new ct(this._stringCache,this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){let e=this.ybase+this.y-this.ydisp;return e>=0&&e<this._rows}_getCorrectBufferLength(e){if(!this._hasScrollback)return e;let t=e+this._optionsService.rawOptions.scrollback;return t>Ys?Ys:t}fillViewportRows(e){if(this.lines.length===0){e??=j;let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this._stringCache.clear(),this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new qs(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){let i=this.getNullCell(j);this._stringCache.clear();let s=0,r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols<e)for(let o=0;o<this.lines.length;o++)s+=+this.lines.get(o).resize(e,i);let n=0;if(this._rows<t)for(let o=this._rows;o<t;o++)this.lines.length<t+this.ybase&&(this._optionsService.rawOptions.windowsPty.backend!==void 0||this._optionsService.rawOptions.windowsPty.buildNumber!==void 0?this.lines.push(new ct(this._stringCache,e,i,!1)):this.ybase>0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new ct(this._stringCache,e,i,!1)));else for(let o=this._rows;o>t;o--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r<this.lines.maxLength){let o=this.lines.length-r;o>0&&(this.lines.trimStart(o),this.ybase=Math.max(this.ybase-o,0),this.ydisp=Math.max(this.ydisp-o,0),this.savedY=Math.max(this.savedY-o,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let n=0;n<this.lines.length;n++)s+=+this.lines.get(n).resize(e,i);if(this._cols=e,this._rows=t,this.lines.length>0){let n=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,n)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition<this.lines.length;)if(t+=this.lines.get(this._memoryCleanupPosition++).cleanupMemory(),t>100)return!0;return e}get _isReflowEnabled(){let e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&e.backend==="conpty"&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){let i=this._optionsService.rawOptions.reflowCursorLine,s=ga(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(j),i);if(s.length>0){let r=va(this.lines,s);ma(this.lines,r.layout),this._reflowLargerAdjustViewport(e,t,r.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){let s=this.getNullCell(j),r=i;for(;r-- >0;)this.ybase===0?(this.y>0&&this.y--,this.lines.length<t&&this.lines.push(new ct(this._stringCache,e,s,!1))):(this.ydisp===this.ybase&&this.ydisp--,this.ybase--);this.savedY=Math.max(this.savedY-i,0)}_reflowSmaller(e,t){let i=this._optionsService.rawOptions.reflowCursorLine,s=this.getNullCell(j),r=[],n=0;for(let o=this.lines.length-1;o>=0;o--){let h=this.lines.get(o);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;let l=[h];for(;h.isWrapped&&o>0;)h=this.lines.get(--o),l.unshift(h);if(!i){let E=this.ybase+this.y;if(E>=o&&E<o+l.length)continue}let a=l[l.length-1].getTrimmedLength(),c=Sa(l,this._cols,e),d=c.length-l.length,u;this.ybase===0&&this.y!==this.lines.length-1?u=Math.max(0,this.y-this.lines.maxLength+d):u=Math.max(0,this.lines.length-this.lines.maxLength+d);let f=[];for(let E=0;E<d;E++){let B=this.getBlankLine(j,!0);f.push(B)}f.length>0&&(r.push({start:o+l.length+n,newLines:f}),n+=f.length),l.push(...f);let _=c.length-1,p=c[_];p===0&&(_--,p=c[_]);let S=l.length-d-1,k=a;for(;S>=0;){let E=Math.min(k,p);if(l[_]===void 0)break;if(l[_].copyCellsFrom(l[S],k-E,p-E,E,!0),p-=E,p===0&&(_--,p=c[_]),k-=E,k===0){S--;let B=Math.max(S,0);k=gt(l,B,this._cols)}}for(let E=0;E<l.length;E++)c[E]<e&&l[E].setCell(c[E],s);let R=d-u;for(;R-- >0;)this.ybase===0?this.y<t-1?(this.y++,this.lines.pop()):(this.ybase++,this.ydisp++):this.ybase<Math.min(this.lines.maxLength,this.lines.length+n)-t&&(this.ybase===this.ydisp&&this.ydisp++,this.ybase++);this.savedY=Math.min(this.savedY+d,this.ybase+t-1)}if(r.length>0){let o=[],h=[];for(let p=0;p<this.lines.length;p++)h.push(this.lines.get(p));let l=this.lines.length,a=l-1,c=0,d=r[c];this.lines.length=Math.min(this.lines.maxLength,this.lines.length+n);let u=0;for(let p=Math.min(this.lines.maxLength-1,l+n-1);p>=0;p--)if(d&&d.start>a+u){for(let S=d.newLines.length-1;S>=0;S--)this.lines.set(p--,d.newLines[S]);p++,o.push({index:a+1,amount:d.newLines.length}),u+=d.newLines.length,d=r[++c]}else this.lines.set(p,h[a--]);let f=0;for(let p=o.length-1;p>=0;p--)o[p].index+=f,this.lines.onInsertEmitter.fire(o[p]),f+=o[p].amount;let _=Math.max(0,l+n-this.lines.maxLength);_>0&&this.lines.onTrimEmitter.fire(_)}}translateBufferLineToString(e,t,i=0,s){let r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+1<this.lines.length&&this.lines.get(i+1).isWrapped;)i++;return{first:t,last:i}}setupTabStops(e){for(e!=null?this.tabs[e]||(e=this.prevStop(e)):(this.tabs={},e=0);e<this._cols;e+=this._optionsService.rawOptions.tabStopWidth)this.tabs[e]=!0}prevStop(e){for(e??=this.x;!this.tabs[--e]&&e>0;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e<this._cols;);return e>=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t<this.markers.length;t++)this.markers[t].line===e&&(this.markers[t].dispose(),this.markers.splice(t--,1));this._isClearing=!1}clearAllMarkers(){this._isClearing=!0;for(let e=0;e<this.markers.length;e++)this.markers[e].dispose();this.markers.length=0,this._isClearing=!1}addMarker(e){let t=new wa(e);return this.markers.push(t),t.register(this.lines.onTrim(i=>{t.line-=i,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(i=>{t.line>=i.index&&(t.line+=i.amount)})),t.register(this.lines.onDelete(i=>{t.line>=i.index&&t.line<i.index+i.amount&&t.dispose(),t.line>i.index&&(t.line-=i.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}},ba=class extends L{constructor(e,t,i){super(),this._optionsService=e,this._bufferService=t,this._logService=i,this._normalBuffer=this._register(new le),this._altBuffer=this._register(new le),this._onBufferActivate=this._register(new y),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new Xs(!0,this._optionsService,this._bufferService,this._logService),this._normalBuffer.value=this._normal,this._normal.fillViewportRows(),this._alt=new Xs(!1,this._optionsService,this._bufferService,this._logService),this._altBuffer.value=this._alt,this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}},Hi=class extends L{constructor(e,t){super(),this.isUserScrolling=!1,this._onResize=this._register(new y),this.onResize=this._onResize.event,this._onScroll=this._register(new y),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,2),this.rows=Math.max(e.rawOptions.rows||0,1),this.buffers=this._register(new ba(e,this,t)),this._register(this.buffers.onBufferActivate(i=>{this._onScroll.fire(i.activeBuffer.ydisp)}))}get buffer(){return this.buffers.active}resize(e,t){let i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){let i=this.buffer,s;s=this._cachedBlankLine,(!s||s.length!==this.cols||s.getFg(0)!==e.fg||s.getBg(0)!==e.bg)&&(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;let r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(i.scrollTop===0){let o=i.lines.isFull;n===i.lines.length-1?o?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),o?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{let o=n-r+1;i.lines.shiftElements(r+1,o-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){let i=this.buffer;if(e<0){if(i.ydisp===0)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);let s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};Hi=F([g(0,oe),g(1,Je)],Hi);var Xe={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,blinkIntervalDuration:0,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollbar:{showScrollbar:!0},scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,mouseEventsRequireAlt:!1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:fe,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",quirks:{},vtExtensions:{}},ya=["normal","bold","100","200","300","400","500","600","700","800","900"],Ca=class extends L{constructor(e){super(),this._onOptionChange=this._register(new y),this.onOptionChange=this._onOptionChange.event;let t={...Xe};for(let i in e)if(i in t)try{let s=e[i];t[i]=this._sanitizeAndValidateOption(i,s)}catch(s){console.error(s)}this.rawOptions=t,this.options={...t},this._setupOptions(),this._register(O(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(i=>{i===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(i=>{e.indexOf(i)!==-1&&t()})}_setupOptions(){let e=i=>{if(!(i in Xe))throw new Error(`No option with key "${i}"`);return this.rawOptions[i]},t=(i,s)=>{if(!(i in Xe))throw new Error(`No option with key "${i}"`);s=this._sanitizeAndValidateOption(i,s),this.rawOptions[i]!==s&&(this.rawOptions[i]=s,this._onOptionChange.fire(i))};for(let i in this.rawOptions){let s={get:e.bind(this,i),set:t.bind(this,i)};Object.defineProperty(this.options,i,s)}}_sanitizeAndValidateOption(e,t){switch(e){case"cursorStyle":if(t||(t=Xe[e]),!ka(t))throw new Error(`"${t}" is not a valid value for ${e}`);break;case"wordSeparator":t||(t=Xe[e]);break;case"fontWeight":case"fontWeightBold":if(typeof t=="number"&&1<=t&&t<=1e3)break;t=ya.includes(t)?t:Xe[e];break;case"blinkIntervalDuration":if(t=Math.floor(t),t<0)throw new Error(`${e} cannot be less than 0, value: ${t}`);break;case"cursorWidth":t=Math.floor(t);case"lineHeight":case"tabStopWidth":if(t<1)throw new Error(`${e} cannot be less than 1, value: ${t}`);break;case"minimumContrastRatio":t=Math.max(1,Math.min(21,Math.round(t*10)/10));break;case"scrollback":if(t=Math.min(t,4294967295),t<0)throw new Error(`${e} cannot be less than 0, value: ${t}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(t<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${t}`);break;case"rows":case"cols":if(!t&&t!==0)throw new Error(`${e} must be numeric, value: ${t}`);break;case"windowsPty":t=t??{};break}return t}};function ka(e){return e==="block"||e==="underline"||e==="bar"}var js=Object.freeze({insertMode:!1}),Gs=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0}),Js=()=>({flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}),Fi=class extends L{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorHidden=!1,this._onData=this._register(new y),this.onData=this._onData.event,this._onUserInput=this._register(new y),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new y),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new y),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.isCursorInitialized=i.rawOptions.showCursorImmediately??!1,this.modes=structuredClone(js),this.decPrivateModes=structuredClone(Gs),this.kittyKeyboard=Js()}reset(){this.modes=structuredClone(js),this.decPrivateModes=structuredClone(Gs),this.kittyKeyboard=Js()}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;let i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(s=>s.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(t=>t.charCodeAt(0))),this._onBinary.fire(e))}};Fi=F([g(0,ne),g(1,Je),g(2,oe)],Fi);var Zs={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>e.button===4||e.action!==1?!1:(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>e.action!==32},DRAG:{events:23,restrict:e=>!(e.action===32&&e.button===3)},ANY:{events:31,restrict:e=>!0}};function ui(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return e.button===4?(i|=64,i|=e.action):(i|=e.button&3,e.button&4&&(i|=64),e.button&8&&(i|=128),e.action===32?i|=32:e.action===0&&!t&&(i|=3)),i}var fi=String.fromCharCode,Qs={DEFAULT:e=>{let t=[ui(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`\x1B[M${fi(t[0])}${fi(t[1])}${fi(t[2])}`},SGR:e=>{let t=e.action===0&&e.button!==4?"m":"M";return`\x1B[<${ui(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{let t=e.action===0&&e.button!==4?"m":"M";return`\x1B[<${ui(e,!0)};${e.x};${e.y}${t}`}},xa=class extends L{constructor(){super(),this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._onProtocolChange=this._register(new y),this.onProtocolChange=this._onProtocolChange.event;for(let e of Object.keys(Zs))this.addProtocol(e,Zs[e]);for(let e of Object.keys(Qs))this.addEncoding(e,Qs[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return this._protocols[this._activeProtocol].events!==0}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT"}setCustomWheelEventHandler(e){this._customWheelEventHandler=e}allowCustomWheelEvent(e){return this._customWheelEventHandler?this._customWheelEventHandler(e)!==!1:!0}restrictMouseEvent(e){return this._protocols[this._activeProtocol].restrict(e)}encodeMouseEvent(e){return this._encodings[this._activeEncoding](e)}get isDefaultEncoding(){return this._activeEncoding==="DEFAULT"}get isPixelEncoding(){return this._activeEncoding==="SGR_PIXELS"}},Ke=class Rt{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new y,this.onChange=this._onChange.event}static extractShouldJoin(t){return(t&1)!==0}static extractWidth(t){return t>>1&3}static extractCharKind(t){return t>>3}static createPropertyValue(t,i,s=!1){return(t&16777215)<<3|(i&3)<<1|(s?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(t){if(!this._providers[t])throw new Error(`unknown Unicode version "${t}"`);this._active=t,this._activeProvider=this._providers[t],this._onChange.fire(t)}register(t){this._providers[t.version]=t,this._active||(this.activeVersion=t.version)}wcwidth(t){return this._activeProvider.wcwidth(t)}getStringCellWidth(t){let i=0,s=0,r=t.length;for(let n=0;n<r;++n){let o=t.charCodeAt(n);if(55296<=o&&o<=56319){if(++n>=r)return i+this.wcwidth(o);let a=t.charCodeAt(n);56320<=a&&a<=57343?o=(o-55296)*1024+a-56320+65536:i+=this.wcwidth(a)}let h=this.charProperties(o,s),l=Rt.extractWidth(h);Rt.extractShouldJoin(h)&&(l-=Rt.extractWidth(s)),i+=l,s=h}return i}charProperties(t,i){return this._activeProvider.charProperties(t,i)}},pi=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],Ba=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]],X;function Ea(e,t){let i=0,s=t.length-1,r;if(e<t[0][0]||e>t[s][1])return!1;for(;s>=i;)if(r=i+s>>1,e>t[r][1])i=r+1;else if(e<t[r][0])s=r-1;else return!0;return!1}var Da=class{constructor(){if(this.version="6",!X){X=new Uint8Array(65536),X.fill(1),X[0]=0,X.fill(0,1,32),X.fill(0,127,160),X.fill(2,4352,4448),X[9001]=2,X[9002]=2,X.fill(2,11904,42192),X[12351]=1,X.fill(2,44032,55204),X.fill(2,63744,64256),X.fill(2,65040,65050),X.fill(2,65072,65136),X.fill(2,65280,65377),X.fill(2,65504,65511);for(let e=0;e<pi.length;++e)X.fill(0,pi[e][0],pi[e][1]+1)}}wcwidth(e){return e<32?0:e<127?1:e<65536?X[e]:Ea(e,Ba)?0:e>=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),s=i===0&&t!==0;if(s){let r=Ke.extractWidth(t);r===0?s=!1:r>i&&(i=r)}return Ke.createPropertyValue(0,i,s)}},Ma=class{constructor(){this.glevel=0,this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}};function er(e){let t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1)?.get(e.cols-1),i=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);i&&t&&(i.isWrapped=t[3]!==0&&t[3]!==32)}var Nr=class Wi{constructor(t=32,i=32){if(this.maxLength=t,this.maxSubParamsLength=i,i>256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(t),this.length=0,this._subParams=new Int32Array(i),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(t),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}static fromArray(t){let i=new Wi;if(!t.length)return i;for(let s=Array.isArray(t[0])?1:0;s<t.length;++s){let r=t[s];if(Array.isArray(r))for(let n=0;n<r.length;++n)i.addSubParam(r[n]);else i.addParam(r)}return i}clone(){let t=new Wi(this.maxLength,this.maxSubParamsLength);return t.params.set(this.params),t.length=this.length,t._subParams.set(this._subParams),t._subParamsLength=this._subParamsLength,t._subParamsIdx.set(this._subParamsIdx),t._rejectDigits=this._rejectDigits,t._rejectSubDigits=this._rejectSubDigits,t._digitIsSub=this._digitIsSub,t}toArray(){let t=[];for(let i=0;i<this.length;++i){t.push(this.params[i]);let s=this._subParamsIdx[i]>>8,r=this._subParamsIdx[i]&255;r-s>0&&t.push(Array.prototype.slice.call(this._subParams,s,r))}return t}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}resetZdm(){this.length=1,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1,this._subParamsIdx[0]=0,this.params[0]=0}addParam(t){if(this._digitIsSub=!1,this.length>=this.maxLength){this._rejectDigits=!0;return}if(t<-1)throw new Error("values less than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=t>2147483647?2147483647:t}addSubParam(t){if(this._digitIsSub=!0,!!this.length){if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength){this._rejectSubDigits=!0;return}if(t<-1)throw new Error("values less than -1 are not allowed");this._subParams[this._subParamsLength++]=t>2147483647?2147483647:t,this._subParamsIdx[this.length-1]++}}hasSubParams(t){return(this._subParamsIdx[t]&255)-(this._subParamsIdx[t]>>8)>0}getSubParams(t){let i=this._subParamsIdx[t]>>8,s=this._subParamsIdx[t]&255;return s-i>0?this._subParams.subarray(i,s):null}getSubParamsAll(){let t={};for(let i=0;i<this.length;++i){let s=this._subParamsIdx[i]>>8,r=this._subParamsIdx[i]&255;r-s>0&&(t[i]=this._subParams.slice(s,r))}return t}addDigit(t){let i;if(this._rejectDigits||!(i=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;let s=this._digitIsSub?this._subParams:this.params,r=s[i-1];s[i-1]=~r?Math.min(r*10+t,2147483647):t}},nt=[],La=class{constructor(){this._state=0,this._active=nt,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];let i=this._handlers[e];return i.push(t),{dispose:()=>{let s=i.indexOf(t);s!==-1&&i.splice(s,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=nt}reset(){if(this._state===2)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=nt,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||nt,!this._active.length)this._handlerFb(this._id,"START");else for(let e=this._active.length-1;e>=0;e--)this._active[e].start()}_put(e,t,i){if(!this._active.length)this._handlerFb(this._id,"PUT",Ge(e,t,i));else for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i)}start(){this.reset(),this._state=1}put(e,t,i){if(this._state!==3){if(this._state===1)for(;t<i;){let s=e[t++];if(s===59){this._state=2,this._start();break}if(s<48||57<s){this._state=3;return}this._id===-1&&(this._id=0),this._id=this._id*10+s-48}this._state===2&&i-t>0&&this._put(e,t,i)}}end(e,t=!0){if(this._state!==0){if(this._state!==3)if(this._state===1&&this._start(),!this._active.length)this._handlerFb(this._id,"END",e);else{let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&i===!1){for(;s>=0&&(i=this._active[s].end(e),i!==!0);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}this._active=nt,this._id=-1,this._state=0}}},Hr=class Fr{constructor(t){this._handler=t,this._data=new es(Fr._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(t,i,s){this._hitLimit||this._data.append(Ge(t,i,s))&&(this._hitLimit=!0)}end(t){let i=!1;if(this._hitLimit)i=!1;else if(t&&(i=this._handler(this._data.toString()),i instanceof Promise))return i.then(s=>(this._data.reset(),this._hitLimit=!1,s));return this._data.reset(),this._hitLimit=!1,i}};Hr._payloadLimit=1e7;var de=Hr,ot=[],Ra=class{constructor(){this._handlers=Object.create(null),this._active=ot,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=ot}registerHandler(e,t){this._handlers[e]??=[];let i=this._handlers[e];return i.push(t),{dispose:()=>{let s=i.indexOf(t);s!==-1&&i.splice(s,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=ot,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||ot,!this._active.length)this._handlerFb(this._ident,"HOOK",t);else for(let i=this._active.length-1;i>=0;i--)this._active[i].hook(t)}put(e,t,i){if(!this._active.length)this._handlerFb(this._ident,"PUT",Ge(e,t,i));else for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i)}unhook(e,t=!0){if(!this._active.length)this._handlerFb(this._ident,"UNHOOK",e);else{let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&i===!1){for(;s>=0&&(i=this._active[s].unhook(e),i!==!0);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}this._active=ot,this._ident=0}},dt=new Nr;dt.addParam(0);var Wr=class zr{constructor(t){this._handler=t,this._data=new es(zr._payloadLimit),this._params=dt,this._hitLimit=!1}hook(t){this._params=t.length>1||t.params[0]?t.clone():dt,this._data.reset(),this._hitLimit=!1}put(t,i,s){this._hitLimit||this._data.append(Ge(t,i,s))&&(this._hitLimit=!0)}unhook(t){let i=!1;if(this._hitLimit)i=!1;else if(t&&(i=this._handler(this._data.toString(),this._params),i instanceof Promise))return i.then(s=>(this._params=dt,this._data.reset(),this._hitLimit=!1,s));return this._params=dt,this._data.reset(),this._hitLimit=!1,i}};Wr._payloadLimit=1e7;var tr=Wr,at=[],Ta=class{constructor(){this._handlers=Object.create(null),this._active=at,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];let i=this._handlers[e];return i.push(t),{dispose:()=>{let s=i.indexOf(t);s!==-1&&i.splice(s,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=at}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=at,this._ident=0}start(e){if(this.reset(),this._ident=e,this._active=this._handlers[e]||at,!this._active.length)this._handlerFb(this._ident,"START");else for(let t=this._active.length-1;t>=0;t--)this._active[t].start()}put(e,t,i){if(!this._active.length)this._handlerFb(this._ident,"PUT",Ge(e,t,i));else for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i)}end(e,t=!0){if(!this._active.length)this._handlerFb(this._ident,"END",e);else{let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&i===!1){for(;s>=0&&(i=this._active[s].end(e),i!==!0);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}this._active=at,this._ident=0}},Kr=class $r{constructor(t){this._handler=t,this._data=new es($r._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(t,i,s){this._hitLimit||this._data.append(Ge(t,i,s))&&(this._hitLimit=!0)}end(t){let i=!1;if(this._hitLimit)i=!1;else if(t&&(i=this._handler(this._data.toString()),i instanceof Promise))return i.then(s=>(this._data.reset(),this._hitLimit=!1,s));return this._data.reset(),this._hitLimit=!1,i}};Kr._payloadLimit=1e7;var Pa=Kr,Aa=class{constructor(e){this.table=new Uint16Array(e)}setDefault(e,t){this.table.fill(e<<8|t)}add(e,t,i,s){this.table[t<<8|e]=i<<8|s}addMany(e,t,i,s){for(let r=0;r<e.length;r++)this.table[t<<8|e[r]]=i<<8|s}},re=160,Oa=(function(){let e=new Aa(4257),t=Array.apply(null,Array(256)).map((o,h)=>h),i=(o,h)=>t.slice(o,h),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));let n=i(0,17);e.setDefault(1,0),e.addMany(s,0,2,0);for(let o of n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158],o,0,7),e.add(159,o,11,14),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(95,1,11,14),e.addMany(r,14,0,14),e.add(127,14,0,14),e.addMany(i(32,48),14,9,15),e.addMany(i(48,127),14,15,16),e.addMany(i(48,127),15,15,16),e.addMany(r,15,0,15),e.addMany(i(32,48),15,9,15),e.add(127,15,0,15),e.addMany(s,16,16,16),e.addMany(r,16,0,16),e.addMany(i(8,14),16,16,16),e.add(127,16,0,16),e.addMany([27,156,24,26],16,17,0),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(re,0,2,0),e.add(re,8,5,8),e.add(re,6,0,6),e.add(re,11,0,11),e.add(re,13,13,13),e.add(re,16,16,16),e})(),Ia=class extends L{constructor(e=Oa){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new Nr,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(t,i,s)=>{},this._executeHandlerFb=t=>{},this._csiHandlerFb=(t,i)=>{},this._escHandlerFb=t=>{},this._errorHandlerFb=t=>t,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register(O(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new La),this._dcsParser=this._register(new Ra),this._apcParser=this._register(new Ta),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i<60||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let r=0;r<e.intermediates.length;++r){let n=e.intermediates.charCodeAt(r);if(32>n||n>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=n}}if(e.final.length!==1)throw new Error("final must be a single byte");let s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){let t=[];for(;e;)t.push(String.fromCharCode(e&255)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){let i=this._identifier(e,[48,126]);this._escHandlers[i]??=[];let s=this._escHandlers[i];return s.push(t),{dispose:()=>{let r=s.indexOf(t);r!==-1&&s.splice(r,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){let i=e.charCodeAt(0);this._executeHandlers[i]=t,i<24&&(this._executeHandlersArr[i]=t)}clearExecuteHandler(e){let t=e.charCodeAt(0);this._executeHandlers[t]&&delete this._executeHandlers[t],t<24&&(this._executeHandlersArr[t]=void 0)}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){let i=this._identifier(e);this._csiHandlers[i]??=[];let s=this._csiHandlers[i];return s.push(t),{dispose:()=>{let r=s.indexOf(t);r!==-1&&s.splice(r,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return e.prefix=void 0,this._apcParser.registerHandler(this._identifier(e,[48,126]),t)}clearApcHandler(e){e.prefix=void 0,this._apcParser.clearHandler(this._identifier(e,[48,126]))}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0,this._parseStack.state!==0&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r,n=0,o;if(this._parseStack.state)if(this._parseStack.state===2)this._parseStack.state=0,n=this._parseStack.chunkPos+1;else{if(i===void 0||this._parseStack.state===1)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");let h=this._parseStack.handlers,l=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(i===!1&&l>-1){for(;l>=0&&(o=h[l](this._params),o!==!0);l--)if(o instanceof Promise)return this._parseStack.handlerPos=l,o}this._parseStack.handlers=[];break;case 4:if(i===!1&&l>-1){for(;l>=0&&(o=h[l](),o!==!0);l--)if(o instanceof Promise)return this._parseStack.handlerPos=l,o}this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],o=this._dcsParser.unhook(s!==24&&s!==26,i),o)return o;s===27&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],o=this._oscParser.end(s!==24&&s!==26,i),o)return o;s===27&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],o=this._apcParser.end(s!==24&&s!==26,i),o)return o;s===27&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break}this._parseStack.state=0,n=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=this._parseStack.transition&255}for(let h=n;h<t;++h){if(s=e[h],s<24&&this.currentState<=6){(this._executeHandlersArr[s]??this._executeHandlerFb)(s),this.precedingJoinState=0;continue}if(s===27&&this.currentState<8&&h+2<t&&e[h+1]===91){this._params.resetZdm(),this._collect=0;let l=h+2,a=e[l];a>=60&&a<=63&&(this._collect=a,l++);let c=!1;for(;l<t;l++)if(a=e[l],a>=48&&a<=57)this._params.addDigit(a-48);else if(a===59)this._params.addParam(0);else if(a===58)this._params.addSubParam(-1);else if(a>=64&&a<=126){let d=this._csiHandlers[this._collect<<8|a],u=d?d.length-1:-1;for(;u>=0&&(o=d[u](this._params),o!==!0);u--)if(o instanceof Promise)return r=1792,this._preserveStack(3,d,u,r,l),o;u<0&&this._csiHandlerFb(this._collect<<8|a,this._params),this.precedingJoinState=0,h=l,this.currentState=0,c=!0;break}else break;c||(h=l-1,this.currentState=4);continue}switch(r=this._transitions.table[this.currentState<<8|(s<re?s:re)],r>>8){case 2:let l=h,a=t-4;for(;l<a&&e[++l]>=32&&(e[l]<=126||e[l]>=re)&&e[++l]>=32&&(e[l]<=126||e[l]>=re)&&e[++l]>=32&&(e[l]<=126||e[l]>=re)&&e[++l]>=32&&(e[l]<=126||e[l]>=re););if(l>=a)for(;l<t&&e[l]>=32&&(e[l]<=126||e[l]>=re);)l++;this._printHandler(e,h,l),h=l-1;break;case 3:this._executeHandlers[s]?this._executeHandlers[s]():this._executeHandlerFb(s),this.precedingJoinState=0;break;case 0:break;case 1:if(this._errorHandler({position:h,code:s,currentState:this.currentState,collect:this._collect,params:this._params,abort:!1}).abort)return;break;case 7:let c=this._csiHandlers[this._collect<<8|s],d=c?c.length-1:-1;for(;d>=0&&(o=c[d](this._params),o!==!0);d--)if(o instanceof Promise)return this._preserveStack(3,c,d,r,h),o;d<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}while(++h<t&&(s=e[h])>47&&s<60);h--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:let u=this._escHandlers[this._collect<<8|s],f=u?u.length-1:-1;for(;f>=0&&(o=u[f](),o!==!0);f--)if(o instanceof Promise)return this._preserveStack(4,u,f,r,h),o;f<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.resetZdm(),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let _=h+1;;++_)if(_>=t||(s=e[_])===24||s===26||s===27||s>127&&s<re){this._dcsParser.put(e,h,_),h=_-1;break}break;case 14:if(o=this._dcsParser.unhook(s!==24&&s!==26),o)return this._preserveStack(6,[],0,r,h),o;s===27&&(r|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0;break;case 4:this._oscParser.start();break;case 5:for(let _=h+1;;_++)if(_>=t||(s=e[_])<32||s>127&&s<re){this._oscParser.put(e,h,_),h=_-1;break}break;case 6:if(o=this._oscParser.end(s!==24&&s!==26),o)return this._preserveStack(5,[],0,r,h),o;s===27&&(r|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0;break;case 15:this._apcParser.start(this._collect<<8|s);break;case 16:for(let _=h+1;;++_)if(!(_<t&&(e[_]>=32&&e[_]<127||e[_]>=8&&e[_]<14||e[_]>=re))){this._apcParser.put(e,h,_),h=_-1;break}break;case 17:if(o=this._apcParser.end(s!==24&&s!==26),o)return this._preserveStack(7,[],0,r,h),o;s===27&&(r|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0;break}this.currentState=r&255}}},Na=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,Ha=/^[\da-f]+$/;function ir(e){if(!e)return;let t=e.toLowerCase();if(t.startsWith("rgb:")){t=t.slice(4);let i=Na.exec(t);if(i){let s=i[1]?15:i[4]?255:i[7]?4095:65535;return[Math.round(parseInt(i[1]||i[4]||i[7]||i[10],16)/s*255),Math.round(parseInt(i[2]||i[5]||i[8]||i[11],16)/s*255),Math.round(parseInt(i[3]||i[6]||i[9]||i[12],16)/s*255)]}}else if(t.startsWith("#")&&(t=t.slice(1),Ha.exec(t)&&[3,6,9,12].includes(t.length))){let i=t.length/3,s=[0,0,0];for(let r=0;r<3;++r){let n=parseInt(t.slice(i*r,i*r+i),16);s[r]=i===1?n<<4:i===2?n:i===3?n>>4:n>>8}return s}}function gi(e,t){let i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}function Fa(e,t=16){let[i,s,r]=e;return`rgb:${gi(i,t)}/${gi(s,t)}/${gi(r,t)}`}var Wa="6.1.0-beta.292",za={"(":0,")":1,"*":2,"+":3,"-":1,".":2};function sr(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var rr=0,Ka=class extends L{constructor(e,t,i,s,r,n,o,h,l=new Ia){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=n,this._mouseStateService=o,this._unicodeService=h,this._parser=l,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new Un,this._utf8Decoder=new qn,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=j.clone(),this._eraseAttrDataInternal=j.clone(),this._onRequestBell=this._register(new y),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new y),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new y),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new y),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new y),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new y),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new y),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new y),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new y),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new y),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new y),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new y),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new y),this.onColor=this._onColor.event,this._onRequestColorSchemeQuery=this._register(new y),this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new zi(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(a=>this._activeBuffer=a.activeBuffer)),this._parser.setCsiHandlerFallback((a,c)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(a),params:c.toArray()})}),this._parser.setEscHandlerFallback(a=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(a)})}),this._parser.setExecuteHandlerFallback(a=>{this._logService.debug("Unknown EXECUTE code: ",{code:a})}),this._parser.setOscHandlerFallback((a,c,d)=>{this._logService.debug("Unknown OSC code: ",{identifier:a,action:c,data:d})}),this._parser.setDcsHandlerFallback((a,c,d)=>{c==="HOOK"&&(d=d.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(a),action:c,payload:d})}),this._parser.setApcHandlerFallback((a,c,d)=>{this._logService.debug("Unknown APC code: ",{identifier:this._parser.identToString(a),action:c,payload:d})}),this._parser.setPrintHandler((a,c,d)=>this.print(a,c,d)),this._parser.registerCsiHandler({final:"@"},a=>this.insertChars(a)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},a=>this.scrollLeft(a)),this._parser.registerCsiHandler({final:"A"},a=>this.cursorUp(a)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},a=>this.scrollRight(a)),this._parser.registerCsiHandler({final:"B"},a=>this.cursorDown(a)),this._parser.registerCsiHandler({final:"C"},a=>this.cursorForward(a)),this._parser.registerCsiHandler({final:"D"},a=>this.cursorBackward(a)),this._parser.registerCsiHandler({final:"E"},a=>this.cursorNextLine(a)),this._parser.registerCsiHandler({final:"F"},a=>this.cursorPrecedingLine(a)),this._parser.registerCsiHandler({final:"G"},a=>this.cursorCharAbsolute(a)),this._parser.registerCsiHandler({final:"H"},a=>this.cursorPosition(a)),this._parser.registerCsiHandler({final:"I"},a=>this.cursorForwardTab(a)),this._parser.registerCsiHandler({final:"J"},a=>this.eraseInDisplay(a,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},a=>this.eraseInDisplay(a,!0)),this._parser.registerCsiHandler({final:"K"},a=>this.eraseInLine(a,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},a=>this.eraseInLine(a,!0)),this._parser.registerCsiHandler({final:"L"},a=>this.insertLines(a)),this._parser.registerCsiHandler({final:"M"},a=>this.deleteLines(a)),this._parser.registerCsiHandler({final:"P"},a=>this.deleteChars(a)),this._parser.registerCsiHandler({final:"S"},a=>this.scrollUp(a)),this._parser.registerCsiHandler({final:"T"},a=>this.scrollDown(a)),this._parser.registerCsiHandler({final:"X"},a=>this.eraseChars(a)),this._parser.registerCsiHandler({final:"Z"},a=>this.cursorBackwardTab(a)),this._parser.registerCsiHandler({final:"^"},a=>this.scrollDown(a)),this._parser.registerCsiHandler({final:"`"},a=>this.charPosAbsolute(a)),this._parser.registerCsiHandler({final:"a"},a=>this.hPositionRelative(a)),this._parser.registerCsiHandler({final:"b"},a=>this.repeatPrecedingCharacter(a)),this._parser.registerCsiHandler({final:"c"},a=>this.sendDeviceAttributesPrimary(a)),this._parser.registerCsiHandler({prefix:">",final:"c"},a=>this.sendDeviceAttributesSecondary(a)),this._parser.registerCsiHandler({final:"d"},a=>this.linePosAbsolute(a)),this._parser.registerCsiHandler({final:"e"},a=>this.vPositionRelative(a)),this._parser.registerCsiHandler({final:"f"},a=>this.hVPosition(a)),this._parser.registerCsiHandler({final:"g"},a=>this.tabClear(a)),this._parser.registerCsiHandler({final:"h"},a=>this.setMode(a)),this._parser.registerCsiHandler({prefix:"?",final:"h"},a=>this.setModePrivate(a)),this._parser.registerCsiHandler({final:"l"},a=>this.resetMode(a)),this._parser.registerCsiHandler({prefix:"?",final:"l"},a=>this.resetModePrivate(a)),this._parser.registerCsiHandler({final:"m"},a=>this.charAttributes(a)),this._parser.registerCsiHandler({final:"n"},a=>this.deviceStatus(a)),this._parser.registerCsiHandler({prefix:"?",final:"n"},a=>this.deviceStatusPrivate(a)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},a=>this.softReset(a)),this._parser.registerCsiHandler({prefix:">",final:"q"},a=>this.sendXtVersion(a)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},a=>this.setCursorStyle(a)),this._parser.registerCsiHandler({final:"r"},a=>this.setScrollRegion(a)),this._parser.registerCsiHandler({final:"s"},a=>this.saveCursor(a)),this._parser.registerCsiHandler({final:"t"},a=>this.windowOptions(a)),this._parser.registerCsiHandler({final:"u"},a=>this.restoreCursor(a)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},a=>this.insertColumns(a)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},a=>this.deleteColumns(a)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},a=>this.selectProtected(a)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},a=>this.requestMode(a,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},a=>this.requestMode(a,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},a=>this.kittyKeyboardSet(a)),this._parser.registerCsiHandler({prefix:"?",final:"u"},a=>this.kittyKeyboardQuery(a)),this._parser.registerCsiHandler({prefix:">",final:"u"},a=>this.kittyKeyboardPush(a)),this._parser.registerCsiHandler({prefix:"<",final:"u"},a=>this.kittyKeyboardPop(a)),this._parser.setExecuteHandler("\x07",()=>this.bell()),this._parser.setExecuteHandler(` +`,()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler(" ",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("\x84",()=>this.index()),this._parser.setExecuteHandler("\x85",()=>this.nextLine()),this._parser.setExecuteHandler("\x88",()=>this.tabSet()),this._parser.registerOscHandler(0,new de(a=>(this.setTitle(a),this.setIconName(a),!0))),this._parser.registerOscHandler(1,new de(a=>this.setIconName(a))),this._parser.registerOscHandler(2,new de(a=>this.setTitle(a))),this._parser.registerOscHandler(4,new de(a=>this.setOrReportIndexedColor(a))),this._parser.registerOscHandler(8,new de(a=>this.setHyperlink(a))),this._parser.registerOscHandler(10,new de(a=>this.setOrReportFgColor(a))),this._parser.registerOscHandler(11,new de(a=>this.setOrReportBgColor(a))),this._parser.registerOscHandler(12,new de(a=>this.setOrReportCursorColor(a))),this._parser.registerOscHandler(104,new de(a=>this.restoreIndexedColor(a))),this._parser.registerOscHandler(110,new de(a=>this.restoreFgColor(a))),this._parser.registerOscHandler(111,new de(a=>this.restoreBgColor(a))),this._parser.registerOscHandler(112,new de(a=>this.restoreCursorColor(a))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(let a in G)this._parser.registerEscHandler({intermediates:"(",final:a},()=>this.selectCharset("("+a)),this._parser.registerEscHandler({intermediates:")",final:a},()=>this.selectCharset(")"+a)),this._parser.registerEscHandler({intermediates:"*",final:a},()=>this.selectCharset("*"+a)),this._parser.registerEscHandler({intermediates:"+",final:a},()=>this.selectCharset("+"+a)),this._parser.registerEscHandler({intermediates:"-",final:a},()=>this.selectCharset("-"+a)),this._parser.registerEscHandler({intermediates:".",final:a},()=>this.selectCharset("."+a)),this._parser.registerEscHandler({intermediates:"/",final:a},()=>this.selectCharset("/"+a));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(a=>(this._logService.error("Parsing error: ",a),a)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new tr((a,c)=>this.requestStatusString(a,c)))}getAttrData(){return this._curAttrData}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=3){let t,i=new Promise((s,r)=>{t=setTimeout(()=>r("#SLOW_TIMEOUT"),5e3)});Promise.race([e,i]).then(()=>{t!==void 0&&clearTimeout(t)},s=>{if(t!==void 0&&clearTimeout(t),s!=="#SLOW_TIMEOUT")throw s;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0,o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(n=this._parseStack.position+131072)}if(this._logService.logLevel<=1&&this._logService.debug(`parsing data ${typeof e=="string"?` "${e}"`:` "${Array.prototype.map.call(e,a=>String.fromCharCode(a)).join("")}"`}`),this._logService.logLevel===0&&this._logService.trace("parsing data (codes)",typeof e=="string"?e.split("").map(a=>a.charCodeAt(0)):e),this._parseBuffer.length<e.length&&this._parseBuffer.length<131072&&(this._parseBuffer=new Uint32Array(Math.min(e.length,131072))),o||this._dirtyRowTracker.clearRange(),e.length>131072)for(let a=n;a<e.length;a+=131072){let c=a+131072<e.length?a+131072:e.length,d=typeof e=="string"?this._stringDecoder.decode(e.substring(a,c),this._parseBuffer):this._utf8Decoder.decode(e.subarray(a,c),this._parseBuffer);if(i=this._parser.parse(this._parseBuffer,d))return this._preserveStack(s,r,d,a),this._logSlowResolvingAsync(i),i}else if(!o){let a=typeof e=="string"?this._stringDecoder.decode(e,this._parseBuffer):this._utf8Decoder.decode(e,this._parseBuffer);if(i=this._parser.parse(this._parseBuffer,a))return this._preserveStack(s,r,a,0),this._logSlowResolvingAsync(i),i}(this._activeBuffer.x!==s||this._activeBuffer.y!==r)&&this._onCursorMove.fire();let h=this._dirtyRowTracker.end+(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp),l=this._dirtyRowTracker.start+(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp);l<this._bufferService.rows&&this._onRequestRefreshRows.fire({start:Math.min(l,this._bufferService.rows-1),end:Math.min(h,this._bufferService.rows-1)})}print(e,t,i){let s,r,n=this._charsetService.charset,o=this._optionsService.rawOptions.screenReaderMode,h=this._bufferService.cols,l=this._coreService.decPrivateModes.wraparound,a=this._coreService.modes.insertMode,c=this._curAttrData,d=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);if(!d)return;this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._activeBuffer.x&&i-t>0&&d.getWidth(this._activeBuffer.x-1)===2&&d.setCellFromCodepoint(this._activeBuffer.x-1,0,1,c);let u=this._parser.precedingJoinState;for(let f=t;f<i;++f){if(s=e[f],s===173)continue;if(s<127&&n){let R=n[String.fromCharCode(s)];R&&(s=R.charCodeAt(0))}let _=this._unicodeService.charProperties(s,u);r=Ke.extractWidth(_);let p=Ke.extractShouldJoin(_),S=p?Ke.extractWidth(u):0;u=_,o&&this._onA11yChar.fire(Ae(s));let k=this._getCurrentLinkId();if(k&&this._oscLinkService.addLineToLink(k,this._activeBuffer.ybase+this._activeBuffer.y),this._activeBuffer.x+r-S>h){if(l){let R=d,E=this._activeBuffer.x-S;if(this._activeBuffer.x=S,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),d=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!d)return;for(S>0&&d instanceof ct&&d.copyCellsFrom(R,E,0,S,!1);E<h;)R.setCellFromCodepoint(E++,0,1,c)}else if(this._activeBuffer.x=h-1,r===2)continue}if(p&&this._activeBuffer.x){let R=d.getWidth(this._activeBuffer.x-1)?1:2;d.addCodepointToCell(this._activeBuffer.x-R,s,r);for(let E=r-S;--E>=0;)d.setCellFromCodepoint(this._activeBuffer.x++,0,0,c);continue}if(a&&(d.insertCells(this._activeBuffer.x,r-S,this._activeBuffer.getNullCell(c)),d.getWidth(h-1)===2&&d.setCellFromCodepoint(h-1,0,1,c)),d.setCellFromCodepoint(this._activeBuffer.x++,s,r,c),r>0)for(;--r;)d.setCellFromCodepoint(this._activeBuffer.x++,0,0,c)}this._parser.precedingJoinState=u,this._activeBuffer.x<h&&i-t>0&&d.getWidth(this._activeBuffer.x)===0&&!d.hasContent(this._activeBuffer.x)&&d.setCellFromCodepoint(this._activeBuffer.x,0,1,c),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return e.final==="t"&&!e.prefix&&!e.intermediates?this._parser.registerCsiHandler(e,i=>sr(i.params[0],this._optionsService.rawOptions.windowOptions)?t(i):!0):this._parser.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new tr(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new de(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new Pa(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;let e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){let t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){let t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){let t=e.params[0];return t===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:t===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){let t=e.params[0];return t===1&&(this._curAttrData.bg|=536870912),(t===2||t===0)&&(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){let n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n&&(n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1))}_resetBufferLine(e,t=!1){let i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){this._restrictCursor(this._bufferService.cols);let i;switch(e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);i<this._bufferService.rows;i++)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(i);break;case 1:if(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i,0,this._activeBuffer.x+1,!0,t),this._activeBuffer.x+1>=this._bufferService.cols){let r=this._activeBuffer.lines.get(i+1);r&&(r.isWrapped=!1)}for(;i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--&&!this._activeBuffer.lines.get(this._activeBuffer.ybase+i)?.getTrimmedLength(););for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:let s=this._activeBuffer.lines.length-this._bufferService.rows;s>0&&(this._activeBuffer.lines.trimStart(s),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-s,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-s,0),this._onScroll.fire(0));break}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t);break}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.y<this._activeBuffer.scrollTop)return!0;let i=this._activeBuffer.ybase+this._activeBuffer.y,s=this._bufferService.rows-1-this._activeBuffer.scrollBottom,r=this._bufferService.rows-1+this._activeBuffer.ybase-s+1;for(;t--;)this._activeBuffer.lines.splice(r-1,1),this._activeBuffer.lines.splice(i,0,this._activeBuffer.getBlankLine(this._eraseAttrData()));return this._dirtyRowTracker.markRangeDirty(this._activeBuffer.y,this._activeBuffer.scrollBottom),this._activeBuffer.x=0,!0}deleteLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.y<this._activeBuffer.scrollTop)return!0;let i=this._activeBuffer.ybase+this._activeBuffer.y,s;for(s=this._bufferService.rows-1-this._activeBuffer.scrollBottom,s=this._bufferService.rows-1+this._activeBuffer.ybase-s;t--;)this._activeBuffer.lines.splice(i,1),this._activeBuffer.lines.splice(s,0,this._activeBuffer.getBlankLine(this._eraseAttrData()));return this._dirtyRowTracker.markRangeDirty(this._activeBuffer.y,this._activeBuffer.scrollBottom),this._activeBuffer.x=0,!0}insertChars(e){this._restrictCursor();let t=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);return t&&(t.insertCells(this._activeBuffer.x,e.params[0]||1,this._activeBuffer.getNullCell(this._eraseAttrData())),this._dirtyRowTracker.markDirty(this._activeBuffer.y)),!0}deleteChars(e){this._restrictCursor();let t=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);return t&&(t.deleteCells(this._activeBuffer.x,e.params[0]||1,this._activeBuffer.getNullCell(this._eraseAttrData())),this._dirtyRowTracker.markDirty(this._activeBuffer.y)),!0}scrollUp(e){let t=e.params[0]||1;for(;t--;)this._activeBuffer.lines.splice(this._activeBuffer.ybase+this._activeBuffer.scrollTop,1),this._activeBuffer.lines.splice(this._activeBuffer.ybase+this._activeBuffer.scrollBottom,0,this._activeBuffer.getBlankLine(this._eraseAttrData()));return this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom),!0}scrollDown(e){let t=e.params[0]||1;for(;t--;)this._activeBuffer.lines.splice(this._activeBuffer.ybase+this._activeBuffer.scrollBottom,1),this._activeBuffer.lines.splice(this._activeBuffer.ybase+this._activeBuffer.scrollTop,0,this._activeBuffer.getBlankLine(j));return this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom),!0}scrollLeft(e){if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.y<this._activeBuffer.scrollTop)return!0;let t=e.params[0]||1;for(let i=this._activeBuffer.scrollTop;i<=this._activeBuffer.scrollBottom;++i){let s=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);s.deleteCells(0,t,this._activeBuffer.getNullCell(this._eraseAttrData())),s.isWrapped=!1}return this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom),!0}scrollRight(e){if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.y<this._activeBuffer.scrollTop)return!0;let t=e.params[0]||1;for(let i=this._activeBuffer.scrollTop;i<=this._activeBuffer.scrollBottom;++i){let s=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);s.insertCells(0,t,this._activeBuffer.getNullCell(this._eraseAttrData())),s.isWrapped=!1}return this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom),!0}insertColumns(e){if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.y<this._activeBuffer.scrollTop)return!0;let t=e.params[0]||1;for(let i=this._activeBuffer.scrollTop;i<=this._activeBuffer.scrollBottom;++i){let s=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);s.insertCells(this._activeBuffer.x,t,this._activeBuffer.getNullCell(this._eraseAttrData())),s.isWrapped=!1}return this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom),!0}deleteColumns(e){if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.y<this._activeBuffer.scrollTop)return!0;let t=e.params[0]||1;for(let i=this._activeBuffer.scrollTop;i<=this._activeBuffer.scrollBottom;++i){let s=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);s.deleteCells(this._activeBuffer.x,t,this._activeBuffer.getNullCell(this._eraseAttrData())),s.isWrapped=!1}return this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom),!0}eraseChars(e){this._restrictCursor();let t=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);return t&&(t.replaceCells(this._activeBuffer.x,this._activeBuffer.x+(e.params[0]||1),this._activeBuffer.getNullCell(this._eraseAttrData())),this._dirtyRowTracker.markDirty(this._activeBuffer.y)),!0}repeatPrecedingCharacter(e){let t=this._parser.precedingJoinState;if(!t)return!0;let i=e.params[0]||1,s=Ke.extractWidth(t),r=this._activeBuffer.x-s,n=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).getString(r),o=new Uint32Array(n.length*i),h=0;for(let a=0;a<n.length;){let c=n.codePointAt(a)||0;o[h++]=c,a+=c>65535?2:1}let l=h;for(let a=1;a<i;++a)o.copyWithin(l,0,h),l+=h;return this.print(o,0,l),!0}sendDeviceAttributesPrimary(e){return e.params[0]>0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("\x1B[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("\x1B[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("\x1B[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("\x1B[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("\x1B[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`\x1BP>|xterm.js(${Wa})\x1B\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t<e.length;t++)switch(e.params[t]){case 4:this._coreService.modes.insertMode=!0;break;case 20:this._optionsService.options.convertEol=!0;break}return!0}setModePrivate(e){for(let t=0;t<e.length;t++)switch(e.params[t]){case 1:this._coreService.decPrivateModes.applicationCursorKeys=!0;break;case 2:this._charsetService.setgCharset(0,ze),this._charsetService.setgCharset(1,ze),this._charsetService.setgCharset(2,ze),this._charsetService.setgCharset(3,ze);break;case 3:this._optionsService.rawOptions.windowOptions.setWinLines&&(this._bufferService.resize(132,this._bufferService.rows),this._onRequestReset.fire());break;case 6:this._coreService.decPrivateModes.origin=!0,this._setCursor(0,0);break;case 7:this._coreService.decPrivateModes.wraparound=!0;break;case 12:this._optionsService.rawOptions.quirks?.allowSetCursorBlink&&(this._optionsService.options.cursorBlink=!0);break;case 45:this._coreService.decPrivateModes.reverseWraparound=!0;break;case 66:this._logService.debug("Serial port requested application keypad."),this._coreService.decPrivateModes.applicationKeypad=!0,this._onRequestSyncScrollBar.fire();break;case 9:this._mouseStateService.activeProtocol="X10";break;case 1e3:this._mouseStateService.activeProtocol="VT200";break;case 1002:this._mouseStateService.activeProtocol="DRAG";break;case 1003:this._mouseStateService.activeProtocol="ANY";break;case 1004:this._coreService.decPrivateModes.sendFocus=!0,this._onRequestSendFocus.fire();break;case 1005:this._logService.debug("DECSET 1005 not supported (see #2507)");break;case 1006:this._mouseStateService.activeEncoding="SGR";break;case 1015:this._logService.debug("DECSET 1015 not supported (see #2507)");break;case 1016:this._mouseStateService.activeEncoding="SGR_PIXELS";break;case 25:this._coreService.isCursorHidden=!1;break;case 1048:this.saveCursor();break;case 1049:this.saveCursor();case 47:case 1047:if(this._optionsService.rawOptions.vtExtensions?.kittyKeyboard){let i=this._coreService.kittyKeyboard;i.mainFlags=i.flags,i.flags=i.altFlags}this._bufferService.buffers.activateAltBuffer(this._eraseAttrData()),this._coreService.isCursorInitialized=!0,this._onRequestRefreshRows.fire(void 0),this._onRequestSyncScrollBar.fire();break;case 2004:this._coreService.decPrivateModes.bracketedPasteMode=!0;break;case 2026:this._coreService.decPrivateModes.synchronizedOutput=!0;break;case 2031:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&(this._coreService.decPrivateModes.colorSchemeUpdates=!0);break;case 9001:this._optionsService.rawOptions.vtExtensions?.win32InputMode&&(this._coreService.decPrivateModes.win32InputMode=!0);break}return!0}resetMode(e){for(let t=0;t<e.length;t++)switch(e.params[t]){case 4:this._coreService.modes.insertMode=!1;break;case 20:this._optionsService.options.convertEol=!1;break}return!0}resetModePrivate(e){for(let t=0;t<e.length;t++)switch(e.params[t]){case 1:this._coreService.decPrivateModes.applicationCursorKeys=!1;break;case 3:this._optionsService.rawOptions.windowOptions.setWinLines&&(this._bufferService.resize(80,this._bufferService.rows),this._onRequestReset.fire());break;case 6:this._coreService.decPrivateModes.origin=!1,this._setCursor(0,0);break;case 7:this._coreService.decPrivateModes.wraparound=!1;break;case 12:this._optionsService.rawOptions.quirks?.allowSetCursorBlink&&(this._optionsService.options.cursorBlink=!1);break;case 45:this._coreService.decPrivateModes.reverseWraparound=!1;break;case 66:this._logService.debug("Switching back to normal keypad."),this._coreService.decPrivateModes.applicationKeypad=!1,this._onRequestSyncScrollBar.fire();break;case 9:case 1e3:case 1002:case 1003:this._mouseStateService.activeProtocol="NONE";break;case 1004:this._coreService.decPrivateModes.sendFocus=!1;break;case 1005:this._logService.debug("DECRST 1005 not supported (see #2507)");break;case 1006:this._mouseStateService.activeEncoding="DEFAULT";break;case 1015:this._logService.debug("DECRST 1015 not supported (see #2507)");break;case 1016:this._mouseStateService.activeEncoding="DEFAULT";break;case 25:this._coreService.isCursorHidden=!0;break;case 1048:this.restoreCursor();break;case 1049:case 47:case 1047:if(this._optionsService.rawOptions.vtExtensions?.kittyKeyboard){let i=this._coreService.kittyKeyboard;i.altFlags=i.flags,i.flags=i.mainFlags}this._bufferService.buffers.activateNormalBuffer(),e.params[t]===1049&&this.restoreCursor(),this._coreService.isCursorInitialized=!0,this._onRequestRefreshRows.fire(void 0),this._onRequestSyncScrollBar.fire();break;case 2004:this._coreService.decPrivateModes.bracketedPasteMode=!1;break;case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break;case 2031:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&(this._coreService.decPrivateModes.colorSchemeUpdates=!1);break;case 9001:this._optionsService.rawOptions.vtExtensions?.win32InputMode&&(this._coreService.decPrivateModes.win32InputMode=!1);break}return!0}requestMode(e,t){let i;(p=>(p[p.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",p[p.SET=1]="SET",p[p.RESET=2]="RESET",p[p.PERMANENTLY_SET=3]="PERMANENTLY_SET",p[p.PERMANENTLY_RESET=4]="PERMANENTLY_RESET"))(i||={});let s=this._coreService.decPrivateModes,{activeProtocol:r,activeEncoding:n}=this._mouseStateService,o=this._coreService,{buffers:h,cols:l}=this._bufferService,{active:a,alt:c}=h,d=this._optionsService.rawOptions,u=(p,S)=>(o.triggerDataEvent(`\x1B[${t?"":"?"}${p};${S}$y`),!0),f=p=>p?1:2,_=e.params[0];return t?_===2?u(_,4):_===4?u(_,f(o.modes.insertMode)):_===12?u(_,3):_===20?u(_,f(d.convertEol)):u(_,0):_===1?u(_,f(s.applicationCursorKeys)):_===3?u(_,d.windowOptions.setWinLines?l===80?2:l===132?1:0:0):_===6?u(_,f(s.origin)):_===7?u(_,f(s.wraparound)):_===8?u(_,3):_===9?u(_,f(r==="X10")):_===12?u(_,f(d.cursorBlink)):_===25?u(_,f(!o.isCursorHidden)):_===45?u(_,f(s.reverseWraparound)):_===66?u(_,f(s.applicationKeypad)):_===67?u(_,4):_===1e3?u(_,f(r==="VT200")):_===1002?u(_,f(r==="DRAG")):_===1003?u(_,f(r==="ANY")):_===1004?u(_,f(s.sendFocus)):_===1005?u(_,4):_===1006?u(_,f(n==="SGR")):_===1015?u(_,4):_===1016?u(_,f(n==="SGR_PIXELS")):_===1048?u(_,1):_===47||_===1047||_===1049?u(_,f(a===c)):_===2004?u(_,f(s.bracketedPasteMode)):_===2026?u(_,f(s.synchronizedOutput)):_===9001&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?u(_,f(s.win32InputMode)):u(_,0)}_updateAttrColor(e,t,i,s,r){return t===2?(e|=50331648,e&=-16777216,e|=vt.fromColorRGB([i,s,r])):t===5&&(e&=-67108864,e|=33554432|i&255),e}_extractColor(e,t,i){let s=[0,0,-1,0,0,0],r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){let o=e.getSubParams(t+n),h=0;do s[1]===5&&(r=1),s[n+h+1+r]=o[h];while(++h<o.length&&h+n+1+r<s.length);break}if(s[1]===5&&n+r>=2||s[1]===2&&n+r>=5)break;s[1]&&(r=1)}while(++n+t<e.length&&n+r<s.length);for(let o=2;o<s.length;++o)s[o]===-1&&(s[o]=0);switch(s[0]){case 38:i.fg=this._updateAttrColor(i.fg,s[1],s[3],s[4],s[5]);break;case 48:i.bg=this._updateAttrColor(i.bg,s[1],s[3],s[4],s[5]);break;case 58:i.extended=i.extended.clone(),i.extended.underlineColor=this._updateAttrColor(i.extended.underlineColor,s[1],s[3],s[4],s[5])}return n}_processUnderline(e,t){t.extended=t.extended.clone(),(!~e||e>5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,e===0&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=j.fg,e.bg=j.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(e.length===1&&e.params[0]===0)return this._processSGR0(this._curAttrData),!0;let t=e.length,i,s=this._curAttrData;for(let r=0;r<t;r++)i=e.params[r],i>=30&&i<=37?(s.fg&=-67108864,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-67108864,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-67108864,s.fg|=16777216|i-90|8):i>=100&&i<=107?(s.bg&=-67108864,s.bg|=16777216|i-100|8):i===0?this._processSGR0(s):i===1?s.fg|=134217728:i===3?s.bg|=67108864:i===4?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):i===5?s.fg|=536870912:i===7?s.fg|=67108864:i===8?s.fg|=1073741824:i===9?s.fg|=2147483648:i===2?s.bg|=134217728:i===21?this._processUnderline(2,s):i===22?(s.fg&=-134217729,s.bg&=-134217729):i===23?s.bg&=-67108865:i===24?(s.fg&=-268435457,this._processUnderline(0,s)):i===25?s.fg&=-536870913:i===27?s.fg&=-67108865:i===28?s.fg&=-1073741825:i===29?s.fg&=2147483647:i===39?(s.fg&=-67108864,s.fg|=j.fg&16777215):i===49?(s.bg&=-67108864,s.bg|=j.bg&16777215):i===38||i===48||i===58?r+=this._extractColor(e,r,s):i===53?s.bg|=1073741824:i===55?s.bg&=-1073741825:i===221&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.fg&=-134217729:i===222&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.bg&=-134217729:i===59?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("\x1B[0n");break;case 6:let t=this._activeBuffer.y+1,i=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[${t};${i}R`);break}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:let t=this._activeBuffer.y+1,i=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[?${t};${i}R`);break;case 15:break;case 25:break;case 26:break;case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&this._onRequestColorSchemeQuery.fire();break}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=j.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){let t=e.length===0?1:e.params[0];if(t===0)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar";break}let i=t%2===1;this._coreService.decPrivateModes.cursorBlink=i}return!0}setScrollRegion(e){let t=e.params[0]||1,i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||i===0)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!sr(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;let t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:t!==2&&this._onRequestWindowsOptionsReport.fire(0);break;case 16:this._onRequestWindowsOptionsReport.fire(1);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`\x1B[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:(t===0||t===2)&&(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),(t===0||t===1)&&(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:(t===0||t===2)&&this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),(t===0||t===1)&&this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop());break}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let t=0;t<this._activeBuffer.savedCharsets.length;t++)this._charsetService.setgCharset(t,this._activeBuffer.savedCharsets[t]);return this._charsetService.setgLevel(this._activeBuffer.savedGlevel),this._coreService.decPrivateModes.origin=this._activeBuffer.savedOriginMode,this._coreService.decPrivateModes.wraparound=this._activeBuffer.savedWraparoundMode,this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){let t=[],i=e.split(";");for(;i.length>1;){let s=i.shift(),r=i.shift();if(/^\d+$/.exec(s)){let n=parseInt(s,10);if(nr(n))if(r==="?")t.push({type:0,index:n});else{let o=ir(r);o&&t.push({type:1,index:n,color:o})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){let t=e.indexOf(";");if(t===-1)return!0;let i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):i.trim()?!1:this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();let i=e.split(":"),s,r=i.findIndex(n=>n.startsWith("id="));return r!==-1&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){let i=e.split(";");for(let s=0;s<i.length&&!(t>=this._specialColors.length);++s,++t)if(i[s]==="?")this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{let r=ir(i[s]);r&&this._onColor.fire([{type:1,index:this._specialColors[t],color:r}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;let t=[],i=e.split(";");for(let s=0;s<i.length;++s)if(/^\d+$/.exec(i[s])){let r=parseInt(i[s],10);nr(r)&&t.push({type:2,index:r})}return t.length&&this._onColor.fire(t),!0}restoreFgColor(e){return this._onColor.fire([{type:2,index:256}]),!0}restoreBgColor(e){return this._onColor.fire([{type:2,index:257}]),!0}restoreCursorColor(e){return this._onColor.fire([{type:2,index:258}]),!0}nextLine(){return this._activeBuffer.x=0,this.index(),!0}keypadApplicationMode(){return this._logService.debug("Serial port requested application keypad."),this._coreService.decPrivateModes.applicationKeypad=!0,this._onRequestSyncScrollBar.fire(),!0}keypadNumericMode(){return this._logService.debug("Switching back to normal keypad."),this._coreService.decPrivateModes.applicationKeypad=!1,this._onRequestSyncScrollBar.fire(),!0}selectDefaultCharset(){return this._charsetService.setgLevel(0),this._charsetService.setgCharset(0,ze),!0}selectCharset(e){return e.length!==2?(this.selectDefaultCharset(),!0):(e[0]==="/"||this._charsetService.setgCharset(za[e[0]],G[e[1]]??ze),!0)}index(){return this._restrictCursor(),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){let e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=j.clone(),this._eraseAttrDataInternal=j.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=this._curAttrData.bg&67108863,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){let e=new ge;e.content=1<<22|69,e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t<this._bufferService.rows;++t){let i=this._activeBuffer.ybase+this._activeBuffer.y+t,s=this._activeBuffer.lines.get(i);s&&(s.fill(e),s.isWrapped=!1)}return this._dirtyRowTracker.markAllDirty(),this._setCursor(0,0),!0}requestStatusString(e,t){let i=o=>(this._coreService.triggerDataEvent(`\x1B${o}\x1B\\`),!0),s=this._bufferService.buffer,r=this._optionsService.rawOptions;return i(e==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:e==='"p'?'P1$r61;1"p':e==="r"?`P1$r${s.scrollTop+1};${s.scrollBottom+1}r`:e==="m"?"P1$r0m":e===" q"?`P1$r${{block:2,underline:4,bar:6}[r.cursorStyle]-(r.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,i=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(i){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t;break}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`\x1B[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;return s.length>=16&&s.shift(),s.push(i.flags),i.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=Math.max(1,e.params[0]||1),i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;for(let r=0;r<t&&s.length>0;r++)i.flags=s.pop();return s.length===0&&t>0&&(i.flags=0),!0}},zi=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){e<this.start?this.start=e:e>this.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(rr=e,e=t,t=rr),e<this.start&&(this.start=e),t>this.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};zi=F([g(0,ne)],zi);function nr(e){return 0<=e&&e<256}var $a=class extends L{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._innerWriteTimer=this._register(new Kt),this._onWriteParsed=this._register(new y),this.onWriteParsed=this._onWriteParsed.event,this._register(O(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed||this._isSyncWriting)return;this._isSyncWriting=!0;let e,t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);let i=this._callbacks.shift();i&&i()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(t!==void 0&&this._syncCalls>t){this._syncCalls=0;return}if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;this._isSyncWriting=!0;let i;for(;i=this._writeBuffer.shift();){this._action(i);let s=this._callbacks.shift();s&&s()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput){this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),this._innerWrite();return}this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;let i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){let s=this._writeBuffer[this._bufferOffset],r=this._action(s,t);if(r){let o=h=>{this._store.isDisposed||(performance.now()-i>=12?this._scheduleInnerWrite(0,h):this._innerWrite(i,h))};r.catch(h=>(queueMicrotask(()=>{throw h}),Promise.resolve(!1))).then(o);return}let n=this._callbacks[this._bufferOffset];if(n&&n(),this._bufferOffset++,this._pendingData-=s.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}},Ki=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){let t=this._bufferService.buffer;if(e.id===void 0){let h=t.addMarker(t.ybase+t.y),l={data:e,id:this._nextId++,lines:[h]};return h.onDispose(()=>this._removeMarkerFromLink(l,h)),this._dataByLinkId.set(l.id,l),l.id}let i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;let n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose(()=>this._removeMarkerFromLink(o,n)),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){let i=this._dataByLinkId.get(e);if(i&&i.lines.every(s=>s.line!==t)){let s=this._bufferService.buffer.addMarker(t);i.lines.push(s),s.onDispose(()=>this._removeMarkerFromLink(i,s))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){let i=e.lines.indexOf(t);i!==-1&&(e.lines.splice(i,1),e.lines.length===0&&(e.data.id!==void 0&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};Ki=F([g(0,ne)],Ki);var or=!1,Ua=class extends L{constructor(e){super(),this._windowsWrappingHeuristics=this._register(new le),this._onBinary=this._register(new y),this.onBinary=this._onBinary.event,this._onData=this._register(new y),this.onData=this._onData.event,this._onLineFeed=this._register(new y),this.onLineFeed=this._onLineFeed.event,this._onRender=this._register(new y),this.onRender=this._onRender.event,this._onResize=this._register(new y),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new y),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new y),this._instantiationService=new _a,this.optionsService=this._register(new Ca(e)),this._instantiationService.setService(oe,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(Ni)),this._instantiationService.setService(Je,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(Hi)),this._instantiationService.setService(ne,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(Fi)),this._instantiationService.setService(Ee,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(xa)),this._instantiationService.setService(Ft,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(Ke)),this.unicodeService.register(new Da),this._instantiationService.setService(jn,this.unicodeService),this._charsetService=this._instantiationService.createInstance(Ma),this._instantiationService.setService(Xn,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(Ki),this._instantiationService.setService(pr,this._oscLinkService),this._inputHandler=this._register(new Ka(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(he.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(he.forward(this._bufferService.onResize,this._onResize)),this._register(he.forward(this.coreService.onData,this._onData)),this._register(he.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new $a((t,i)=>this._inputHandler.parse(t,i))),this._register(he.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new y),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(let t in e)this.optionsService.options[t]=e[t]}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=3&&!or&&(this._logService.warn("writeSync is unreliable and will be removed soon."),or=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1,t=this.optionsService.rawOptions.windowsPty;t&&t.backend!==void 0&&t.buildNumber!==void 0&&(e=t.backend==="conpty"&&t.buildNumber<21376),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){let e=[];e.push(this.onLineFeed(er.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>(er(this._bufferService),!1))),this._windowsWrappingHeuristics.value=O(()=>{for(let t of e)t.dispose()})}}},K=0,qa=class{constructor(e,t){this._getKey=e,this._array=[],this._insertedValues=[],this._isFlushingInserted=!1,this._deletedIndices=[],this._isFlushingDeleted=!1,this._flushInsertedTask=new Nt(t),this._flushDeletedTask=new Nt(t)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),this._insertedValues.length===0&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(e)}_flushInserted(){let e=this._insertedValues.sort((r,n)=>this._getKey(r)-this._getKey(n)),t=0,i=0,s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r<s.length;r++)i>=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),this._array.length===0)return!1;let t=this._getKey(e);if(t===void 0||(K=this._search(t),K===-1)||this._getKey(this._array[K])!==t)return!1;do if(this._array[K]===e)return this._deletedIndices.length===0&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(K),!0;while(++K<this._array.length&&this._getKey(this._array[K])===t);return!1}_flushDeleted(){this._isFlushingDeleted=!0;let e=this._deletedIndices.sort((r,n)=>r-n),t=0,i=new Array(this._array.length-e.length),s=0;for(let r=0;r<this._array.length;r++)e[t]===r?t++:i[s++]=this._array[r];this._array=i,this._deletedIndices.length=0,this._isFlushingDeleted=!1}_flushCleanupDeleted(){!this._isFlushingDeleted&&this._deletedIndices.length>0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(K=this._search(e),!(K<0||K>=this._array.length)&&this._getKey(this._array[K])===e))do yield this._array[K];while(++K<this._array.length&&this._getKey(this._array[K])===e)}forEachByKey(e,t){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(K=this._search(e),!(K<0||K>=this._array.length)&&this._getKey(this._array[K])===e))do t(this._array[K]);while(++K<this._array.length&&this._getKey(this._array[K])===e)}values(){return this._flushCleanupInserted(),this._flushCleanupDeleted(),[...this._array].values()}_search(e){let t=0,i=this._array.length-1;for(;i>=t;){let s=t+i>>1,r=this._getKey(this._array[s]);if(r>e)i=s-1;else if(r<e)t=s+1;else{for(;s>0&&this._getKey(this._array[s-1])===e;)s--;return s}}return t}},je=0,Mt=0,$i=class extends L{constructor(e,t){super(),this._logService=e,this._bufferService=t,this._lineCache=this._register(new Va),this._onDecorationRegistered=this._register(new y),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new y),this.onDecorationRemoved=this._onDecorationRemoved.event,this._decorations=new qa(i=>i?.marker.line,this._logService),this._register(O(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}get decorations(){return this._decorations.values()}registerDecoration(e){if(e.marker.isDisposed)return;let t=new Ya(e);if(t){let i=t.marker.onDispose(()=>t.dispose()),s=t.onDispose(()=>{s.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),i.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(let e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,i){let s=this._lineCache.getDecorationsOnLine(t);if(s)for(let r of s)je=r.options.x??0,Mt=je+(r.options.width??1),e>=je&&e<Mt&&(!i||(r.options.layer??"bottom")===i)&&(yield r)}forEachDecorationAtCell(e,t,i,s){let r=this._lineCache.getDecorationsOnLine(t);if(r)for(let n of r)je=n.options.x??0,Mt=je+(n.options.width??1),e>=je&&e<Mt&&(!i||(n.options.layer??"bottom")===i)&&s(n)}};$i=F([g(0,Je),g(1,ne)],$i);var Va=class extends L{constructor(){super(...arguments),this._decorationsByLine=new Map,this._decorations=new Set,this._bufferLineListeners=this._register(new le),this._lineIndexSyncTimer=this._register(new eo),this._lineIndexSyncCallbacks=[]}clear(){this._lineIndexSyncCallbacks.length=0,this._lineIndexSyncTimer.cancel(),this._decorationsByLine.clear(),this._decorations.clear()}add(e){this._decorations.add(e),this._addToLineBuckets(e)}remove(e){this._decorations.delete(e),this._removeFromLineBuckets(e)}getDecorationsOnLine(e){return this._decorationsByLine.get(e)}attachToBufferLines(e){let t=new Qe;this._bufferLineListeners.value=t,t.add(e.onTrim(i=>this._handleBufferLinesTrim(i))),t.add(e.onInsert(i=>this._handleBufferLinesInsert(i))),t.add(e.onDelete(i=>this._handleBufferLinesDelete(i)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){let t=e.marker.line;if(t<0)return;e._indexedStartLine=t;let i=this._getDecorationHeight(e);for(let s=t;s<t+i;s++){let r=this._decorationsByLine.get(s);r||(r=[],this._decorationsByLine.set(s,r)),r.push(e)}}_removeFromLineBuckets(e){let t=e._indexedStartLine,i=this._getDecorationHeight(e);for(let s=t;s<t+i;s++){let r=this._decorationsByLine.get(s);if(!r)continue;let n=r.indexOf(e);n!==-1&&r.splice(n,1),r.length===0&&this._decorationsByLine.delete(s)}}_reindexDecoration(e){this._removeFromLineBuckets(e),!e.marker.isDisposed&&e.marker.line>=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{let t=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(let i of t)i()})}_handleBufferLinesTrim(e){if(e<=0)return;let t=new Map;for(let[i,s]of this._decorationsByLine){let r=i-e;r<0||this._mergeLineBucket(t,r,s)}this._decorationsByLine.clear();for(let[i,s]of t)this._decorationsByLine.set(i,s);for(let i of this._decorations)i.marker.isDisposed||(i._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,i){let s=e.get(t);if(s)for(let r=0,n=i.length;r<n;r++)s.push(i[r]);else e.set(t,i.slice())}_applyBufferLinesInsert(e){let{index:t,amount:i}=e,s=[];for(let n of this._decorations){if(n.marker.isDisposed)continue;let o=n._indexedStartLine;o<t&&o+this._getDecorationHeight(n)>t&&(s.push(n),this._removeFromLineBuckets(n))}let r=new Map;for(let[n,o]of this._decorationsByLine){let h=n>=t?n+i:n;this._mergeLineBucket(r,h,o)}this._decorationsByLine.clear();for(let[n,o]of r)this._decorationsByLine.set(n,o);for(let n of this._decorations)n.marker.isDisposed||n._indexedStartLine>=t&&(n._indexedStartLine=n.marker.line);for(let n of s)this._addToLineBuckets(n)}_applyBufferLinesDelete(e){let t=e.index+e.amount,i=new Map;for(let[r,n]of this._decorationsByLine){if(r>=e.index&&r<t)continue;let o=r>=t?r-e.amount:r;this._mergeLineBucket(i,o,n)}this._decorationsByLine.clear();for(let[r,n]of i)this._decorationsByLine.set(r,n);let s=[];for(let r of this._decorations){if(r.marker.isDisposed)continue;let n=r._indexedStartLine,o=this._getDecorationHeight(r);n>=t?r._indexedStartLine=r.marker.line:n<e.index&&n+o>t&&s.push(r)}for(let r of s)this._reindexDecoration(r)}},Ya=class extends Qe{constructor(e){super(),this.options=e,this.onRenderEmitter=this.add(new y),this.onRender=this.onRenderEmitter.event,this._onDispose=this.add(new y),this.onDispose=this._onDispose.event,this._cachedBg=null,this._cachedFg=null,this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}get backgroundColorRGB(){return this._cachedBg===null&&(this.options.backgroundColor?this._cachedBg=W.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return this._cachedFg===null&&(this.options.foregroundColor?this._cachedFg=W.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}dispose(){this._onDispose.fire(),super.dispose()}},Xa=1e3,ja=class{constructor(e,t=Xa){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,e):e,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,t):t;let s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._refreshTimeoutID!==void 0&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){let r=s-this._lastRefreshMs,n=this._debounceThresholdMS-r;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},n)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;let e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}},ar=!1,Ht=class extends L{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="";let r=this._coreBrowserService.mainDocument;this._accessibilityContainer=r.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=r.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let n=0;n<this._terminal.rows;n++)this._rowElements[n]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[n]);if(this._topBoundaryFocusListener=n=>this._handleBoundaryFocus(n,0),this._bottomBoundaryFocusListener=n=>this._handleBoundaryFocus(n,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new ja(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");ar?(this._accessibilityContainer.classList.add("debug"),this._rowContainer.classList.add("debug"),this._debugRootContainer=r.createElement("div"),this._debugRootContainer.classList.add("xterm"),this._debugRootContainer.appendChild(r.createTextNode("------start a11y------")),this._debugRootContainer.appendChild(this._accessibilityContainer),this._debugRootContainer.appendChild(r.createTextNode("------end a11y------")),this._terminal.element.insertAdjacentElement("afterend",this._debugRootContainer)):this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(n=>this._handleResize(n.rows))),this._register(this._terminal.onRender(n=>this._refreshRows(n.start,n.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(n=>this._handleChar(n))),this._register(this._terminal.onLineFeed(()=>this._handleChar(` +`))),this._register(this._terminal.onA11yTab(n=>this._handleTab(n))),this._register(this._terminal.onKey(n=>this._handleKey(n.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register(D(r,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register(O(()=>{ar?this._debugRootContainer.remove():this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,e===` +`&&(this._liveRegionLineCount++,this._liveRegionLineCount===21&&(this._liveRegion.textContent=Tt.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),new RegExp("\\p{Control}","u").test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){let i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){let n=i.lines.get(i.ydisp+r),o=[],h=n?.translateToString(!0,void 0,void 0,o)||"",l=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(h.length===0?(a.textContent="\xA0",this._rowColumns.set(a,[0,1])):(a.textContent=h,this._rowColumns.set(a,o)),a.setAttribute("aria-posinset",l),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent===Tt.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){let i=e.target,s=this._rowElements[t===0?1:this._rowElements.length-2],r=i.getAttribute("aria-posinset"),n=t===0?"1":`${this._terminal.buffer.lines.length}`;if(r===n||e.relatedTarget!==s)return;let o,h;if(t===0?(o=i,h=this._rowElements.pop(),this._rowContainer.removeChild(h)):(o=this._rowElements.shift(),h=i,this._rowContainer.removeChild(o)),o.removeEventListener("focus",this._topBoundaryFocusListener),h.removeEventListener("focus",this._bottomBoundaryFocusListener),t===0){let l=this._createAccessibilityTreeNode();this._rowElements.unshift(l),this._rowContainer.insertAdjacentElement("afterbegin",l)}else{let l=this._createAccessibilityTreeNode();this._rowElements.push(l),this._rowContainer.appendChild(l)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(t===0?-1:1),this._rowElements[t===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;let e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed){this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection();return}if(!e.anchorNode||!e.focusNode){console.error("anchorNode and/or focusNode are null");return}let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;let s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;let r=({node:h,offset:l})=>{let a=h instanceof Text?h.parentNode:h,c=parseInt(a?.getAttribute("aria-posinset"),10)-1;if(isNaN(c))return console.warn("row is invalid. Race condition?"),null;let d=this._rowColumns.get(a);if(!d)return console.warn("columns is null. Race condition?"),null;let u=l<d.length?d[l]:d.slice(-1)[0]+1;return u>=this._terminal.cols&&(++c,u=0),{row:c,column:u}},n=r(t),o=r(i);if(!(!n||!o)){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let t=this._rowContainer.children.length;t<this._terminal.rows;t++)this._rowElements[t]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[t]);for(;this._rowElements.length>e;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){let e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e<this._terminal.rows;e++)this._refreshRowDimensions(this._rowElements[e]),this._alignRowWidth(this._rowElements[e])}}_refreshRowDimensions(e){e.style.height=`${this._renderService.dimensions.css.cell.height}px`}_alignRowWidth(e){e.style.transform="";let t=e.getBoundingClientRect().width,i=this._rowColumns.get(e)?.slice(-1)?.[0];if(!i)return;let s=i*this._renderService.dimensions.css.cell.width;e.style.transform=`scaleX(${s/t})`}};Ht=F([g(1,qi),g(2,be),g(3,ye)],Ht);var Ui=class extends L{constructor(e,t,i,s,r){super(),this._element=e,this._mouseCoordsService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new y),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new y),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register(O(()=>{ut(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register(D(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register(D(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register(D(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register(D(this._element,"mouseup",this._handleMouseUp.bind(this)))}get currentLink(){return this._currentLink}_handleMouseMove(e){this._lastMouseEvent=e;let t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;let i=e.composedPath();for(let s=0;s<i.length;s++){let r=i[s];if(r.classList.contains("xterm"))break;if(r.classList.contains("xterm-hover"))return}(!this._lastBufferCell||t.x!==this._lastBufferCell.x||t.y!==this._lastBufferCell.y)&&(this._handleHover(t),this._lastBufferCell=t)}_handleHover(e){if(this._activeLine!==e.y||this._wasResized){this._clearCurrentLink(),this._askForLink(e,!1),this._wasResized=!1;return}this._currentLink&&this._linkAtPosition(this._currentLink.link,e)||(this._clearCurrentLink(),this._askForLink(e,!0))}_askForLink(e,t){(!this._activeProviderReplies||!t)&&(this._activeProviderReplies?.forEach(s=>{s?.forEach(r=>{r.link.dispose&&r.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(let[s,r]of this._linkProviderService.linkProviders.entries())t?this._activeProviderReplies?.get(s)&&(i=this._checkLinkProviderResult(s,e,i)):r.provideLinks(e.y,n=>{if(this._isMouseOut)return;let o=n?.map(h=>({link:h}));this._activeProviderReplies?.set(s,o),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){let i=new Set;for(let s=0;s<t.size;s++){let r=t.get(s);if(r)for(let n=0;n<r.length;n++){let o=r[n],h=o.link.range.start.y<e?0:o.link.range.start.x,l=o.link.range.end.y>e?this._bufferService.cols:o.link.range.end.x;for(let a=h;a<=l;a++){if(i.has(a)){r.splice(n--,1);break}i.add(a)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;let s=this._activeProviderReplies.get(e),r=!1;for(let n=0;n<e;n++)(!this._activeProviderReplies.has(n)||this._activeProviderReplies.get(n))&&(r=!0);if(!r&&s){let n=s.find(o=>this._linkAtPosition(o.link,t));n&&(i=!0,this._handleNewLink(n))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let n=0;n<this._activeProviderReplies.size;n++){let o=this._activeProviderReplies.get(n)?.find(h=>this._linkAtPosition(h.link,t));if(o){i=!0,this._handleNewLink(o);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;let t=this._positionFromMouseEvent(e,this._element);t&&this._mouseDownLink&&Ga(this._mouseDownLink.link,this._currentLink.link)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){!this._currentLink||!this._lastMouseEvent||(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,ut(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;let t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:e.link.decorations===void 0?!0:e.link.decorations.underline,pointerCursor:e.link.decorations===void 0?!0:e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:i=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==i&&(this._currentLink.state.decorations.pointerCursor=i,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",i))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:i=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==i&&(this._currentLink.state.decorations.underline=i,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,i))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(i=>{if(!this._currentLink)return;let s=i.start===0?0:i.start+1+this._bufferService.buffer.ydisp,r=this._bufferService.buffer.ydisp+1+i.end;if(this._currentLink.link.range.start.y>=s&&this._currentLink.link.range.end.y<=r&&(this._clearCurrentLink(s,r),this._lastMouseEvent)){let n=this._positionFromMouseEvent(this._lastMouseEvent,this._element);n&&this._askForLink(n,!1)}})))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){let i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){let i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t){let i=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(i)return{x:i[0],y:i[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};Ui=F([g(1,zt),g(2,ye),g(3,ne),g(4,mr)],Ui);function Ga(e,t){return e.text===t.text&&e.range.start.x===t.range.start.x&&e.range.start.y===t.range.start.y&&e.range.end.x===t.range.end.x&&e.range.end.y===t.range.end.y}var Ja=class extends Ua{constructor(e={}){super(e),this._linkifier=this._register(new le),this.browser=br,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this._register(new le),this._onCursorMove=this._register(new y),this.onCursorMove=this._onCursorMove.event,this._onKey=this._register(new y),this.onKey=this._onKey.event,this._onSelectionChange=this._register(new y),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this._register(new y),this.onTitleChange=this._onTitleChange.event,this._onBell=this._register(new y),this.onBell=this._onBell.event,this._onFocus=this._register(new y),this._onBlur=this._register(new y),this._onA11yCharEmitter=this._register(new y),this._onA11yTabEmitter=this._register(new y),this._onWillOpen=this._register(new y),this._onDimensionsChange=this._register(new y),this.onDimensionsChange=this._onDimensionsChange.event,this._setup(),this._decorationService=this._instantiationService.createInstance($i),this._instantiationService.setService(mt,this._decorationService),this._keyboardService=this._instantiationService.createInstance(Ii),this._instantiationService.setService(Zn,this._keyboardService),this._linkProviderService=this._instantiationService.createInstance(zo),this._instantiationService.setService(mr,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(Si)),this._register(this._inputHandler.onRequestBell(()=>this._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(t=>this._reportWindowsOptions(t))),this._register(this._inputHandler.onColor(t=>this._handleColorEvent(t))),this._register(he.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(he.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(he.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(he.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(t=>this._afterResize(t.cols,t.rows))),this._register(O(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}get dimensions(){if(!this._renderService)return;let e=this._renderService.dimensions;return{css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}}_handleColorEvent(e){if(this._themeService)for(let t of e){let i,s;switch(t.index){case 256:i="foreground",s="10";break;case 257:i="background",s="11";break;case 258:i="cursor",s="12";break;default:i="ansi",s="4;"+t.index}switch(t.type){case 0:let r=H.toColorRGB(i==="ansi"?this._themeService.colors.ansi[t.index]:this._themeService.colors[i]);this.coreService.triggerDataEvent(`\x1B]${s};${Fa(r)}\x1B\\`);break;case 1:if(i==="ansi")this._themeService.modifyColors(n=>n.ansi[t.index]=$.toColor(...t.color));else{let n=i;this._themeService.modifyColors(o=>o[n]=$.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index);break}}}_reportColorScheme(){if(!this._themeService)return;let e=ie.relativeLuminance(this._themeService.colors.background.rgba>>8),t=ie.relativeLuminance(this._themeService.colors.foreground.rgba>>8),i=e<t?1:2;this.coreService.triggerDataEvent(`\x1B[?997;${i}n`)}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(Ht,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent("\x1B[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent("\x1B[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;let e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;let i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,h=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=h+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register(D(this.element,"copy",t=>{this.hasSelection()&&Kn(t,this._selectionService)}));let e=t=>$n(t,this.textarea,this.coreService,this.optionsService);this._register(D(this.textarea,"paste",e)),this._register(D(this.element,"paste",e)),At?this._register(D(this.element,"mousedown",t=>{t.button===2&&Ms(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register(D(this.element,"contextmenu",t=>{Ms(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),Ji&&this._register(D(this.element,"auxclick",t=>{t.button===1&&dr(t,this.textarea,this.screenElement)}))}_bindKeys(){this._register(D(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register(D(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register(D(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register(D(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register(D(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register(D(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register(D(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService){this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView);return}this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",o=>this.element.classList.toggle("allow-transparency",o))),e.appendChild(this.element);let t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register(D(this.screenElement,"mousemove",o=>this.updateCursorStyle(o))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);let i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",mi.get()),Cr||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>i.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(Fo,this.textarea,e.ownerDocument.defaultView??window,this._document??(typeof window<"u"?window.document:null))),this._instantiationService.setService(be,this._coreBrowserService),this._register(D(this.textarea,"focus",o=>this._handleTextAreaFocus(o))),this._register(D(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(Di,this._document,this._helperContainer),this._instantiationService.setService(Wt,this._charSizeService),this._themeService=this._instantiationService.createInstance(Oi),this._instantiationService.setService(Ze,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(It),this._instantiationService.setService(vr,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(Pi,this.rows,this.screenElement)),this._instantiationService.setService(ye,this._renderService),this._register(this._renderService.onRenderedViewportChange(o=>this._onRender.fire(o))),this._register(this._renderService.onDimensionsChange(o=>this._onDimensionsChange.fire({css:{canvas:{...o.css.canvas},cell:{...o.css.cell}},device:{canvas:{...o.device.canvas},cell:{...o.device.cell},char:{...o.device.char}}}))),this.onResize(o=>this._renderService.resize(o.cols,o.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(xi,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(Mi),this._instantiationService.setService(zt,this._mouseCoordsService);let s=this._linkifier.value=this._register(this._instantiationService.createInstance(Ui,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(o){this._logService.error("onWillOpen handler threw an exception",o)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(Ci,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(o=>{super.scrollLines(o,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(Ai,this.element,this.screenElement,s)),this._instantiationService.setService(gr,this._selectionService),this._mouseService=this._instantiationService.createInstance(Ti),this._instantiationService.setService(Jn,this._mouseService),this._register(this._selectionService.onRequestScrollLines(o=>this.scrollLines(o.amount,o.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(o=>this._renderService.handleSelectionChanged(o.start,o.end,o.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(o=>{this.textarea.value=o,this.textarea.focus(),this.textarea.select()})),this._register(he.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(ki,this.screenElement)),this._register(D(this.element,"mousedown",o=>this._selectionService.handleMouseDown(o))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(Ht,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",o=>this._handleScreenReaderModeOptionChange(o)));let r=this.options.scrollbar?.showScrollbar??!0,n=this.options.scrollbar?.width;r&&n&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(Ot,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",o=>{let h=(o?.showScrollbar??!0)&&!!o?.width;!this._overviewRulerRenderer&&h&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(Ot,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:o=>this._viewport?.handleTouchScroll(o)},o=>this._register(o),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(Ei,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,i=!1){this._renderService?.refreshRows(e,t,i)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}paste(e){cr(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");let t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return this._selectionService?this._selectionService.hasSelection:!1}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(!(!this._selectionService||!this._selectionService.hasSelection))return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;let t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;!t&&(e.key==="Dead"||e.key==="AltGraph")&&(this._unprocessedDeadKey=!0);let i=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),i.type===3||i.type===2){let r=this.rows-1;return this.scrollLines(i.type===2?-r:r),e.preventDefault(),e.stopPropagation(),!1}if(i.type===1&&this.selectAll(),this._isThirdLevelShift(this.browser,e)||(i.cancel&&(e.preventDefault(),e.stopPropagation()),!i.key)||!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.key.length===1&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;(i.key===""||i.key==="\r")&&(this.textarea.value="");let s=this._keyboardService.useWin32InputMode&&vi(e);if(this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){let i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return t.type==="keypress"?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return;vi(e)||this.focus();let t=this._keyboardService.evaluateKeyUp(e);if(t?.key){let i=this._keyboardService.useWin32InputMode&&vi(e);this.coreService.triggerDataEvent(t.key,!i)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled||this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;if(e.charCode)t=e.charCode;else if(e.which===null||e.which===void 0)t=e.keyCode;else if(e.which!==0&&e.charCode!==0)t=e.which;else return!1;return!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)?!1:(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,!0)}_inputEvent(e){if(e.data&&e.inputType==="insertText"&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;let t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){if(e===this.cols&&t===this.rows){this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure();return}super.resize(e,t)}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e<this.rows;e++)this.buffer.lines.push(this.buffer.getBlankLine(j));this._onScroll.fire({position:this.buffer.ydisp}),this.refresh(0,this.rows-1)}reset(){this.options.rows=this.rows,this.options.cols=this.cols;let e=this._customKeyEventHandler;this._setup(),super.reset(),this._mouseService?.reset(),this._selectionService?.reset(),this._decorationService.reset(),this._customKeyEventHandler=e,this.refresh(0,this.rows-1,!0)}clearTextureAtlas(){this._renderService?.clearTextureAtlas()}_reportFocus(){this.element?.classList.contains("focus")?this.coreService.triggerDataEvent("\x1B[I"):this.coreService.triggerDataEvent("\x1B[O")}_reportWindowsOptions(e){if(this._renderService)switch(e){case 0:let t=this._renderService.dimensions.css.canvas.width.toFixed(0),i=this._renderService.dimensions.css.canvas.height.toFixed(0);this.coreService.triggerDataEvent(`\x1B[4;${i};${t}t`);break;case 1:let s=this._renderService.dimensions.css.cell.width.toFixed(0),r=this._renderService.dimensions.css.cell.height.toFixed(0);this.coreService.triggerDataEvent(`\x1B[6;${r};${s}t`);break}}};function vi(e){return e.keyCode===16||e.keyCode===17||e.keyCode===18||e.keyCode===91||e.keyCode===92||e.keyCode===93||e.keyCode===224||e.key==="Meta"}var Za=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){let i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i<this._addons.length;i++)if(this._addons[i]===e){t=i;break}if(t===-1)throw new Error("Could not dispose an addon that has not been loaded");e.isDisposed=!0,e.dispose.apply(e.instance),this._addons.splice(t,1)}},Qa=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new ge)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}},hr=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){let t=this._buffer.lines.get(e);if(t)return new Qa(t)}getNullCell(){return new ge}},eh=class extends L{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new y),this.onBufferChange=this._onBufferChange.event,this._normal=new hr(this._core.buffers.normal,"normal"),this._alternate=new hr(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}},th=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,i=>t(i.toArray()))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,(i,s)=>t(i,s.toArray()))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}registerApcHandler(e,t){return this._core.registerApcHandler(e,t)}},ih=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}},sh=["cols","rows"],Se=0,Ur=class extends L{constructor(e){super(),this._core=this._register(new Ja(e)),this._addonManager=this._register(new Za),this._publicOptions={...this._core.options};let t=s=>this._core.options[s],i=(s,r)=>{this._checkReadonlyOptions(s),this._core.options[s]=r};for(let s in this._core.options){let r={get:t.bind(this,s),set:i.bind(this,s)};Object.defineProperty(this._publicOptions,s,r)}}_checkReadonlyOptions(e){if(sh.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new th(this._core)}get unicode(){return this._checkProposedApi(),new ih(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new eh(this._core))}get markers(){return this._core.markers}get modes(){let e=this._core.coreService.decPrivateModes,t="none";switch(this._core.mouseStateService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any";break}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:e.synchronizedOutput,win32InputMode:e.win32InputMode,wraparoundMode:e.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(e){for(let t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write(`\r +`,t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return mi.get()},set promptLabel(e){mi.set(e)},get tooMuchOutput(){return Tt.get()},set tooMuchOutput(e){Tt.set(e)}}}_verifyIntegers(...e){for(Se of e)if(Se===1/0||isNaN(Se)||Se%1!==0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(Se of e)if(Se&&(Se===1/0||isNaN(Se)||Se%1!==0||Se<0))throw new Error("This API only accepts positive integers")}};var rh={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"};function qr(e,t,i){switch(e){case"Escape":return"\x1B";case"Tab":return" ";case"ArrowUp":case"ArrowDown":case"ArrowRight":case"ArrowLeft":case"Home":case"End":{let s=rh[e];return i?`\x1B[1;5${s}`:t.applicationCursorKeys?`\x1BO${s}`:`\x1B[${s}`}case"-":case"/":case"|":return i?nh(e):e}}function nh(e){if(e.length!==1)return e;let t=e.charCodeAt(0);if(t>=97&&t<=122||t>=65&&t<=90)return String.fromCharCode(t&31);switch(e){case"@":case" ":return"\0";case"[":return"\x1B";case"\\":return"";case"]":return"";case"^":return"";case"_":case"/":case"-":return"";case"?":return"\x7F";default:return e}}function oh(e){let t="";for(let i=0;i<e.length;i+=1)t+=String.fromCharCode(e[i]??0);return btoa(t)}function Vr(e){let t=atob(e),i=new Uint8Array(t.length);for(let s=0;s<t.length;s+=1)i[s]=t.charCodeAt(s);return i}function ts(e){let t=new TextEncoder().encode(e),i=[];for(let s=0;s<t.byteLength;s+=65536)i.push(oh(t.subarray(s,Math.min(s+65536,t.byteLength))));return i}function Yr(){return{suppressedWriteCount:0}}function Xr({terminal:e,data:t,isReplay:i,replayWriteState:s}){if(!i){e.write(t);return}s.suppressedWriteCount+=1,e.write(t,()=>{s.suppressedWriteCount-=1})}function qt(e){return e.suppressedWriteCount<=0}var Nc=16*1024;var ah='"JetBrainsMono Nerd Font Mono", "MesloLGS NF", "Symbols Nerd Font Mono", ui-monospace, Menlo, Monaco, "Courier New", monospace',hh=500,lh=40,ch=700,dh=10;function De(e){let t=window.ReactNativeWebView;t?t.postMessage(JSON.stringify(e)):e.type!=="text-mirror"&&console.log("[terminal-page]",e)}function is(e){let t=document.getElementById("error");t&&(t.textContent=e,t.style.display="block"),De({type:"error",message:e})}function jr(e,t,i){e.options.theme=t,e.options.fontSize=i,document.documentElement.style.setProperty("--terminal-background",t.background)}function _h(e){let t=e.buffer.active,i=t.baseY+e.rows,s=[];for(let r=t.baseY;r<i;r+=1)s.push(t.getLine(r)?.translateToString(!0)??"");for(;s.length>0&&s[s.length-1]==="";)s.pop();return s.slice(-lh)}function uh(){let e=document.getElementById("terminal");if(!e){is("terminal container missing");return}let t=new Ur({allowProposedApi:!0,convertEol:!0,cursorBlink:!0,fontFamily:ah,fontSize:12,scrollback:1e4}),i=new rs;t.loadAddon(i),t.loadAddon(new xs),t.unicode.activeVersion="11",t.loadAddon(new Bs((u,f)=>{u.preventDefault(),De({type:"link",url:f})})),t.open(e);let s=Yr(),r={cols:0,rows:0},n=null,o=null,h="",l=()=>{let{width:u,height:f}=e.getBoundingClientRect();u<=0||f<=0||(i.fit(),(t.cols!==r.cols||t.rows!==r.rows)&&(r={cols:t.cols,rows:t.rows},De({type:"resize",cols:t.cols,rows:t.rows})))},a=()=>{n===null&&(n=window.requestAnimationFrame(()=>{n=null,l()}))};t.onData(u=>{if(qt(s))for(let f of ts(u))De({type:"data",dataBase64:f})}),t.onBinary(u=>{if(!qt(s))return;let f=new Uint8Array(u.length);for(let p=0;p<u.length;p+=1)f[p]=u.charCodeAt(p)&255;let _="";for(let p=0;p<f.length;p+=1)_+=String.fromCharCode(f[p]??0);De({type:"data",dataBase64:btoa(_)})}),t.onTitleChange(u=>{qt(s)&&De({type:"title",title:u})});let c=null;e.addEventListener("touchstart",u=>{if(u.touches.length!==1){c=null;return}let f=u.touches[0];f&&(c={x:f.clientX,y:f.clientY,at:Date.now(),moved:!1})},{passive:!0}),e.addEventListener("touchmove",u=>{let f=u.touches[0];!c||!f||Math.hypot(f.clientX-c.x,f.clientY-c.y)>dh&&(c.moved=!0)},{passive:!0}),e.addEventListener("touchend",()=>{let u=c;c=null,!(!u||u.moved||Date.now()-u.at>=ch)&&t.focus()},{passive:!0});let d=u=>{switch(u.type){case"init":jr(t,u.theme,u.fontSize),u.textMirror&&o===null&&(o=window.setInterval(()=>{let f=_h(t),_=f.join(` +`);_!==h&&(h=_,De({type:"text-mirror",lines:f}))},hh)),l();return;case"theme":jr(t,u.theme,u.fontSize),a();return;case"write":for(let f of u.chunks)Xr({terminal:t,data:Vr(f),isReplay:u.replay,replayWriteState:s});return;case"status":t.write(`\r +\x1B[2m${u.text}\x1B[0m\r +`);return;case"reset":t.reset();return;case"resize":a();return;case"focus":t.focus();return;case"blur":t.blur();return;case"key":{let f=qr(u.key,{applicationCursorKeys:t.modes.applicationCursorKeysMode},u.ctrl);for(let _ of ts(f))De({type:"data",dataBase64:_});t.scrollToBottom();return}case"paste":t.paste(u.text),t.scrollToBottom();return}};window.addEventListener("message",u=>{let f;try{f=typeof u.data=="string"?JSON.parse(u.data):u.data}catch{return}if(!(!f||typeof f!="object"||!("type"in f)))try{d(f)}catch(_){is(_ instanceof Error?_.message:String(_))}}),window.__bbTerminal={handle:d},new ResizeObserver(()=>a()).observe(e),window.addEventListener("resize",a),l(),De({type:"ready",cols:t.cols,rows:t.rows})}try{uh()}catch(e){is(e instanceof Error?e.message:String(e))}})(); +</script> +</body> +</html> diff --git a/apps/mobile/e2e/flows/phase1-shell.yaml b/apps/mobile/e2e/flows/phase1-shell.yaml new file mode 100644 index 0000000000..5a132b2a41 --- /dev/null +++ b/apps/mobile/e2e/flows/phase1-shell.yaml @@ -0,0 +1,88 @@ +# Phase 1 shell: first run → real Add-server screen → home (thread list) → +# workspace menu → Settings → Server status shows a live realtime connection against +# the harness backend → Servers list. +# +# Requires Metro started with EXPO_PUBLIC_BB_E2E=1 (the app wipes saved +# profiles/preferences on launch, so every run starts at first run). +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-app.yaml +# First run: no profiles → the home route redirects to Add server. +- extendedWaitUntil: + visible: "Connect to a bb server" + timeout: 30000 +- tapOn: + id: "server-url-input" +- inputText: "${SERVER_URL}" +- tapOn: + id: "server-label-input" +- inputText: "E2E backend" +# Tapping static text dismisses the keyboard; Maestro's hideKeyboard swipe is +# unreliable on this screen since the bb connect row lengthened it. +- tapOn: "Server URL" +- tapOn: + id: "add-server-submit" +# Probe (/health + /system/config) → save → activate → home (the thread list). +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 30000 +- assertVisible: "E2E backend" +- assertNotVisible: + id: "connection-banner" +- takeScreenshot: phase1-home +# Workspace menu (the header's server avatar): the active server with its +# realtime label, the server rows, Add server, Settings. +- tapOn: + id: "home-workspace-menu" +- extendedWaitUntil: + visible: + id: "workspace-settings" + timeout: 10000 +- assertVisible: + id: "workspace-profile-label" +- assertVisible: "Connected" +- assertVisible: + id: "workspace-add-server" +- takeScreenshot: phase1-workspace-menu +- tapOn: + id: "workspace-settings" +- extendedWaitUntil: + visible: + id: "settings-server-status" + timeout: 10000 +# Server status: realtime connected, primary host id from /system/config. +- tapOn: + id: "settings-server-status" +- extendedWaitUntil: + visible: + id: "realtime-state" + text: "connected" + timeout: 20000 +- assertVisible: + id: "server-info-host" +- assertVisible: ".*${SERVER_URL}.*" +# Dev-only poke proves the SDK mutation path (POST /system/config/reload). +- tapOn: + id: "poke-system" +- extendedWaitUntil: + visible: "Config reload requested" + timeout: 15000 +- takeScreenshot: phase1-server-status +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "settings-servers" + timeout: 10000 +- tapOn: + id: "settings-servers" +- extendedWaitUntil: + visible: ".*E2E backend.*" + timeout: 10000 +- takeScreenshot: phase1-servers diff --git a/apps/mobile/e2e/flows/phase3-compose.yaml b/apps/mobile/e2e/flows/phase3-compose.yaml new file mode 100644 index 0000000000..785ecb1ed3 --- /dev/null +++ b/apps/mobile/e2e/flows/phase3-compose.yaml @@ -0,0 +1,190 @@ +# Phase 3 compose: first run → add the harness server → deep link to +# bb://compose (home opens its dock) → pick the seeded project → open the environment picker (screenshot) +# → type a prompt → Create → lands on the thread placeholder with the title → +# deep link to /projects/new → the machine picker lists the harness host and +# the remote path browser lists that machine's folders. +# +# Requires Metro started with EXPO_PUBLIC_BB_E2E=1 (profiles/preferences are +# wiped on launch) and the harness backend (seeds "Mobile E2E Project"). +# Optional: `-e REPO_PARENT_DIR=<dir>` (the folder that contains the harness +# "test-project" checkout, printed by the backend as the parent of the +# project source path) makes the browser step navigate there and assert the +# repo folder is listed. +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +# Cold start: a warm reload keeps the previous deep link as the initial URL, +# which would skip the first-run add-server screen. +- runFlow: ../subflows/launch-app.yaml +# First run: add the Direct server. +- extendedWaitUntil: + visible: "Connect to a bb server" + timeout: 30000 +- tapOn: + id: "server-url-input" +- inputText: "${SERVER_URL}" +- tapOn: + id: "server-label-input" +- inputText: "E2E backend" +# Tapping static text dismisses the keyboard; Maestro's hideKeyboard swipe is +# unreliable on this screen since the bb connect row lengthened it. +- tapOn: "Server URL" +- tapOn: + id: "add-server-submit" +- extendedWaitUntil: + notVisible: "Connect to a bb server" + timeout: 30000 +# Compose via deep link: home opens its bottom dock (expanded: the top +# pill row with the project picker is visible). +- openLink: "bb://compose" +# A fresh simulator confirms the first custom-scheme link. +- runFlow: + when: + visible: "Open" + commands: + - tapOn: "Open" +- extendedWaitUntil: + visible: + id: "home-compose-dock" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "compose-top-controls" + timeout: 15000 +# Project picker → the seeded project. Match the option by id as well as text: +# the same project name is visible in the sidebar behind the sheet. +- tapOn: + id: "project-picker" +- extendedWaitUntil: + visible: + id: "project-picker-option-.*" + text: "(?i)mobile e2e project" + timeout: 15000 +- tapOn: + id: "project-picker-option-.*" + text: "(?i)mobile e2e project" +- extendedWaitUntil: + visible: + id: "project-picker" + text: "(?i).*mobile e2e project.*" + timeout: 15000 +# Environment picker opens as a sheet listing the modes; screenshot it open. +- extendedWaitUntil: + visible: + id: "environment-picker" + timeout: 10000 +- tapOn: + id: "environment-picker" +- extendedWaitUntil: + visible: + id: "environment-picker-option-project-default" + timeout: 15000 +- assertVisible: + id: "environment-picker-option-local" +- assertVisible: + id: "environment-picker-option-worktree" +- takeScreenshot: phase3-compose-environment-picker +- tapOn: + id: "environment-picker-option-project-default" +# Model picker shows the fake provider's catalog once loaded. +# The agent pill row scrolls horizontally; bring the model pill into view. +- swipe: + from: + id: "compose-controls" + direction: LEFT +- extendedWaitUntil: + visible: + id: "model-picker" + timeout: 10000 +- tapOn: + id: "model-picker" +- extendedWaitUntil: + visible: + id: "model-picker-option-.*" + timeout: 30000 +- takeScreenshot: phase3-compose-model-picker +- tapOn: + id: "model-picker-option-.*" +# The model sheet stays open for reasoning/Fast edits; tap the backdrop. +- tapOn: + point: "50%,20%" +- extendedWaitUntil: + notVisible: + id: "model-picker-option-.*" + timeout: 10000 +# Prompt + create. +- tapOn: + id: "compose-input" +- inputText: "hello from mobile" +- takeScreenshot: phase3-compose +- tapOn: + id: "compose-submit" +- extendedWaitUntil: + visible: + id: "thread-detail-screen" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "thread-detail-title" + text: "(?i).*hello from mobile.*" + timeout: 30000 +- takeScreenshot: phase3-compose-created-thread +# New project: machine picker lists the harness host; path browser lists +# that machine's folders. +- openLink: "bb://projects/new" +- runFlow: + when: + visible: "Open" + commands: + - tapOn: "Open" +- extendedWaitUntil: + visible: + id: "new-project-screen" + timeout: 30000 +- tapOn: + id: "new-project-host" +- extendedWaitUntil: + visible: + id: "new-project-host-picker-option-.*" + timeout: 15000 +- takeScreenshot: phase3-new-project-hosts +- tapOn: + id: "new-project-host-picker-option-.*" +- tapOn: + id: "new-project-path" +- extendedWaitUntil: + visible: + id: "new-project-path-sheet-browser" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "new-project-path-sheet-browser-dir-.*" + timeout: 30000 +- runFlow: + when: + true: ${typeof REPO_PARENT_DIR !== "undefined" && REPO_PARENT_DIR !== ""} + commands: + # The sheet resizes as the listing lands; let it settle before tapping. + - waitForAnimationToEnd + - tapOn: + id: "new-project-path-sheet-browser-edit" + - extendedWaitUntil: + visible: + id: "new-project-path-sheet-browser-path-input" + timeout: 10000 + - tapOn: + id: "new-project-path-sheet-browser-path-input" + # The editor opens pre-filled with the current folder. + - eraseText: 200 + - inputText: "${REPO_PARENT_DIR}" + - tapOn: + id: "new-project-path-sheet-browser-path-go" + - extendedWaitUntil: + visible: + id: "new-project-path-sheet-browser-dir-test-project" + timeout: 30000 +- takeScreenshot: phase3-new-project-browser diff --git a/apps/mobile/e2e/flows/phase3-threads.yaml b/apps/mobile/e2e/flows/phase3-threads.yaml new file mode 100644 index 0000000000..086ad3090a --- /dev/null +++ b/apps/mobile/e2e/flows/phase3-threads.yaml @@ -0,0 +1,159 @@ +# Phase 3 threads: first run → add the harness server → home lists the seeded +# project and threads → long-press menu (rename, pin, archive) → Settings → +# Archived (unarchive) → search. +# +# Requires Metro started with EXPO_PUBLIC_BB_E2E=1 (profiles/preferences are +# wiped on launch) and the harness backend (seeds "Mobile E2E Project" with +# "Completed thread" and "Idle thread"). +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-app.yaml +# First run: add the Direct server. +- extendedWaitUntil: + visible: "Connect to a bb server" + timeout: 30000 +- tapOn: + id: "server-url-input" +- inputText: "${SERVER_URL}" +- tapOn: + id: "server-label-input" +- inputText: "E2E backend" +# Tapping static text dismisses the keyboard; Maestro's hideKeyboard swipe is +# unreliable on this screen since the bb connect row lengthened it. +- tapOn: "Server URL" +- tapOn: + id: "add-server-submit" +# Home = the thread list: seeded project header + both threads. +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 30000 +- extendedWaitUntil: + visible: "Mobile E2E Project" + timeout: 30000 +- assertVisible: "Completed thread" +- assertVisible: "Idle thread" +- takeScreenshot: phase3-home +# Long-press → Rename. +- longPressOn: "Idle thread" +- extendedWaitUntil: + visible: + id: "sidebar-action-rename" + timeout: 10000 +- tapOn: + id: "sidebar-action-rename" +- extendedWaitUntil: + visible: + id: "rename-input" + timeout: 10000 +- tapOn: + id: "rename-input" +- eraseText: 40 +- inputText: "Renamed thread" +- tapOn: + id: "rename-submit" +- extendedWaitUntil: + visible: "Renamed thread" + timeout: 15000 +- assertNotVisible: "Idle thread" +# Long-press → Pin → Pinned section appears on top. +- longPressOn: "Renamed thread" +- extendedWaitUntil: + visible: + id: "sidebar-action-pin" + timeout: 10000 +- tapOn: + id: "sidebar-action-pin" +- extendedWaitUntil: + visible: + id: "sidebar-header-pinned" + timeout: 15000 +- assertVisible: "Renamed thread" +- takeScreenshot: phase3-pinned +# Long-press → Archive → row disappears (undo toast shows). +- longPressOn: "Renamed thread" +- extendedWaitUntil: + visible: + id: "sidebar-action-archive" + timeout: 10000 +- tapOn: + id: "sidebar-action-archive" +- extendedWaitUntil: + notVisible: "Renamed thread" + timeout: 15000 +- assertVisible: ".*Archived Renamed thread.*" +# Workspace menu → Settings → Archived. +- runFlow: ../subflows/open-settings.yaml +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +# The Threads section sits below the fold of the settings home. +- scrollUntilVisible: + element: + id: "settings-archived" + direction: DOWN + timeout: 30000 +- tapOn: + id: "settings-archived" +- extendedWaitUntil: + visible: + id: "archived-thread-list" + timeout: 15000 +- extendedWaitUntil: + visible: "Renamed thread" + timeout: 15000 +- takeScreenshot: phase3-archived +# Unarchive from the long-press menu (the trailing button sits under the +# dev-client's floating gear on some simulators); the row leaves the list. +- longPressOn: "Renamed thread" +- extendedWaitUntil: + visible: + id: "sidebar-action-unarchive" + timeout: 10000 +- tapOn: + id: "sidebar-action-unarchive" +- extendedWaitUntil: + notVisible: "Renamed thread" + timeout: 15000 +# Back to home (native header back ×2): the thread is back in the list. +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 15000 +- extendedWaitUntil: + visible: "Renamed thread" + timeout: 15000 +# Search from the home header. +- extendedWaitUntil: + visible: + id: "home-search" + timeout: 10000 +- tapOn: + id: "home-search" +- extendedWaitUntil: + visible: + id: "thread-search-input" + timeout: 10000 +- assertVisible: "Recent" +- tapOn: + id: "thread-search-input" +- inputText: "Compl" +- extendedWaitUntil: + visible: "Completed thread" + timeout: 15000 +- assertNotVisible: "Renamed thread" +- takeScreenshot: phase3-search diff --git a/apps/mobile/e2e/flows/phase4a-conversation-rows.yaml b/apps/mobile/e2e/flows/phase4a-conversation-rows.yaml new file mode 100644 index 0000000000..d7d0b986a8 --- /dev/null +++ b/apps/mobile/e2e/flows/phase4a-conversation-rows.yaml @@ -0,0 +1,101 @@ +# Phase 4a conversation + structure rows: the generated "Forked from …" row +# (header with the source-thread chip, collapsed preview, expand → body), and +# the long-press message actions sheet ("Copy text"), and on the Rich thread +# the authored bubble, the assistant markdown and the "Worked for" turn recap. +# +# Requires Metro with EXPO_PUBLIC_BB_E2E=1 and the harness backend (seeds the +# "Rows thread" started on behalf of "Idle thread"; see +# tests/integration/mobile-e2e/backend.ts). Works on a fresh install and on a +# device that already has the server profile. +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-to-home.yaml +# --- Rows thread: a thread started on behalf of "Idle thread" (fork seed +# anchor) → the generated "Forked from <Idle thread>" header, the collapsed +# one-line preview, and the full body after expanding. +- extendedWaitUntil: + visible: "Rows thread" + timeout: 30000 +- tapOn: "Rows thread" +- extendedWaitUntil: + visible: + id: "thread-timeline" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "conversation-generated-header" + timeout: 30000 +- assertVisible: "(?s).*Forked from.*" +- assertVisible: + id: "conversation-source-thread" +- assertVisible: + id: "conversation-generated-preview" +- assertVisible: "(?s).*Worker finished: all checks pass\\..*" +- assertVisible: "(?s).*Response to: Worker finished.*" +- assertNotVisible: + id: "conversation-generated-body" +- takeScreenshot: phase4a-rows-a +# Long-press the assistant message: the actions sheet offers "Copy text". +# (Done here rather than on the Rich thread: that thread ends with a pending +# question whose banner covers the bottom third of the screen, and rows the +# list pre-renders under it still count as "visible" to Maestro.) +- longPressOn: "(?s).*Response to: Worker finished.*" +- extendedWaitUntil: + visible: "Copy text" + timeout: 10000 +- takeScreenshot: phase4a-rows-d +- tapOn: "Copy text" +- extendedWaitUntil: + visible: "Copied" + timeout: 10000 +- extendedWaitUntil: + notVisible: "Copy text" + timeout: 10000 +# Expand through the preview: the full body appears. +- tapOn: + id: "conversation-generated-preview" +- extendedWaitUntil: + visible: + id: "conversation-generated-body" + timeout: 10000 +- assertVisible: "(?s).*The summary is in the next message\\..*" +- takeScreenshot: phase4a-rows-b +# The source-thread chip opens the sender thread. +- tapOn: + id: "conversation-source-thread" +- extendedWaitUntil: + visible: + id: "thread-detail-title" + timeout: 15000 +- assertVisible: "Idle thread" +- back +- extendedWaitUntil: + visible: "Rows thread" + timeout: 15000 +# --- Rich thread: bubble, assistant markdown, turn recap, long-press copy --- +# Relaunch to land on Home. +- runFlow: ../subflows/launch-to-home.yaml +- extendedWaitUntil: + visible: "Rich thread" + timeout: 30000 +- tapOn: "Rich thread" +- extendedWaitUntil: + visible: + id: "thread-timeline" + timeout: 30000 +# The thread was read before: the list opens at the end, the recap sits above. +- scrollUntilVisible: + element: + id: "timeline-turn-completed" + direction: UP + timeout: 60000 + speed: 40 + visibilityPercentage: 50 + centerElement: true +- waitForAnimationToEnd +- assertVisible: "(?s).*Worked.*" +- assertVisible: "(?s).*Response to: approve:command echo hi.*" +- takeScreenshot: phase4a-rows-c diff --git a/apps/mobile/e2e/flows/phase4a-diff-showcase.yaml b/apps/mobile/e2e/flows/phase4a-diff-showcase.yaml new file mode 100644 index 0000000000..a76bf17413 --- /dev/null +++ b/apps/mobile/e2e/flows/phase4a-diff-showcase.yaml @@ -0,0 +1,63 @@ +# Phase 4a: the native diff renderer and ANSI terminal output render on +# device. Opens the dev showcase (/dev/diff, Settings → Developer) and checks +# a parsed multi-file patch (paths, +/- tally, rename, binary), the large-hunk +# "Show N more lines" cap, the timeline file-change fallbacks, and a terminal +# block that collapses to its tail. +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" +--- +- runFlow: ../subflows/launch-app.yaml +- openLink: "bb://dev/diff" +- extendedWaitUntil: + visible: + id: "dev-diff-screen" + timeout: 30000 +# Multi-file patch: modified file with stats, pure rename, binary add, delete. +- assertVisible: + text: ".*src/diff/parse\\.ts, \\+10, -3.*" +- assertVisible: + text: "@@ -1,9 \\+1,11 @@ import type.*" +- assertVisible: + text: ".*old/name\\.ts, new/name\\.ts, renamed.*" +- assertVisible: + text: ".*assets/logo\\.png, binary.*" +# Collapse the first card and expand it again. +- tapOn: + id: "dev-diff-card-apps-mobile-src-diff-parse.ts-header" +- assertNotVisible: + text: "@@ -1,9 \\+1,11 @@ import type.*" +- tapOn: + id: "dev-diff-card-apps-mobile-src-diff-parse.ts-header" +- assertVisible: + text: "@@ -1,9 \\+1,11 @@ import type.*" +# Timeline file-change fallbacks. +- scrollUntilVisible: + element: + text: "No diff available." + direction: DOWN + timeout: 60000 +- assertVisible: + text: "Applied edit to src/index.ts.*" +# Terminal output: collapsed tail with an earlier-lines toggle. +- scrollUntilVisible: + element: + text: ".*36 earlier lines" + direction: DOWN + timeout: 60000 +- assertNotVisible: + text: ".*line 1 of a fairly long command output.*" +- tapOn: ".*36 earlier lines" +- assertVisible: + text: ".*line 1 of a fairly long command output.*" +# Large hunk collapses behind "Show N more lines" (400 lines, cap 40). +- scrollUntilVisible: + element: + text: "Show 360 more lines" + direction: DOWN + timeout: 60000 +- tapOn: "Show 360 more lines" +- assertNotVisible: + text: "Show 360 more lines" diff --git a/apps/mobile/e2e/flows/phase4a-timeline.yaml b/apps/mobile/e2e/flows/phase4a-timeline.yaml new file mode 100644 index 0000000000..9c520fea5b --- /dev/null +++ b/apps/mobile/e2e/flows/phase4a-timeline.yaml @@ -0,0 +1,70 @@ +# Phase 4a timeline: first run → add the harness server → open "Rich thread" +# → the timeline opens at the unread divider (never read → top), the first +# user message is visible → scroll to the long markdown message → table of +# contents → jump back to the first message. +# +# Requires Metro started with EXPO_PUBLIC_BB_E2E=1 and the harness backend +# (seeds "Rich thread" with several fake-provider turns; see +# tests/integration/mobile-e2e/backend.ts). +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-app.yaml +# First run: add the Direct server. +- extendedWaitUntil: + visible: "Connect to a bb server" + timeout: 30000 +- tapOn: + id: "server-url-input" +- inputText: "${SERVER_URL}" +- tapOn: + id: "server-label-input" +- inputText: "E2E backend" +# Tapping static text dismisses the keyboard; Maestro's hideKeyboard swipe is +# unreliable on this screen since the bb connect row lengthened it. +- tapOn: "Server URL" +- tapOn: + id: "add-server-submit" +# Home lists the seeded threads; open the rich one. +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 30000 +- extendedWaitUntil: + visible: "Rich thread" + timeout: 30000 +- tapOn: "Rich thread" +# Thread detail: header + timeline. The thread was never read, so the list +# opens at the "New" divider above the first row. +- extendedWaitUntil: + visible: + id: "thread-detail-header" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "thread-timeline" + timeout: 30000 +- extendedWaitUntil: + visible: "Hello rich thread, first message" + timeout: 30000 +- assertVisible: "Needs input" +- takeScreenshot: phase4a-timeline-top +# Scroll down to the long markdown message (assert on plain words: the +# heading text survives any renderer; `(?s)` lets the match span the lines +# of a multi-line text node). +- scrollUntilVisible: + element: + text: "(?s).*Release checklist overview.*" + direction: DOWN + timeout: 60000 + speed: 40 + visibilityPercentage: 10 +- takeScreenshot: phase4a-timeline-long-message +# The Rich thread ends on a pending question, so the banner holds the bottom. +- assertVisible: + id: "thread-prompt-area" +- takeScreenshot: phase4a-timeline-bottom diff --git a/apps/mobile/e2e/flows/phase4a-work-rows.yaml b/apps/mobile/e2e/flows/phase4a-work-rows.yaml new file mode 100644 index 0000000000..2ac20d0420 --- /dev/null +++ b/apps/mobile/e2e/flows/phase4a-work-rows.yaml @@ -0,0 +1,221 @@ +# Phase 4a work-row renderers: the dev showcase (/dev/work-rows, Settings → +# Developer) renders synthetic command / tool / file-change / web / image / +# approval / question / delegation / workflow rows through the real list model +# (grouping, compact intents, auto-expand). Checks a few expand/collapse paths +# and the bodies they reveal, then opens the seeded "Rich thread" and expands +# its real tool row. +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-app.yaml +# First run: add the Direct server (the showcase renders under the thread +# host provider, which needs an active profile). +- runFlow: + when: + visible: "Connect to a bb server" + commands: + - tapOn: + id: "server-url-input" + - inputText: "${SERVER_URL}" + - tapOn: + id: "server-label-input" + - inputText: "E2E backend" + # Tapping static text dismisses the keyboard; Maestro's hideKeyboard + # swipe is unreliable on this screen since the bb connect row lengthened it. + - tapOn: "Server URL" + - tapOn: + id: "add-server-submit" +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 30000 +# Workspace menu → Settings → Developer → Work rows showcase (a deep link would +# reload the dev client and, under EXPO_PUBLIC_BB_E2E=1, wipe the profile). +- runFlow: ../subflows/open-settings.yaml +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +- scrollUntilVisible: + element: + id: "settings-dev-work-rows" + direction: DOWN + timeout: 30000 +- tapOn: + id: "settings-dev-work-rows" +- extendedWaitUntil: + visible: + id: "dev-work-rows-screen" + timeout: 30000 +# Commands: completed (dimmed) title, failed title with exit status, pending +# with a live shimmer; expand the completed one → terminal card. +- assertVisible: "(?s).*Ran.*pnpm exec vitest run src/diff.*" +- tapOn: + text: "(?s).*Ran.*pnpm exec vitest run src/diff.*" +- extendedWaitUntil: + visible: + id: "timeline-command-output" + timeout: 10000 +- assertVisible: "(?s).*2 passed.*" +- assertVisible: "(?s).*exit code 0.*" +- takeScreenshot: phase4a-work-rows-command +# Closed step: the step summary collapses the exploration; expanding it +# shows one flat line per intent (compact intents), never a chevron. +- scrollUntilVisible: + element: + id: "timeline-row-step-summary" + direction: DOWN + timeout: 30000 + speed: 40 +- tapOn: + id: "timeline-row-step-summary" +- extendedWaitUntil: + visible: + id: "timeline-activity-intent" + timeout: 10000 +- assertVisible: "(?s).*Read src/a.ts.*" +- assertVisible: "(?s).*deploy/SKILL.md.*" +- takeScreenshot: phase4a-work-rows-compact-intents +# Tool with arguments + output. +- scrollUntilVisible: + element: + text: "(?s).*mcp__github__list_pull_requests.*" + direction: DOWN + timeout: 30000 + speed: 40 +- tapOn: + text: "(?s).*mcp__github__list_pull_requests.*" +- extendedWaitUntil: + visible: + id: "timeline-tool-detail" + timeout: 10000 +- assertVisible: "(?s).*owner: get-bb.*" +- assertVisible: + id: "timeline-tool-args-toggle" +- tapOn: + id: "timeline-tool-args-toggle" +- assertVisible: "(?s).*perPage: 50.*" +- scrollUntilVisible: + element: + text: "Show 6 more lines" + direction: DOWN + timeout: 30000 + speed: 40 +- takeScreenshot: phase4a-work-rows-tool +# File change: diff card with the workspace-relative path and +4/-1. +- scrollUntilVisible: + element: + text: "(?s).*Edited.*index.ts.*" + direction: DOWN + timeout: 30000 + speed: 40 +- tapOn: + text: "(?s).*Edited.*index.ts.*" +- extendedWaitUntil: + visible: + id: "timeline-file-change-diff" + timeout: 10000 +- assertVisible: "(?s).*installTelemetry.*" +- takeScreenshot: phase4a-work-rows-file-change +# Approvals and questions: granted/denied glyphs are read-only; the answered +# question expands to its recorded answer. +- scrollUntilVisible: + element: + text: "(?s).*Permission granted for this session.*" + direction: DOWN + timeout: 30000 + speed: 40 +- assertVisible: "(?s).*Permission denied.*Bash.*" +- scrollUntilVisible: + element: + text: "(?s).*Answered 2 questions.*" + direction: DOWN + timeout: 30000 + speed: 40 +- tapOn: + text: "(?s).*Answered 2 questions.*" +- scrollUntilVisible: + element: + id: "timeline-question-answers" + direction: DOWN + timeout: 30000 + speed: 40 +- assertVisible: "(?s).*Keep the web key names.*" +- assertVisible: "No answer" +- takeScreenshot: phase4a-work-rows-approval-question +# Delegations sit in a bundle summary; expanding the delegation reveals the +# result markdown and its children one depth down. Then the workflow rows: +# the running one carries a phase strip and its phase tree when expanded. +- scrollUntilVisible: + element: + text: "(?s).*Ran 2 subagents.*" + direction: DOWN + timeout: 30000 + speed: 40 +- tapOn: + text: "(?s).*Ran 2 subagents.*" +- extendedWaitUntil: + visible: "(?s).*Ran subagent.*Find where the timeline rows.*" + timeout: 10000 +- tapOn: + text: "(?s).*Ran subagent.*Find where the timeline rows.*" +- scrollUntilVisible: + element: + id: "timeline-delegation-output" + direction: DOWN + timeout: 30000 + speed: 40 +# The completed subagent's children close into a step summary one depth down. +- tapOn: + text: "(?s).*Explored 1 search, 1 file.*" +- scrollUntilVisible: + element: + text: "(?s).*Searched for registerTimelineRowRenderer.*" + direction: DOWN + timeout: 30000 + speed: 40 +- takeScreenshot: phase4a-work-rows-delegation +- scrollUntilVisible: + element: + text: "(?s).*Running workflow.*" + direction: DOWN + timeout: 30000 + speed: 40 +- tapOn: + text: "(?s).*Running workflow.*" +- scrollUntilVisible: + element: + id: "timeline-workflow-progress" + direction: DOWN + timeout: 30000 + speed: 40 +- assertVisible: "(?s).*Implement the screens.*" +- assertVisible: "(?s).*Review for regressions.*" +- takeScreenshot: phase4a-work-rows-workflow +- scrollUntilVisible: + element: + text: "(?s).*Failed workflow.*" + direction: DOWN + timeout: 30000 + speed: 40 +# The tap occasionally lands while the list is still settling from the scroll +# and the row stays collapsed; retry the tap if the status pill does not show. +- retry: + maxRetries: 2 + commands: + - tapOn: + text: "(?s).*Failed workflow.*" + - scrollUntilVisible: + element: + id: "timeline-workflow-status-failed" + direction: DOWN + timeout: 15000 + speed: 40 +- assertVisible: "(?s).*tsc exited with code 2.*" +- assertVisible: + id: "timeline-workflow-usage" +- takeScreenshot: phase4a-work-rows-workflow-failed diff --git a/apps/mobile/e2e/flows/phase4b-actions.yaml b/apps/mobile/e2e/flows/phase4b-actions.yaml new file mode 100644 index 0000000000..00fea1f52e --- /dev/null +++ b/apps/mobile/e2e/flows/phase4b-actions.yaml @@ -0,0 +1,87 @@ +# Phase 4b actions on the thread screen: open the thread named by +# THREAD_TITLE → the header shows the environment line (project · host · +# workspace) → "…" → Rename through the sheet → the title updates → +# long-press the user message → Copy text toasts "Copied" → long-press again +# → Add to chat quotes it into the follow-up composer ("> …"). +# +# Create an idle thread titled "P4b actions" first (see phase4b-send.yaml): +# maestro test e2e/flows/phase4b-actions.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "P4b actions" +--- +- runFlow: ../subflows/launch-to-home.yaml +- scrollUntilVisible: + element: "${THREAD_TITLE}" + direction: DOWN + timeout: 60000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-detail-title" + text: "${THREAD_TITLE}" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "thread-composer-input" + timeout: 30000 +- takeScreenshot: phase4b-actions-thread +# Rename through the "…" sheet; its header carries the environment line +# (project · host · workspace). +- tapOn: + id: "thread-actions-button" +- extendedWaitUntil: + visible: + id: "thread-action-rename" + timeout: 10000 +- assertVisible: "Mobile E2E Project.*" +- tapOn: + id: "thread-action-rename" +- extendedWaitUntil: + visible: + id: "thread-rename-input" + timeout: 10000 +- tapOn: + id: "thread-rename-input" +- eraseText: 60 +- inputText: "${THREAD_TITLE} renamed" +- tapOn: + id: "thread-rename-submit" +- extendedWaitUntil: + visible: + id: "thread-detail-title" + text: "${THREAD_TITLE} renamed" + timeout: 15000 +- takeScreenshot: phase4b-actions-renamed +# Long-press the user message → Copy text. +- longPressOn: + id: "conversation-user-bubble" + index: 0 +- extendedWaitUntil: + visible: + id: "action-sheet-copy" + timeout: 10000 +- assertVisible: + id: "action-sheet-add-to-chat" +- takeScreenshot: phase4b-actions-message-sheet +- tapOn: + id: "action-sheet-copy" +- extendedWaitUntil: + visible: "Copied" + timeout: 10000 +# Long-press again → Add to chat quotes the message into the composer. +- longPressOn: + id: "conversation-user-bubble" + index: 0 +- extendedWaitUntil: + visible: + id: "action-sheet-add-to-chat" + timeout: 10000 +- tapOn: + id: "action-sheet-add-to-chat" +- extendedWaitUntil: + visible: "> Reply with exactly READY.*" + timeout: 10000 +- takeScreenshot: phase4b-actions-quoted diff --git a/apps/mobile/e2e/flows/phase4b-approval.yaml b/apps/mobile/e2e/flows/phase4b-approval.yaml new file mode 100644 index 0000000000..25960a10d7 --- /dev/null +++ b/apps/mobile/e2e/flows/phase4b-approval.yaml @@ -0,0 +1,48 @@ +# Phase 4b approval banner: with `approve:command echo hi` sent to THREAD_ID +# through the API (the fake provider blocks on a command approval), open the +# Interactions showcase's Live thread section, see the approval card, tap +# Allow once, and watch the banner clear. Pass `-e THREAD_ID=<id>`. +# +# Run: maestro test -e THREAD_ID=thr_xxx e2e/flows/phase4b-approval.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-to-home.yaml +- runFlow: ../subflows/open-settings.yaml +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +- scrollUntilVisible: + element: + id: "settings-dev-interactions" + direction: DOWN + timeout: 30000 +- tapOn: + id: "settings-dev-interactions" +- extendedWaitUntil: + visible: + id: "dev-interactions-screen" + timeout: 30000 +- tapOn: + id: "dev-live-thread-input" +- inputText: "${THREAD_ID}" +- hideKeyboard +- tapOn: + id: "dev-live-thread-load" +- extendedWaitUntil: + visible: + id: "pending-interaction-approval" + timeout: 20000 +- assertVisible: "(?s).*echo hi.*" +- takeScreenshot: phase4b-approval +- tapOn: + id: "approval-allow_once" + index: 0 +- extendedWaitUntil: + visible: + id: "dev-live-thread-no-interaction" + timeout: 20000 +- takeScreenshot: phase4b-approval-allowed diff --git a/apps/mobile/e2e/flows/phase4b-approve.yaml b/apps/mobile/e2e/flows/phase4b-approve.yaml new file mode 100644 index 0000000000..11014bf8e7 --- /dev/null +++ b/apps/mobile/e2e/flows/phase4b-approve.yaml @@ -0,0 +1,82 @@ +# Phase 4b approval: open the idle thread named by THREAD_TITLE → send +# "approve:command echo hi" (the fake provider blocks on a command +# approval) → the approval banner replaces the composer → Allow once → the +# banner clears and the provider answers "Response to: …". Then send the +# same prompt again → Deny → the provider answers "Denied". +# +# Create an idle thread titled "P4b approve" first (the seeded "Idle thread" gets +# renamed by other flows), e.g. `POST /api/v1/threads` against the harness; +# the flow's env block names the title (Maestro ignores `-e` overrides of keys +# the flow defines): +# maestro test e2e/flows/phase4b-approve.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "P4b approve" +--- +- runFlow: ../subflows/launch-to-home.yaml +- scrollUntilVisible: + element: "${THREAD_TITLE}" + direction: DOWN + timeout: 60000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-composer-input" + timeout: 30000 +# The composer is a pill until focused; the pills render in the expanded card. +- tapOn: + id: "thread-composer-input" +- extendedWaitUntil: + visible: + id: "thread-execution-controls" + timeout: 30000 +# --- Allow once ------------------------------------------------------------- +- tapOn: + id: "thread-composer-input" +- inputText: "approve:command echo hi" +- tapOn: + id: "thread-composer-submit" +# The approval banner replaces the composer and shows the command. +- extendedWaitUntil: + visible: + id: "pending-interaction-approval" + timeout: 30000 +- assertNotVisible: + id: "thread-composer-input" +- assertVisible: "(?s).*echo hi.*" +- takeScreenshot: phase4b-approve-banner +- tapOn: + id: "approval-allow_once" + index: 0 +# Resolved: the composer is back and the provider's answer lands. +- extendedWaitUntil: + visible: + id: "thread-composer-input" + timeout: 30000 +- extendedWaitUntil: + visible: "Response to: approve:command echo hi" + timeout: 30000 +- takeScreenshot: phase4b-approve-allowed +# --- Deny ------------------------------------------------------------------------ +- tapOn: + id: "thread-composer-input" +- inputText: "approve:command echo hi" +- tapOn: + id: "thread-composer-submit" +- extendedWaitUntil: + visible: + id: "pending-interaction-approval" + timeout: 30000 +- tapOn: + id: "approval-deny" + index: 0 +- extendedWaitUntil: + visible: + id: "thread-composer-input" + timeout: 30000 +- extendedWaitUntil: + visible: "Denied" + timeout: 30000 +- takeScreenshot: phase4b-approve-denied diff --git a/apps/mobile/e2e/flows/phase4b-ask-user.yaml b/apps/mobile/e2e/flows/phase4b-ask-user.yaml new file mode 100644 index 0000000000..2567a012d7 --- /dev/null +++ b/apps/mobile/e2e/flows/phase4b-ask-user.yaml @@ -0,0 +1,60 @@ +# Phase 4b user question: open the idle thread named by THREAD_TITLE → send +# "ask_user" (the fake provider asks a native question) → the question +# banner replaces the composer → pick the first option → Submit → the +# banner clears, the composer returns, and the provider finishes the turn +# with "Question answered: …". +# +# Create an idle thread titled "P4b ask user" first (the seeded "Idle thread" gets +# renamed by other flows), e.g. `POST /api/v1/threads` against the harness; +# the flow's env block names the title (Maestro ignores `-e` overrides of keys +# the flow defines): +# maestro test e2e/flows/phase4b-ask-user.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "P4b ask user" +--- +- runFlow: ../subflows/launch-to-home.yaml +- scrollUntilVisible: + element: "${THREAD_TITLE}" + direction: DOWN + timeout: 60000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-composer-input" + timeout: 30000 +# The composer is a pill until focused; the pills render in the expanded card. +- tapOn: + id: "thread-composer-input" +- extendedWaitUntil: + visible: + id: "thread-execution-controls" + timeout: 30000 +- tapOn: + id: "thread-composer-input" +- inputText: "ask_user" +- tapOn: + id: "thread-composer-submit" +# The question banner replaces the composer. +- extendedWaitUntil: + visible: + id: "pending-interaction-question" + timeout: 30000 +- assertNotVisible: + id: "thread-composer-input" +- takeScreenshot: phase4b-ask-user-question +- tapOn: + id: "question-option-0" +- tapOn: + id: "question-submit" +# Resolved: the composer is back and the turn completes. +- extendedWaitUntil: + visible: + id: "thread-composer-input" + timeout: 30000 +- extendedWaitUntil: + visible: "Question answered:.*" + timeout: 30000 +- takeScreenshot: phase4b-ask-user-answered diff --git a/apps/mobile/e2e/flows/phase4b-composer.yaml b/apps/mobile/e2e/flows/phase4b-composer.yaml new file mode 100644 index 0000000000..cf28c2af5f --- /dev/null +++ b/apps/mobile/e2e/flows/phase4b-composer.yaml @@ -0,0 +1,93 @@ +# Phase 4b shared composer: the dev showcase (/dev/composer, Settings → +# Developer) against the harness backend. Types an `@` query → the typeahead +# opens above the input listing the seeded threads; picking one inserts a +# pill and the serialized PromptInput carries `@thread:<id>`; backspace at the +# pill end removes it whole; `/` lists provider commands; the "+" menu opens +# with the attachment + prompt actions; then the compose screen creates a +# thread through the composer. +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-to-home.yaml +- runFlow: ../subflows/open-settings.yaml +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +- scrollUntilVisible: + element: + id: "settings-dev-composer" + direction: DOWN + timeout: 30000 +- tapOn: + id: "settings-dev-composer" +- extendedWaitUntil: + visible: + id: "dev-composer-screen" + timeout: 30000 +# Mention typeahead: "@Rich" → the seeded "Rich thread" row; pick it → pill. +- tapOn: + id: "dev-composer-input" +- inputText: "ask @Rich" +- extendedWaitUntil: + visible: + id: "dev-composer-typeahead" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "dev-composer-typeahead-row-0" + timeout: 15000 +- assertVisible: "Rich thread" +- takeScreenshot: phase4b-composer-typeahead +- tapOn: + id: "dev-composer-typeahead-row-0" +- assertNotVisible: + id: "dev-composer-typeahead" +- assertVisible: "(?s).*@thread:thr_.*" +- takeScreenshot: phase4b-composer-pill +# Backspace at the pill end removes the whole mention (the serialized input +# loses the mention range). +- inputText: " again" +# 7 backspaces land at the pill end; the 8th deletes the whole pill. +- eraseText: 7 +- assertVisible: "(?s).*@thread:thr_.*" +- eraseText: 1 +- assertNotVisible: "(?s).*@thread:thr_.*" +- assertVisible: '(?s).*"text": "ask".*' +# Slash commands open the command menu (the harness provider is codex with a +# skills trigger, so the discovered skills list). +- eraseText: 20 +- inputText: "/" +- extendedWaitUntil: + visible: + id: "dev-composer-typeahead-row-0" + timeout: 20000 +- assertVisible: "Skills" +- takeScreenshot: phase4b-composer-commands +- tapOn: + id: "dev-composer-typeahead-row-0" +- assertVisible: '(?s).*"kind": "command".*' +- eraseText: 30 +# "+" menu: attachment + prompt actions. +- tapOn: + id: "dev-composer-actions" +- assertVisible: "Photo library" +- assertVisible: "Attach file" +- takeScreenshot: phase4b-composer-actions +- tapOn: "Cancel" +# Submit in the ready mode reports the kind. +- tapOn: + id: "dev-composer-input" +- eraseText: 20 +- inputText: "hello" +- tapOn: + id: "dev-composer-submit" +- extendedWaitUntil: + visible: + id: "dev-composer-last-submit" + timeout: 5000 +- assertVisible: "(?s).*submitted: send.*" diff --git a/apps/mobile/e2e/flows/phase4b-interactions.yaml b/apps/mobile/e2e/flows/phase4b-interactions.yaml new file mode 100644 index 0000000000..2ba3a4319f --- /dev/null +++ b/apps/mobile/e2e/flows/phase4b-interactions.yaml @@ -0,0 +1,111 @@ +# Phase 4b pending interactions + queue: Settings → Developer → Interactions +# showcase renders every banner variant on synthetic payloads; the "Live +# thread" section then answers a real pending user question on THREAD_ID +# (pass `-e THREAD_ID=<id>` after sending `ask_user` to that thread through +# the API), expecting the banner to clear once the answer is accepted, and +# finally checks the synthetic secret-request and unknown-plugin cards. +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +# maestro test -e THREAD_ID=thr_xxx e2e/flows/phase4b-interactions.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-to-home.yaml +- runFlow: ../subflows/open-settings.yaml +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +- scrollUntilVisible: + element: + id: "settings-dev-interactions" + direction: DOWN + timeout: 30000 +- tapOn: + id: "settings-dev-interactions" +- extendedWaitUntil: + visible: + id: "dev-interactions-screen" + timeout: 30000 +# Live thread: answer the pending user question for real. +- tapOn: + id: "dev-live-thread-input" +- inputText: "${THREAD_ID}" +- hideKeyboard +- tapOn: + id: "dev-live-thread-load" +- extendedWaitUntil: + visible: + id: "pending-interaction-question" + timeout: 20000 +- assertVisible: "(?s).*Which deployment path.*" +- takeScreenshot: phase4b-interactions-question +- tapOn: + id: "question-option-0" +- tapOn: + id: "question-submit" +- extendedWaitUntil: + visible: + id: "dev-live-thread-no-interaction" + timeout: 20000 +- takeScreenshot: phase4b-interactions-answered +# Synthetic variants: approval command card, plan, secrets, unknown plugin. +- scrollUntilVisible: + element: + id: "approval-command" + direction: DOWN + timeout: 30000 + speed: 40 +- assertVisible: "(?s).*Allow for session.*" +- scrollUntilVisible: + element: + text: "(?s).*Approve plan.*" + direction: DOWN + timeout: 30000 + speed: 40 +- scrollUntilVisible: + element: + id: "pending-interaction-plugin-secrets" + direction: DOWN + timeout: 30000 + speed: 40 +- assertVisible: "(?s).*STAGING_API_KEY.*" +- assertVisible: "(?s).*Add secrets.*" +- takeScreenshot: phase4b-interactions-secrets +- scrollUntilVisible: + element: + id: "pending-interaction-plugin-unsupported" + direction: DOWN + timeout: 30000 + speed: 40 +- assertVisible: "(?s).*needs the desktop app.*" +# Prompt chips: the composer's chip row (workflows, background tasks, plan, +# goal, to-dos). The workflows chip opens a sheet with the agent tree. +- scrollUntilVisible: + element: + id: "dev-prompt-chips" + direction: DOWN + timeout: 30000 + speed: 40 +- assertVisible: "(?s).*2 workflows.*" +- tapOn: + id: "thread-chip-workflows" +- extendedWaitUntil: + visible: "(?s).*fix-confirmed-bugs.*" + timeout: 10000 +- assertVisible: "(?s).*Draft the plan.*" +- takeScreenshot: phase4b-interactions-chips-workflows +- swipe: + direction: DOWN + duration: 300 +# Queued messages list: three synthetic rows, "…" menu offers move/group/delete. +- scrollUntilVisible: + element: + id: "queued-messages-list" + direction: DOWN + timeout: 30000 + speed: 40 +- assertVisible: "(?s).*3 queued messages.*" +- takeScreenshot: phase4b-interactions-queue diff --git a/apps/mobile/e2e/flows/phase4b-queue.yaml b/apps/mobile/e2e/flows/phase4b-queue.yaml new file mode 100644 index 0000000000..14398df6d4 --- /dev/null +++ b/apps/mobile/e2e/flows/phase4b-queue.yaml @@ -0,0 +1,78 @@ +# Phase 4b queue: open the idle thread named by THREAD_TITLE → send +# "delay:30000 first" (the fake provider works for 30 s) → while the runtime +# is active the composer offers Stop + Queue → type "second" and tap the +# submit (queue) button → the message shows in the queued list under the +# stack → Send now → it leaves the queue and is steered into the running +# turn (the "second" user row appears in the timeline) → the turn finishes. +# (The fake provider echoes only the turn's first message; a steer is +# recorded as a user row but gets no "Response to:" of its own.) +# +# Create an idle thread titled "P4b queue" first (the seeded "Idle thread" gets +# renamed by other flows), e.g. `POST /api/v1/threads` against the harness; +# the flow's env block names the title (Maestro ignores `-e` overrides of keys +# the flow defines): +# maestro test e2e/flows/phase4b-queue.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "P4b queue" +--- +- runFlow: ../subflows/launch-to-home.yaml +- scrollUntilVisible: + element: "${THREAD_TITLE}" + direction: DOWN + timeout: 60000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-composer-input" + timeout: 30000 +# The composer is a pill until focused; the pills render in the expanded card. +- tapOn: + id: "thread-composer-input" +- extendedWaitUntil: + visible: + id: "thread-execution-controls" + timeout: 30000 +- tapOn: + id: "thread-composer-input" +- inputText: "delay:30000 first" +- tapOn: + id: "thread-composer-submit" +# Active: the stop button appears next to the (now "queue") submit. +- extendedWaitUntil: + visible: + id: "thread-composer-stop" + timeout: 15000 +- inputText: "second" +- takeScreenshot: phase4b-queue-active +- tapOn: + id: "thread-composer-submit" +# The queued list shows the message. +- extendedWaitUntil: + visible: + id: "queued-messages-list" + timeout: 15000 +- assertVisible: + id: "queued-message-0" +- assertVisible: "second" +- takeScreenshot: phase4b-queue-queued +# Send now: the message leaves the queue and the provider answers it. +- tapOn: + id: "queued-message-send-now" + index: 0 +- extendedWaitUntil: + notVisible: + id: "queued-messages-list" + timeout: 30000 +# Delivered as a steer: the user row shows in the timeline. +- extendedWaitUntil: + visible: "second" + timeout: 30000 +- takeScreenshot: phase4b-queue-delivered +# The first turn finishes on its own. +- extendedWaitUntil: + visible: "Response to: delay:30000 first" + timeout: 60000 +- takeScreenshot: phase4b-queue-done diff --git a/apps/mobile/e2e/flows/phase4b-send.yaml b/apps/mobile/e2e/flows/phase4b-send.yaml new file mode 100644 index 0000000000..6f347001a9 --- /dev/null +++ b/apps/mobile/e2e/flows/phase4b-send.yaml @@ -0,0 +1,54 @@ +# Phase 4b send: add the harness server → open the idle thread named by +# THREAD_TITLE → type "hello" into the follow-up composer → Send → the +# optimistic user row shows at once → the fake provider answers +# "Response to: hello" → the composer is empty and ready again. +# +# Create an idle thread titled "P4b send" first (the seeded "Idle thread" gets +# renamed by other flows), e.g. `POST /api/v1/threads` against the harness; +# the flow's env block names the title (Maestro ignores `-e` overrides of keys +# the flow defines): +# maestro test e2e/flows/phase4b-send.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "P4b send" +--- +- runFlow: ../subflows/launch-to-home.yaml +- scrollUntilVisible: + element: "${THREAD_TITLE}" + direction: DOWN + timeout: 60000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-detail-screen" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "thread-composer-input" + timeout: 30000 +- takeScreenshot: phase4b-send-idle +# The composer is a one-line pill until focused; the execution pills render +# in the expanded card once the thread defaults resolve. +- tapOn: + id: "thread-composer-input" +- extendedWaitUntil: + visible: + id: "thread-execution-controls" + timeout: 30000 +- inputText: "hello" +- takeScreenshot: phase4b-send-typed +- tapOn: + id: "thread-composer-submit" +# Optimistic user row, then the provider's echo. +- extendedWaitUntil: + visible: "hello" + timeout: 10000 +- extendedWaitUntil: + visible: "Response to: hello" + timeout: 30000 +- takeScreenshot: phase4b-send-response +# The draft was cleared: the input reads its placeholder again (the input's +# accessibility label "Prompt" + the placeholder). +- assertVisible: "Prompt Follow up…" diff --git a/apps/mobile/e2e/flows/phase4b-thread-actions.yaml b/apps/mobile/e2e/flows/phase4b-thread-actions.yaml new file mode 100644 index 0000000000..f3e488652c --- /dev/null +++ b/apps/mobile/e2e/flows/phase4b-thread-actions.yaml @@ -0,0 +1,127 @@ +# Phase 4b thread actions + context banner: add the harness server → open +# the thread named by THREAD_TITLE (create it first; see README) → header +# title tap opens the rename sheet → rename → "…" menu lists the thread +# actions (Copy link toasts) → the git button opens the git sheet (when the +# worktree is dirty) → the context banner's changed-files row expands. +# +# Setup (the flow asserts the banner's git row, which needs a dirty +# worktree): create a managed-worktree thread through the API, write a file +# into its worktree, and pass the title: +# maestro test -e THREAD_TITLE="P4b banner parent" e2e/flows/phase4b-thread-actions.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "P4b banner parent" +--- +- runFlow: ../subflows/launch-to-home.yaml +- extendedWaitUntil: + visible: "${THREAD_TITLE}" + timeout: 30000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-detail-screen" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "thread-detail-title" + text: "${THREAD_TITLE}" + timeout: 30000 +# Context chips: the dirty worktree shows the changed-files chip; open its sheet. +- extendedWaitUntil: + visible: + id: "thread-chip-changes" + timeout: 30000 +- tapOn: + id: "thread-chip-changes" +- extendedWaitUntil: + visible: + id: "workspace-changes-list" + timeout: 10000 +- assertVisible: + id: "thread-chip-merge-base" +- takeScreenshot: phase4b-changes-sheet +# Tap the backdrop to close the sheet before the header menu. +- tapOn: + point: "50%,15%" +- extendedWaitUntil: + notVisible: + id: "thread-chip-changes-sheet" + timeout: 10000 +# Git sheet from the "…" menu (the header has no second row). +- tapOn: + id: "thread-actions-button" +- extendedWaitUntil: + visible: + id: "thread-git-button" + timeout: 10000 +- tapOn: + id: "thread-git-button" +- extendedWaitUntil: + visible: + id: "thread-git-sheet" + timeout: 10000 +- assertVisible: + id: "thread-git-action-commit" +- takeScreenshot: phase4b-git-sheet +- tapOn: + point: "50%,15%" +- extendedWaitUntil: + notVisible: + id: "thread-git-sheet" + timeout: 10000 +# Rename through the title. +- tapOn: + id: "thread-detail-title" +- extendedWaitUntil: + visible: + id: "thread-rename-input" + timeout: 10000 +- tapOn: + id: "thread-rename-input" +- eraseText: 60 +- inputText: "${THREAD_TITLE} renamed" +- tapOn: + id: "thread-rename-submit" +- extendedWaitUntil: + visible: + id: "thread-detail-title" + text: "${THREAD_TITLE} renamed" + timeout: 15000 +# Actions menu: copy link. +- tapOn: + id: "thread-actions-button" +- extendedWaitUntil: + visible: + id: "thread-action-copy-link" + timeout: 10000 +- assertVisible: + id: "thread-action-handoff" +- assertVisible: + id: "thread-action-pin" +- assertVisible: + id: "thread-action-delete" +- takeScreenshot: phase4b-thread-menu +- tapOn: + id: "thread-action-copy-link" +- extendedWaitUntil: + visible: "Link copied" + timeout: 10000 +# Long-press an assistant message: the action sheet offers Copy text + Fork. +- longPressOn: + id: "conversation-assistant-body" +- extendedWaitUntil: + visible: + id: "action-sheet-copy" + timeout: 10000 +- assertVisible: + id: "action-sheet-fork" +- takeScreenshot: phase4b-message-actions +- tapOn: + id: "action-sheet-fork" +- extendedWaitUntil: + visible: + id: "compose-fork-hint" + timeout: 30000 +- takeScreenshot: phase4b-fork-compose diff --git a/apps/mobile/e2e/flows/phase5-connect.yaml b/apps/mobile/e2e/flows/phase5-connect.yaml new file mode 100644 index 0000000000..8c0a64931e --- /dev/null +++ b/apps/mobile/e2e/flows/phase5-connect.yaml @@ -0,0 +1,231 @@ +# Phase 5 bb connect: first run → Add server → "Connect with bb connect" → +# manual code entry against the stub apex (code, handle, self-hosted apex) → +# enrolled screen (session signed in, account servers listed) → Done → home +# connected through the stub gate (session cookie on fetch + /ws) → the stub +# expires the session → the app re-mints it (no banner) → the stub revokes +# the machine → auth-required banner → "Sign in again" → re-pair with a new +# code → home connected again. +# +# Requires, besides Metro with EXPO_PUBLIC_BB_E2E=1 (METRO_URL): +# pnpm --filter @bb/integration-tests e2e:mobile-backend +# BB_MOBILE_E2E_SIMULATOR=<udid> pnpm --filter @bb/integration-tests e2e:mobile-connect-stub +# The stub installs its root certificate in the simulator on start; the +# gate is https://stub.localhost:42998 (STUB_URL), the apex https://localhost:42998 +# (APEX_URL), the pairing code STUB-PAIR (CODE). +# +# Run: JAVA_HOME=/opt/homebrew/opt/openjdk@17 maestro test e2e/flows/phase5-connect.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + APEX_URL: "https://localhost:42998" + STUB_URL: "https://stub.localhost:42998" + CODE: "STUB-PAIR" +--- +- runFlow: ../subflows/launch-app.yaml +# First run: Add server offers bb connect above the direct URL form. +- extendedWaitUntil: + visible: "Connect to a bb server" + timeout: 30000 +- assertVisible: + id: "add-server-connect" +- takeScreenshot: p5-connect-add-server +- tapOn: + id: "add-server-connect" +- extendedWaitUntil: + visible: "Connect to getbb.app" + timeout: 15000 +- takeScreenshot: p5-connect-enroll-form +# A wrong code shows the apex's error inline and keeps the form. +- tapOn: + id: "connect-code-input" +- inputText: "EXPIRED-CODE" +# Tapping static text dismisses the keyboard (Maestro's hideKeyboard looks +# for a Return/Done key; the code field's key is "next"). +- tapOn: "Pairing code" +- tapOn: + id: "connect-advanced-toggle" +- scrollUntilVisible: + element: + id: "connect-apex-input" + direction: DOWN +- tapOn: + id: "connect-apex-input" +- inputText: "${APEX_URL}" +- tapOn: "bb connect address" +- scrollUntilVisible: + element: + id: "connect-submit" + direction: DOWN +- tapOn: + id: "connect-submit" +- extendedWaitUntil: + visible: "Code expired" + timeout: 20000 +- takeScreenshot: p5-connect-expired-code +# The real code: handle + self-hosted apex → redeem → profile → session. +- scrollUntilVisible: + element: + id: "connect-code-input" + direction: UP +- tapOn: + id: "connect-code-input" +- eraseText: 20 +- inputText: "${CODE}" +- tapOn: "Pairing code" +- tapOn: + id: "connect-server-input" +- inputText: "stub" +- tapOn: "Server (handle or URL)" +- scrollUntilVisible: + element: + id: "connect-submit" + direction: DOWN +- tapOn: + id: "connect-submit" +# The first successful connection may raise the one-time push-notification +# prompt over the enrolled screen; decline it. +- extendedWaitUntil: + visible: ".*(Paired with bb connect|Not now).*" + timeout: 30000 +- runFlow: + when: + visible: "Not now" + commands: + - tapOn: "Not now" +- extendedWaitUntil: + visible: + id: "connect-enrolled-screen" + timeout: 30000 +- assertVisible: ".*${STUB_URL}.*" +# The connector minted the desktop-session cookie for the new profile. +- extendedWaitUntil: + visible: + id: "connect-session-authenticated" + timeout: 30000 +# The account's servers (from the gate, with the machine credential): the +# enrolled one is marked, the other is one tap away. +- extendedWaitUntil: + visible: + id: "account-server-other" + timeout: 20000 +- assertVisible: ".*This server.*" +- takeScreenshot: p5-connect-enrolled +- tapOn: + id: "account-server-add-other" +- extendedWaitUntil: + visible: "Added Other stub server" + timeout: 10000 +- tapOn: + id: "connect-done" +# Home through the gate: session cookie on every fetch and on the /ws upgrade. +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 30000 +- runFlow: + when: + visible: "Not now" + commands: + - tapOn: "Not now" +- extendedWaitUntil: + notVisible: + id: "connection-banner" + timeout: 30000 +- assertVisible: "Rich thread" +- takeScreenshot: p5-connect-home +# Servers list: mode pills + handle. +# Workspace menu → Settings. +- runFlow: ../subflows/open-settings.yaml +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +- assertVisible: + id: "settings-servers" +- tapOn: + id: "settings-servers" +- extendedWaitUntil: + visible: ".*@stub.*" + timeout: 10000 +- assertVisible: ".*@other.*" +- assertVisible: ".*bb connect.*" +- takeScreenshot: p5-connect-servers +- tapOn: + id: "BackButton" +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 10000 +# The gate forgets the session (cookie rejected, sockets closed): the app +# re-mints one with the credential and reconnects without asking the user. +- runScript: + file: ../scripts/connect-stub-control.js + env: + STUB_ACTION: "expire-session" +- extendedWaitUntil: + visible: + id: "connection-banner" + timeout: 30000 +- extendedWaitUntil: + notVisible: + id: "connection-banner" + timeout: 60000 +- takeScreenshot: p5-connect-session-renewed +# The credential itself is revoked: auth-required banner with "Sign in again". +- runScript: + file: ../scripts/connect-stub-control.js + env: + STUB_ACTION: "revoke-machine" +- extendedWaitUntil: + visible: + id: "connection-banner-auth-required" + timeout: 60000 +- takeScreenshot: p5-connect-auth-required +# The whole banner is the action (the dev-client's floating button covers +# the "Sign in again" label's corner on the simulator). +- tapOn: + id: "connection-banner" +- extendedWaitUntil: + visible: "Sign in again to stub" + timeout: 15000 +- tapOn: + id: "connect-code-input" +- inputText: "${CODE}" +- tapOn: "Pairing code" +- scrollUntilVisible: + element: + id: "connect-submit" + direction: DOWN +- tapOn: + id: "connect-submit" +- extendedWaitUntil: + visible: ".*(Paired again|Not now).*" + timeout: 30000 +- runFlow: + when: + visible: "Not now" + commands: + - tapOn: "Not now" +- extendedWaitUntil: + visible: + id: "connect-enrolled-screen" + timeout: 30000 +- assertVisible: "Paired again" +- extendedWaitUntil: + visible: + id: "connect-session-authenticated" + timeout: 30000 +- takeScreenshot: p5-connect-reauth +- tapOn: + id: "connect-done" +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 30000 +- extendedWaitUntil: + notVisible: + id: "connection-banner" + timeout: 30000 +- takeScreenshot: p5-connect-home-again diff --git a/apps/mobile/e2e/flows/phase5-links.yaml b/apps/mobile/e2e/flows/phase5-links.yaml new file mode 100644 index 0000000000..372c9fe16e --- /dev/null +++ b/apps/mobile/e2e/flows/phase5-links.yaml @@ -0,0 +1,63 @@ +# Phase 5 deep links: first run → add the harness server → `bb://threads/<id>` +# opens the seeded "Completed thread" (pass `-e THREAD_ID=<id>`: the +# `threads.completed` id the backend prints on startup, or +# `GET /api/v1/threads`) → the web alias `bb://projects/<p>/threads/<t>` +# (pass `-e PROJECT_ID=<id>`) lands on the same thread → `bb://settings/servers` +# opens the Servers screen with the added server → `bb://settings` opens +# Settings. (The Notifications section and its per-server push row arrive +# with the push-notifications PR; this flow asserts them once they exist.) +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +# maestro test -e THREAD_ID=thr_xxx -e PROJECT_ID=proj_xxx e2e/flows/phase5-links.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-to-home.yaml +# Scheme link straight to a thread while the app is warm. +- openLink: "bb://threads/${THREAD_ID}" +- extendedWaitUntil: + visible: + id: "thread-detail-header" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "thread-timeline" + timeout: 30000 +- extendedWaitUntil: + visible: "Hello from the seed" + timeout: 30000 +- takeScreenshot: phase5-link-thread +# Back home, then the web-shaped alias path the SPA serves for the same thread. +- openLink: "bb://" +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 30000 +- openLink: "bb://projects/${PROJECT_ID}/threads/${THREAD_ID}" +- extendedWaitUntil: + visible: + id: "thread-detail-header" + timeout: 30000 +- extendedWaitUntil: + visible: "Hello from the seed" + timeout: 30000 +- takeScreenshot: phase5-link-thread-alias +# Settings → Servers by link: the added server is listed. +- openLink: "bb://settings/servers" +- extendedWaitUntil: + visible: + id: "servers-screen" + timeout: 30000 +- extendedWaitUntil: + visible: ".*E2E backend.*" + timeout: 10000 +- takeScreenshot: phase5-link-servers +# Settings root by link. +- openLink: "bb://settings" +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 30000 +- takeScreenshot: phase5-link-settings diff --git a/apps/mobile/e2e/flows/phase6-diff.yaml b/apps/mobile/e2e/flows/phase6-diff.yaml new file mode 100644 index 0000000000..296545c034 --- /dev/null +++ b/apps/mobile/e2e/flows/phase6-diff.yaml @@ -0,0 +1,144 @@ +# Phase 6 Diff tab: add the harness server → open the thread named by +# THREAD_TITLE (its worktree is dirty) → the context banner's changed-files +# row → "Open diff" → the Diff sheet lists the modified / deleted / added +# cards with the modified file's hunk → the target picker offers the +# uncommitted target → "Add to chat" quotes the patch into the composer +# ("> diff --git …") → a file row in the banner opens the diff focused on it +# → an API commit + refresh offers the committed target. +# +# Setup (the fake provider never edits files, so the worktree is dirtied from +# the shell; the script creates the thread too): +# SERVER_URL=http://127.0.0.1:41999 e2e/scripts/phase6-diff-setup.sh +# maestro test e2e/flows/phase6-diff.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "P6 diff" +--- +- runFlow: ../subflows/launch-to-home.yaml +- scrollUntilVisible: + element: "${THREAD_TITLE}" + direction: DOWN + timeout: 60000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-detail-title" + text: "${THREAD_TITLE}" + timeout: 30000 +# The dirty worktree shows the changed-files chip; its sheet has "Open diff". +- extendedWaitUntil: + visible: + id: "thread-chip-changes" + timeout: 30000 +- tapOn: + id: "thread-chip-changes" +- extendedWaitUntil: + visible: + id: "thread-chip-open-diff" + timeout: 10000 +- tapOn: + id: "thread-chip-open-diff" +- extendedWaitUntil: + visible: + id: "diff-tab-header" + timeout: 15000 +# Three cards: modified (alpha.txt, with its hunk), deleted (collapsed by +# default), added. +- extendedWaitUntil: + visible: + id: "diff-tab-list" + timeout: 30000 +# Card headers aggregate their children (path, kind, tally) into one label. +- extendedWaitUntil: + visible: ".*alpha\\.txt.*" + timeout: 30000 +- assertVisible: ".*beta\\.md, deleted.*" +- assertVisible: ".*phase6-added\\.ts, added.*" +- assertVisible: "Phase 6 diff tab check.*" +- assertVisible: + id: "diff-tab-added" +- assertVisible: + id: "diff-tab-removed" +- takeScreenshot: phase6-diff +# Target picker: the working tree is the only target on a fresh worktree +# with no commits above the default branch; pick it and the list holds. +- tapOn: + id: "diff-tab-target" +- extendedWaitUntil: + visible: + id: "diff-target-sheet" + timeout: 10000 +- assertVisible: + id: "diff-target-uncommitted" +- takeScreenshot: phase6-diff-target-picker +- tapOn: + id: "diff-target-uncommitted" +- extendedWaitUntil: + visible: ".*alpha\\.txt.*" + timeout: 15000 +# Collapse all, expand all. +- tapOn: + id: "diff-tab-collapse-all" +- assertNotVisible: "Phase 6 diff tab check.*" +- tapOn: + id: "diff-tab-collapse-all" +- extendedWaitUntil: + visible: "Phase 6 diff tab check.*" + timeout: 10000 +# Add to chat: the sheet closes and the patch lands in the composer as a quote. +- tapOn: + id: "diff-tab-file-add-to-chat" + index: 0 +- extendedWaitUntil: + notVisible: + id: "diff-tab-header" + timeout: 10000 +- extendedWaitUntil: + visible: "> diff --git a/alpha.txt b/alpha.txt.*" + timeout: 15000 +- takeScreenshot: phase6-diff-add-to-chat +# A file row in the changes sheet opens the diff focused on that file. +- tapOn: + id: "thread-chip-changes" +- extendedWaitUntil: + visible: + id: "workspace-changes-list" + timeout: 10000 +- tapOn: + id: "workspace-changes-file" + index: 0 +- extendedWaitUntil: + visible: + id: "diff-tab-header" + timeout: 15000 +- extendedWaitUntil: + visible: ".*alpha\\.txt.*" + timeout: 30000 +- takeScreenshot: phase6-diff-focused +# Commit through the API (the fake provider cannot), refresh: the picker now +# offers "Committed changes" and the commit itself; the committed target +# lists the same files. +- runScript: + file: ../scripts/phase6-commit.js +- tapOn: + id: "diff-tab-refresh" +- extendedWaitUntil: + visible: ".*alpha\\.txt.*" + timeout: 15000 +- tapOn: + id: "diff-tab-target" +- extendedWaitUntil: + visible: + id: "diff-target-branch_committed" + timeout: 15000 +- assertVisible: ".*bb: automated commit.*" +- takeScreenshot: phase6-diff-committed-picker +- tapOn: + id: "diff-target-branch_committed" +- extendedWaitUntil: + visible: ".*alpha\\.txt.*" + timeout: 15000 +- assertVisible: ".*phase6-added\\.ts, added.*" +- takeScreenshot: phase6-diff-committed diff --git a/apps/mobile/e2e/flows/phase6-files.yaml b/apps/mobile/e2e/flows/phase6-files.yaml new file mode 100644 index 0000000000..9ccda190fc --- /dev/null +++ b/apps/mobile/e2e/flows/phase6-files.yaml @@ -0,0 +1,152 @@ +# Phase 6 Files: open the thread named THREAD_TITLE → the workspace panel → +# the Files launcher → search "README" → the workspace result opens as a +# panel file tab (markdown preview) → Source → Jump to line 60 → back to the +# Files launcher → browse thread storage (notes › plan.md) → the storage +# preview renders → close the panel → the full-screen preview route by deep +# link (`/threads/<id>/files?kind=workspace&path=src/app.ts&line=12`) lands +# on the highlighted line → long-press a line → Copy line toasts. +# +# Seed the files first (the harness repo only has alpha.txt / beta.md): +# SERVER_URL=http://127.0.0.1:41999 THREAD_TITLE="Idle thread" e2e/scripts/phase6-files-setup.sh +# maestro test -e THREAD_ID=<threadId from the setup JSON> e2e/flows/phase6-files.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "Idle thread" +--- +- runFlow: ../subflows/launch-to-home.yaml +- scrollUntilVisible: + element: "${THREAD_TITLE}" + direction: DOWN + timeout: 60000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-detail-header" + timeout: 30000 +# Workspace panel → Files launcher. +- tapOn: + id: "thread-panel-button" +- extendedWaitUntil: + visible: + id: "panel-tab-files" + timeout: 15000 +- tapOn: + id: "panel-tab-files" +- extendedWaitUntil: + visible: + id: "files-search-input" + timeout: 15000 +# Idle: the thread storage browser lists the seeded directory + file. +- extendedWaitUntil: + visible: "notes" + timeout: 15000 +- assertVisible: "report.csv" +- takeScreenshot: phase6-files-launcher +# Search "README" → the workspace result. +- tapOn: + id: "files-search-input" +- inputText: "README" +- extendedWaitUntil: + visible: "Workspace files" + timeout: 20000 +- extendedWaitUntil: + visible: + id: "files-search-result" + timeout: 20000 +- takeScreenshot: phase6-files-search +- tapOn: + id: "files-search-result" + index: 0 +# The file opens as a panel tab: markdown preview first. +- extendedWaitUntil: + visible: + id: "file-preview-markdown-body" + timeout: 30000 +- assertVisible: "Mobile E2E Project" +- takeScreenshot: phase6-files-readme-preview +# Source view + jump to line 60. +- tapOn: + id: "file-preview-mode-source" +- extendedWaitUntil: + visible: + id: "file-preview-lines" + timeout: 15000 +- tapOn: + id: "file-preview-jump" +- extendedWaitUntil: + visible: + id: "file-preview-jump-input" + timeout: 10000 +- tapOn: + id: "file-preview-jump-input" +- inputText: "60" +- tapOn: + id: "file-preview-jump-submit" +- extendedWaitUntil: + visible: + id: "file-line-60" + timeout: 15000 +- takeScreenshot: phase6-files-readme-line-60 +# Back to the Files launcher: it stayed mounted (retainWhenInactive), so the +# "README" query is still there; clear it → thread storage browser → notes › +# plan.md. +- tapOn: + id: "panel-tab-files" +- extendedWaitUntil: + visible: + id: "files-search-clear" + timeout: 15000 +- tapOn: + id: "files-search-clear" +- extendedWaitUntil: + visible: + id: "storage-directory-row" + timeout: 15000 +- tapOn: + id: "storage-directory-row" + index: 0 +- extendedWaitUntil: + visible: "plan.md" + timeout: 15000 +- tapOn: "plan.md" +- extendedWaitUntil: + visible: + id: "file-preview-markdown-body" + timeout: 30000 +- assertVisible: "Build the files tab" +- takeScreenshot: phase6-files-storage-preview +# Close the panel (tap the backdrop), then the full-screen preview route by +# deep link: highlighted line 12 of src/app.ts. +- tapOn: + point: "50%,8%" +- extendedWaitUntil: + notVisible: + id: "workspace-panel-tab-strip" + timeout: 10000 +- openLink: "bb://threads/${THREAD_ID}/files?kind=workspace&path=src%2Fapp.ts&line=12" +- extendedWaitUntil: + visible: + id: "file-preview-name" + text: "app.ts" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "file-line-12" + timeout: 15000 +- takeScreenshot: phase6-files-route-line-12 +- longPressOn: + id: "file-line-12" +- extendedWaitUntil: + visible: + id: "action-sheet-copy-line" + timeout: 10000 +- assertVisible: + id: "action-sheet-add-to-chat" +- tapOn: + id: "action-sheet-copy-line" +- extendedWaitUntil: + visible: "Line copied" + timeout: 10000 +- takeScreenshot: phase6-files-line-copied diff --git a/apps/mobile/e2e/flows/phase6-panel.yaml b/apps/mobile/e2e/flows/phase6-panel.yaml new file mode 100644 index 0000000000..af0fc30f28 --- /dev/null +++ b/apps/mobile/e2e/flows/phase6-panel.yaml @@ -0,0 +1,96 @@ +# Phase 6 workspace panel shell: add the harness server → open the thread +# named by THREAD_TITLE → the header's panel button presents the bottom +# sheet → the Info tab shows Directory / Branch / Git status → "Changed files" +# selects the Diff tab (its body may still be a placeholder: the flow asserts +# the tab, not the content) → Files / Terminal launcher entries exist → swipe +# the sheet away. +# +# Setup (the Info rows need a git worktree with changes): create a +# managed-worktree thread through the API, write a file into its checkout, +# and pass the title: +# maestro test -e THREAD_TITLE="P6 panel thread" e2e/flows/phase6-panel.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "P6 panel thread" +--- +- runFlow: ../subflows/launch-to-home.yaml +- extendedWaitUntil: + visible: "${THREAD_TITLE}" + timeout: 30000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-detail-title" + text: "${THREAD_TITLE}" + timeout: 30000 +# The header's panel button presents the sheet on the Info tab. +- extendedWaitUntil: + visible: + id: "thread-panel-button" + timeout: 10000 +- tapOn: + id: "thread-panel-button" +- extendedWaitUntil: + visible: + id: "workspace-panel-tab-strip" + timeout: 15000 +- assertVisible: + id: "panel-tab-thread-info" +- assertVisible: + id: "panel-tab-files" +- assertVisible: + id: "panel-tab-terminal" +- extendedWaitUntil: + visible: + id: "panel-info-directory-path" + timeout: 20000 +- assertVisible: + id: "panel-info-branch-name" +- assertVisible: + id: "panel-info-git-status" +- extendedWaitUntil: + visible: + id: "panel-info-changed-files" + timeout: 20000 +- takeScreenshot: phase6-panel-info +# Changed files → the Diff tab becomes the selected strip entry. +- tapOn: + id: "panel-info-changed-files-uncommitted" +- extendedWaitUntil: + visible: + id: "panel-tab-git-diff" + selected: true + timeout: 10000 +- assertNotVisible: + id: "panel-info" +- takeScreenshot: phase6-panel-diff-tab +# Back to Info through the strip, then the Files launcher. +- tapOn: + id: "panel-tab-thread-info" +- extendedWaitUntil: + visible: + id: "panel-info" + timeout: 10000 +- tapOn: + id: "panel-tab-files" +- extendedWaitUntil: + visible: + id: "panel-tab-files" + selected: true + timeout: 10000 +- assertNotVisible: + id: "panel-info" +- takeScreenshot: phase6-panel-files +# Swipe the sheet away; the thread screen is back. +- swipe: + start: "50%, 20%" + end: "50%, 95%" + duration: 400 +- extendedWaitUntil: + notVisible: + id: "workspace-panel-tab-strip" + timeout: 10000 +- assertVisible: + id: "thread-detail-title" diff --git a/apps/mobile/e2e/flows/phase6-terminal-resume.yaml b/apps/mobile/e2e/flows/phase6-terminal-resume.yaml new file mode 100644 index 0000000000..4dd065deca --- /dev/null +++ b/apps/mobile/e2e/flows/phase6-terminal-resume.yaml @@ -0,0 +1,76 @@ +# Phase 6 terminal, suspend / resume: start a terminal, run a slow producer, +# send the app to the background for 20 s (React Native owns the attach +# socket and suspends it there), come back, and the output printed while the +# app was away is replayed from the last sequence number seen — no gap, no +# reset. Read back through the dev-only text mirror (`terminal-text-mirror`). +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +# maestro test -e THREAD_TITLE="Idle thread" e2e/flows/phase6-terminal-resume.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "Idle thread" +--- +- runFlow: ../subflows/launch-to-home.yaml +- extendedWaitUntil: + visible: "${THREAD_TITLE}" + timeout: 30000 +- tapOn: "${THREAD_TITLE}" +- tapOn: + id: "thread-panel-button" +- extendedWaitUntil: + visible: + id: "panel-tab-terminal" + timeout: 20000 +- tapOn: + id: "panel-tab-terminal" +- extendedWaitUntil: + visible: + id: "terminal-sessions-start" + timeout: 20000 +- tapOn: + id: "terminal-sessions-start" +- extendedWaitUntil: + visible: + id: "panel-terminal-tab" + timeout: 30000 +- tapOn: + id: "panel-terminal-title" +- extendedWaitUntil: + visible: + id: "terminal-text-mirror" + text: ".*test-project.*" + timeout: 30000 +# A slow producer that keeps printing while the app is in the background. +- inputText: "for i in $(seq 1 60); do echo tick-$i; sleep 0.5; done" +- pressKey: Enter +- extendedWaitUntil: + visible: + id: "terminal-text-mirror" + text: ".*tick-3.*" + timeout: 20000 +- pressKey: Home +- extendedWaitUntil: + notVisible: + id: "terminal-accessory-bar" + timeout: 10000 +# 20 s in the background (a no-op swipe is Maestro's wait). +- swipe: + start: "50%, 50%" + end: "50%, 50%" + duration: 20000 +- launchApp: + appId: "app.getbb.mobile" + stopApp: false +- extendedWaitUntil: + visible: + id: "terminal-accessory-bar" + timeout: 30000 +# Everything printed while suspended is replayed from `sinceSeq`. +- extendedWaitUntil: + visible: + id: "terminal-text-mirror" + text: ".*tick-60.*" + timeout: 60000 +- takeScreenshot: phase6-terminal-resume diff --git a/apps/mobile/e2e/flows/phase6-terminal.yaml b/apps/mobile/e2e/flows/phase6-terminal.yaml new file mode 100644 index 0000000000..5ce41e76eb --- /dev/null +++ b/apps/mobile/e2e/flows/phase6-terminal.yaml @@ -0,0 +1,125 @@ +# Phase 6 terminal: add the harness server → open a thread → workspace panel → +# Terminal tab → Start terminal → the session attaches in the panel (xterm in +# a WebView, the attach socket owned by React Native) → open it full screen → +# type a command on the keyboard → its output shows up (read back through the +# dev-only text mirror under the terminal, `terminal-text-mirror`: a WebView's +# text is invisible to the accessibility tree) → the accessory bar's sticky +# Ctrl + c interrupts and ArrowUp recalls the last command → the "…" menu +# renames the session. +# +# Setup: any thread of the backend works; pass its title. +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999) +# maestro test -e THREAD_TITLE="Idle thread" e2e/flows/phase6-terminal.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" + THREAD_TITLE: "Idle thread" +--- +- runFlow: ../subflows/launch-to-home.yaml +- extendedWaitUntil: + visible: "${THREAD_TITLE}" + timeout: 30000 +- tapOn: "${THREAD_TITLE}" +- extendedWaitUntil: + visible: + id: "thread-detail-screen" + timeout: 30000 +# Workspace panel → Terminal. +- tapOn: + id: "thread-panel-button" +- extendedWaitUntil: + visible: + id: "panel-tab-terminal" + timeout: 20000 +- tapOn: + id: "panel-tab-terminal" +- extendedWaitUntil: + visible: + id: "terminal-sessions-start" + timeout: 20000 +- takeScreenshot: phase6-terminal-launcher +- tapOn: + id: "terminal-sessions-start" +# The started session becomes a panel tab with its own toolbar. +- extendedWaitUntil: + visible: + id: "panel-terminal-tab" + timeout: 30000 +- takeScreenshot: phase6-terminal-panel +# Full screen through the tab's title. +- tapOn: + id: "panel-terminal-title" +- extendedWaitUntil: + visible: + id: "terminal-screen" + timeout: 30000 +- extendedWaitUntil: + visible: + id: "terminal-accessory-bar" + timeout: 20000 +# The shell prompt reached the page (mirrored under the terminal). +- extendedWaitUntil: + visible: + id: "terminal-text-mirror" + text: ".*test-project.*" + timeout: 30000 +# The full-screen terminal focused itself on open: typing lands in the shell. +- inputText: "echo bb-4" +- inputText: "2" +- pressKey: Enter +- extendedWaitUntil: + visible: + id: "terminal-text-mirror" + text: ".*bb-42.*" + timeout: 30000 +- takeScreenshot: phase6-terminal-echo +# Accessory bar: sticky Ctrl then "c" interrupts a running command. +- inputText: "sleep 45" +- pressKey: Enter +- tapOn: + id: "terminal-key-ctrl" +- inputText: "c" +- extendedWaitUntil: + visible: + id: "terminal-text-mirror" + text: ".*sleep 45.*" + timeout: 30000 +# ArrowUp recalls the previous command line, Ctrl+u clears it again. +- tapOn: + id: "terminal-key-ArrowUp" +- tapOn: + id: "terminal-key-ctrl" +- inputText: "u" +- takeScreenshot: phase6-terminal-keys +# Terminal menu: rename the session. (The accessory bar's "…" mirrors the +# header's, which the dev client's floating gear covers on large simulators.) +- tapOn: + id: "terminal-key-menu" +- extendedWaitUntil: + visible: + id: "terminal-action-rename" + timeout: 10000 +- assertVisible: + id: "terminal-action-restart" +- assertVisible: + id: "terminal-action-new" +- assertVisible: + id: "terminal-action-close" +- takeScreenshot: phase6-terminal-menu +- tapOn: + id: "terminal-action-rename" +- extendedWaitUntil: + visible: + id: "terminal-rename-input" + timeout: 10000 +- tapOn: + id: "terminal-rename-input" +- eraseText: 60 +- inputText: "P6 shell" +- tapOn: + id: "terminal-rename-submit" +- extendedWaitUntil: + visible: "P6 shell" + timeout: 15000 +- takeScreenshot: phase6-terminal-renamed diff --git a/apps/mobile/e2e/flows/phase7-plugins.yaml b/apps/mobile/e2e/flows/phase7-plugins.yaml new file mode 100644 index 0000000000..c362ba125a --- /dev/null +++ b/apps/mobile/e2e/flows/phase7-plugins.yaml @@ -0,0 +1,159 @@ +# Phase 7 plugins / extensions against the harness backend: Settings → +# Plugins (the integration harness runs no plugin service, so the installed +# list is the empty state) → Marketplaces (the harness seeds `bb-community`) +# → Skills library (the built-in `bb-cli` skill, read-only detail) → skills.sh +# registry browse (live proxy: a list, or the empty / unavailable state when +# offline). The dev-server variant with real plugins lives in +# e2e/manual/phase7-plugins-devserver.yaml. +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runFlow: ../subflows/launch-to-home.yaml +# Workspace menu → Settings. +- runFlow: ../subflows/open-settings.yaml +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +# Plugins: the harness has no plugins → empty state + Browse / Marketplaces rows. +- scrollUntilVisible: + element: + id: "settings-plugins" + direction: DOWN +- tapOn: + id: "settings-plugins" +- extendedWaitUntil: + visible: + id: "plugins-screen" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "plugins-empty" + timeout: 20000 +- assertVisible: + id: "plugins-browse" +- assertVisible: + id: "plugins-marketplaces" +- takeScreenshot: p7-plugins-list +# Marketplaces: the seeded bb-community marketplace renders with its entry count. +- tapOn: + id: "plugins-marketplaces" +- extendedWaitUntil: + visible: + id: "marketplaces-screen" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "marketplace-row-bb-community" + timeout: 20000 +- assertVisible: ".*BB Community.*" +- takeScreenshot: p7-plugins-marketplaces +# The add-marketplace sheet (also behind the header "+", which the dev +# client's floating gear can cover on larger simulators). +- tapOn: + id: "marketplaces-add-row" +- extendedWaitUntil: + visible: + id: "add-marketplace-sheet" + timeout: 10000 +- assertVisible: + id: "add-marketplace-submit" +- takeScreenshot: p7-plugins-add-marketplace +- tapOn: "Cancel" +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "plugins-screen" + timeout: 10000 +# Browse catalog: the harness seeds two bb-community entries. +- tapOn: + id: "plugins-browse" +- extendedWaitUntil: + visible: + id: "plugin-browse-screen" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "plugin-browse-group-.*|plugin-browse-empty" + timeout: 20000 +- takeScreenshot: p7-plugins-browse +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "plugins-screen" + timeout: 10000 +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +# Skills library: the built-in bb-cli skill, then its read-only detail. +- scrollUntilVisible: + element: + id: "settings-skills" + direction: DOWN +- tapOn: + id: "settings-skills" +- extendedWaitUntil: + visible: + id: "skills-screen" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "skill-row-.*" + timeout: 20000 +# The in-process daemon discovers this Mac's real skill folders too, so the +# library can be long: filter down to the built-in bb-cli skill when the +# filter field is shown (more than a handful of skills). +- runFlow: + when: + visible: + id: "skills-filter" + commands: + - tapOn: + id: "skills-filter" + - inputText: "bb-cli" + - tapOn: ".*My skills.*" +- extendedWaitUntil: + visible: + id: "skill-row-bb-cli" + timeout: 20000 +- takeScreenshot: p7-plugins-skills +- tapOn: + id: "skill-row-bb-cli" +- extendedWaitUntil: + visible: + id: "skill-detail-screen" + timeout: 15000 +- assertVisible: + id: "skill-detail-name" + text: "bb-cli" +# The rendered SKILL.md (its first heading); the content card itself is +# taller than the screen, which Maestro does not count as visible. +- extendedWaitUntil: + visible: ".*bb CLI.*" + timeout: 20000 +- takeScreenshot: p7-plugins-skill-detail +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "skills-screen" + timeout: 10000 +# skills.sh registry: a live list, or the empty / unavailable state offline. +- tapOn: + id: "skills-browse" +- extendedWaitUntil: + visible: + id: "registry-skills-screen" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "registry-skill-row-.*|registry-skills-empty|registry-skills-unavailable" + timeout: 40000 +- takeScreenshot: p7-plugins-registry diff --git a/apps/mobile/e2e/flows/phase7-settings.yaml b/apps/mobile/e2e/flows/phase7-settings.yaml new file mode 100644 index 0000000000..6051e1a5bf --- /dev/null +++ b/apps/mobile/e2e/flows/phase7-settings.yaml @@ -0,0 +1,171 @@ +# Phase 7 settings: Settings home → Experiments (toggle "New onboarding", +# persisted server-side: re-open shows it on, the API agrees) → Appearance +# (palette → Nord: the row shows "Nord", the API agrees, the UI re-tints) → +# Machines (the harness host row → detail → rename → the list shows the new +# name) → restore. Resets the server settings it touches at start and end, so +# it is safe on a shared harness backend. +# +# Requires Metro with EXPO_PUBLIC_BB_E2E=1 (first run on every launch) and the +# harness backend on 41999. Run: JAVA_HOME=/opt/homebrew/opt/openjdk@17 +# maestro --device <udid> test e2e/flows/phase7-settings.yaml +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:41999" +--- +- runScript: ../scripts/phase7-settings-reset.js +- runScript: ../scripts/phase7-machine-name.js +- runFlow: ../subflows/launch-to-home.yaml +# Workspace menu → Settings. +- runFlow: ../subflows/open-settings.yaml +- extendedWaitUntil: + visible: + id: "settings-experiments" + timeout: 10000 +- takeScreenshot: p7-settings-home +# Experiments: toggle "New onboarding" on (PUT /settings/experiments). +- tapOn: + id: "settings-experiments" +- extendedWaitUntil: + visible: + id: "experiment-newOnboarding" + timeout: 15000 +- tapOn: + id: "experiment-newOnboarding" +- extendedWaitUntil: + visible: + id: "experiment-newOnboarding" + checked: true + timeout: 10000 +- takeScreenshot: p7-settings-experiments +# Persisted: leave, come back, still on; and the server says so. +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "settings-experiments" + timeout: 10000 +- tapOn: + id: "settings-experiments" +- extendedWaitUntil: + visible: + id: "experiment-newOnboarding" + checked: true + timeout: 15000 +- runScript: + file: ../scripts/phase7-settings-assert.js + env: + EXPECT_NEW_ONBOARDING: "true" + EXPECT_THEME_ID: "default" +# Toggle it back off through the UI (the reset at the end covers failures). +- tapOn: + id: "experiment-newOnboarding" +- extendedWaitUntil: + visible: + id: "experiment-newOnboarding" + checked: false + timeout: 10000 +- tapOn: + id: "BackButton" +# Appearance: palette → Nord (PUT /settings/appearance; the app re-tints from +# the refetched /system/config). +- extendedWaitUntil: + visible: + id: "settings-appearance" + timeout: 10000 +- tapOn: + id: "settings-appearance" +- extendedWaitUntil: + visible: + id: "appearance-palette" + timeout: 15000 +- assertVisible: + id: "appearance-mode-system" +- tapOn: + id: "appearance-palette" +- extendedWaitUntil: + visible: + id: "appearance-palette-option-nord" + timeout: 10000 +- tapOn: + id: "appearance-palette-option-nord" +- extendedWaitUntil: + visible: + id: "appearance-palette" + text: ".*Nord.*" + timeout: 15000 +- runScript: + file: ../scripts/phase7-settings-assert.js + env: + EXPECT_NEW_ONBOARDING: "false" + EXPECT_THEME_ID: "nord" +- takeScreenshot: p7-settings-appearance-nord +- tapOn: + id: "BackButton" +# Machines (below the fold): the harness host → detail → rename → list shows +# the new name. +- extendedWaitUntil: + visible: + id: "settings-appearance" + timeout: 10000 +- scrollUntilVisible: + element: + id: "settings-machines" + direction: DOWN + timeout: 20000 +- tapOn: + id: "settings-machines" +- extendedWaitUntil: + visible: + id: "machine-row-${output.hostId}" + timeout: 15000 +- assertVisible: ".*${output.hostName}.*" +- takeScreenshot: p7-settings-machines +- tapOn: + id: "machine-row-${output.hostId}" +- extendedWaitUntil: + visible: + id: "machine-detail-name" + text: "${output.hostName}" + timeout: 15000 +- assertVisible: + id: "machine-permission-ceiling" +- assertVisible: + id: "machine-rename-row" +- extendedWaitUntil: + visible: + id: "machine-provider-cli-codex" + timeout: 30000 +- takeScreenshot: p7-settings-machine-detail +- tapOn: + id: "machine-rename-row" +- extendedWaitUntil: + visible: + id: "machine-rename-input" + timeout: 10000 +- tapOn: + id: "machine-rename-input" +- eraseText: 80 +- inputText: "P7 renamed machine" +- tapOn: + id: "machine-rename-save" +- extendedWaitUntil: + visible: + id: "machine-detail-name" + text: "P7 renamed machine" + timeout: 15000 +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: ".*P7 renamed machine.*" + timeout: 15000 +- takeScreenshot: p7-settings-machines-renamed +# Restore the name and the server settings. +- runScript: + file: ../scripts/phase7-machine-name.js + env: + RESTORE_NAME: "${output.hostName}" +- extendedWaitUntil: + visible: ".*${output.hostName}.*" + timeout: 15000 +- runScript: ../scripts/phase7-settings-reset.js diff --git a/apps/mobile/e2e/flows/smoke.yaml b/apps/mobile/e2e/flows/smoke.yaml new file mode 100644 index 0000000000..03761f328b --- /dev/null +++ b/apps/mobile/e2e/flows/smoke.yaml @@ -0,0 +1,56 @@ +# Phase 0 smoke (kept as diagnostics): the dev-client opens the Metro bundle, +# the runtime spike screen (Settings → Developer, route /dev/spike) renders, +# workspace packages evaluate, and the app can reach the harness backend +# over HTTP and WebSocket. +# +# Run: pnpm --filter @bb/mobile e2e:ios (Metro on 8082, backend on 41999); +# against a Release build add `-e BB_E2E_EMBEDDED_BUNDLE=1` (see +# ../subflows/launch-app.yaml). +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" +--- +- runFlow: ../subflows/launch-app.yaml +# The spike lives under /dev now; deep-link straight to it (a fresh simulator +# confirms the first `bb://` link). +- openLink: "bb://dev/spike" +- runFlow: + when: + visible: "Open" + commands: + - tapOn: "Open" +- extendedWaitUntil: + visible: ".*Phase 0 spike.*" + timeout: 30000 +- assertVisible: + id: "check-@bb/domain builtInThemes" +- assertVisible: + text: "✓ @bb/thread-view fileNameFromPath: c.ts" +- assertVisible: + text: "✓ crypto.getRandomValues.*" +- tapOn: + id: "probe-http" +- extendedWaitUntil: + visible: ".*HTTP ok: serverUrl=.*" + timeout: 15000 +- tapOn: + id: "raw-ws" +- extendedWaitUntil: + visible: ".*raw WS open.*" + timeout: 15000 +- tapOn: + id: "open-realtime" +- extendedWaitUntil: + visible: "realtime: connected" + timeout: 15000 +# The realtime protocol is invalidation-only: nothing arrives until state +# changes. Reloading server config broadcasts `system: config-changed`. +- tapOn: + id: "poke-system" +- extendedWaitUntil: + visible: ".*system changed: config-changed.*" + timeout: 15000 +- extendedWaitUntil: + visible: ".*raw WS msg.*config-changed.*" + timeout: 15000 +- takeScreenshot: spike-screen diff --git a/apps/mobile/e2e/manual/demo-server.yaml b/apps/mobile/e2e/manual/demo-server.yaml new file mode 100644 index 0000000000..73815420cc --- /dev/null +++ b/apps/mobile/e2e/manual/demo-server.yaml @@ -0,0 +1,90 @@ +# Demo server end-to-end: first run → add the bb demo server by Direct URL → +# thread list → open a thread → send a message → "Working…" → the scripted +# reply arrives and the indicator clears. +# +# This is the exact path an App Store reviewer follows from the review notes, +# so it must pass before the demo server is deployed. Not part of +# `pnpm e2e:ios` (e2e/flows) because it needs the demo worker, not the +# harness backend: +# +# pnpm --filter @bb/demo-server dev # the worker on 8799 +# EXPO_PUBLIC_BB_E2E=1 pnpm --filter @bb/mobile start --port 8082 +# cd apps/mobile && maestro test e2e/manual/demo-server.yaml +# +# To rehearse against the deployed worker, set SERVER_URL to its https URL. +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:8799" +--- +# The E2E build wipes profiles on launch, so the shared subflow lands on +# "Connect to a bb server" and adds SERVER_URL as a Direct URL. The probe +# (/health + /system/config) must pass against the worker. +# +# Launch is inlined rather than `runFlow: ../subflows/launch-to-home.yaml` +# because this simulator refuses the dev-client deep link +# (LSApplicationWorkspaceErrorDomain 115), so `openLink` in +# subflows/launch-app.yaml fails. The launcher's "Recently opened" row for +# Metro reaches the same place. The add-server steps and the Home wait below +# are copied from that subflow unchanged; only the launch differs, and a +# Release/TestFlight build needs none of this. +- stopApp +- launchApp +- runFlow: + when: + visible: "${METRO_URL}" + commands: + - tapOn: "${METRO_URL}" +# Wait for the bundle before touching the screen: a bare `when: visible` +# evaluates immediately and skips while Metro is still serving. The E2E build +# wipes profiles on launch, so first run is guaranteed here. +- extendedWaitUntil: + visible: "Connect to a bb server" + timeout: 60000 +- tapOn: + id: "server-url-input" +- inputText: "${SERVER_URL}" +- tapOn: + id: "server-label-input" +- inputText: "E2E backend" +- tapOn: "Server URL" +- tapOn: + id: "add-server-submit" +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 30000 +- takeScreenshot: demo-home +# The seeded threads come from the demo's sidebar-bootstrap. +- assertVisible: "Add a dark mode toggle" +- assertVisible: "Fix the flaky checkout test" +- tapOn: "Add a dark mode toggle" +# Timeline rows: the seeded user prompt, a command row, and the assistant reply. +- extendedWaitUntil: + visible: "Add a dark mode toggle to the settings screen." + timeout: 30000 +- takeScreenshot: demo-thread +# Sending: the demo records the message, turns the thread active, and +# announces the change over the socket so the app refetches. The composer is +# a pill until focused, so tap the input twice: once to expand the card, once +# to focus the field inside it. +- tapOn: + id: "thread-composer-input" +- tapOn: + id: "thread-composer-input" +- inputText: "Yes, go ahead." +- tapOn: + id: "thread-composer-submit" +- extendedWaitUntil: + visible: "Yes, go ahead." + timeout: 30000 +# The reply lands about two seconds later and the thread returns to idle, so +# "Working…" must clear. The reply is a markdown paragraph, so match a fragment +# rather than the whole text node. +- extendedWaitUntil: + visible: ".*replaying a scripted answer.*" + timeout: 30000 +- extendedWaitUntil: + notVisible: "Working…" + timeout: 15000 +- takeScreenshot: demo-reply diff --git a/apps/mobile/e2e/manual/phase7-plugins-devserver.yaml b/apps/mobile/e2e/manual/phase7-plugins-devserver.yaml new file mode 100644 index 0000000000..aaaeb5c4c6 --- /dev/null +++ b/apps/mobile/e2e/manual/phase7-plugins-devserver.yaml @@ -0,0 +1,135 @@ +# Phase 7 plugins against the checkout's dev server (`scripts/bb-dev-app +# current`; server port 20304 for this worktree — edit SERVER_URL for another +# checkout), which runs the real builtin plugins. Read-mostly: opens the +# Automations detail (settings-less builtin → "no settings" state, Updates → +# "bundled" note, logs viewer), the default-enabled provider-retry settings, +# and Browse (BB Official group). Not part of `pnpm e2e:ios` (e2e/flows) +# because it needs a running dev server. +appId: app.getbb.mobile +env: + METRO_URL: "http://127.0.0.1:8082" + SERVER_URL: "http://127.0.0.1:20304" +--- +# Wipe any saved profile first (dev builds accept the reset deep link) so the +# launch subflow adds the dev server instead of reusing a harness profile. +# The app must already be running from Metro (a cold `bb://` link lands on +# the dev-client launcher) and sitting on a screen that survives losing the +# profile (home), so: launch, reset, launch again → first run → add server. +- runFlow: ../subflows/launch-app.yaml +- openLink: "bb://e2e/reset" +- extendedWaitUntil: + visible: ".*Connect to a bb server.*" + timeout: 60000 +- runFlow: ../subflows/launch-to-home.yaml +- runFlow: ../subflows/open-settings.yaml +- extendedWaitUntil: + visible: + id: "settings-screen" + timeout: 10000 +- scrollUntilVisible: + element: + id: "settings-plugins" + direction: DOWN +- tapOn: + id: "settings-plugins" +- extendedWaitUntil: + visible: + id: "plugins-screen" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "plugin-row-automations" + timeout: 30000 +- takeScreenshot: p7-plugins-dev-list +- tapOn: + id: "plugin-row-automations" +- extendedWaitUntil: + visible: + id: "plugin-detail-screen" + timeout: 15000 +- assertVisible: + id: "plugin-detail-name" + text: "Automations" +- scrollUntilVisible: + element: + id: "plugin-settings-none" + direction: DOWN +- assertVisible: "This plugin has no settings." +- scrollUntilVisible: + element: + id: "plugin-detail-updates-builtin" + direction: DOWN +- takeScreenshot: p7-plugins-dev-detail +- scrollUntilVisible: + element: + id: "plugin-detail-logs" + direction: DOWN +- tapOn: + id: "plugin-detail-logs" +- extendedWaitUntil: + visible: + id: "plugin-logs-screen" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "plugin-logs-list|plugin-logs-empty" + timeout: 20000 +- takeScreenshot: p7-plugins-dev-logs +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "plugin-detail-screen" + timeout: 10000 +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "plugins-screen" + timeout: 10000 +# A default-enabled plugin with a host-rendered settings schema. +- scrollUntilVisible: + element: + id: "plugin-row-provider-retry" + direction: DOWN +- tapOn: + id: "plugin-row-provider-retry" +- extendedWaitUntil: + visible: + id: "plugin-detail-screen" + timeout: 15000 +- scrollUntilVisible: + element: + id: "plugin-settings-form" + direction: DOWN +- assertVisible: + id: "plugin-setting-maximumWait" + text: "6 hours" +- takeScreenshot: p7-plugins-dev-settings +- tapOn: + id: "BackButton" +- extendedWaitUntil: + visible: + id: "plugins-screen" + timeout: 10000 +- scrollUntilVisible: + element: + id: "plugins-browse" + direction: UP +- tapOn: + id: "plugins-browse" +- extendedWaitUntil: + visible: + id: "plugin-browse-screen" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "plugin-browse-group-builtin" + timeout: 30000 +- takeScreenshot: p7-plugins-dev-browse +# Leave the simulator at first run again for the harness flows. +- runFlow: ../subflows/launch-app.yaml +- openLink: "bb://e2e/reset" +- extendedWaitUntil: + visible: ".*Connect to a bb server.*" + timeout: 60000 diff --git a/apps/mobile/e2e/scripts/ci-run-flows.sh b/apps/mobile/e2e/scripts/ci-run-flows.sh new file mode 100755 index 0000000000..ecbbc174d0 --- /dev/null +++ b/apps/mobile/e2e/scripts/ci-run-flows.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Runs the Maestro flows CI can run against a Release build of the app (the +# embedded JS bundle, no Metro) and a fresh mobile e2e backend, one `maestro +# test` per flow so each gets its own artifacts, continuing past failures and +# exiting non-zero if any flow failed. +# +# Usage: +# SERVER_URL=http://127.0.0.1:41999 \ +# e2e/scripts/ci-run-flows.sh <simulator udid> <artifacts dir> [flow...] +# +# Default flows (in this order; later ones depend on seeds the earlier ones +# leave alone): smoke, phase1-shell, phase4a-timeline, phase3-compose, +# phase4b-send, phase6-panel. The script creates the threads the title-based +# flows open ("P4b send" idle; "P6 panel thread" with a dirty managed +# worktree) through the API first. +# +# Environment: SERVER_URL (default http://127.0.0.1:41999; the flows' own +# env blocks point at the same port), MAESTRO_FLAGS (extra `maestro test` +# flags). Needs maestro + java on PATH. Every flow gets +# `-e BB_E2E_EMBEDDED_BUNDLE=1` (see ../subflows/launch-app.yaml); pass +# `--dev-client` as the first argument to drive a dev client through Metro +# instead (local use). +# (bash 3.2 on macOS: empty arrays are expanded with the `${arr[@]+"${arr[@]}"}` +# idiom so `set -u` does not trip.) +set -uo pipefail + +cd "$(dirname "$0")/.." + +LAUNCH_ENV=(-e BB_E2E_EMBEDDED_BUNDLE=1) +if [ "${1:-}" = "--dev-client" ]; then + LAUNCH_ENV=() + shift +fi + +UDID="${1:?simulator udid}" +ARTIFACTS="${2:?artifacts dir}" +shift 2 +FLOWS=("$@") +if [ ${#FLOWS[@]} -eq 0 ]; then + FLOWS=(smoke phase1-shell phase4a-timeline phase3-compose phase4b-send phase6-panel) +fi + +export SERVER_URL="${SERVER_URL:-http://127.0.0.1:41999}" +mkdir -p "$ARTIFACTS" + +needs() { + local flow + for flow in "${FLOWS[@]}"; do + [ "$flow" = "$1" ] && return 0 + done + return 1 +} + +# Seeds for the title-based flows (idempotent per title). +if needs phase4b-send; then + THREAD_TITLE="P4b send" scripts/create-idle-thread.sh +fi +if needs phase6-panel; then + THREAD_TITLE="P6 panel thread" scripts/phase6-diff-setup.sh +fi + +failed=() +for flow in "${FLOWS[@]}"; do + file="flows/$flow.yaml" + out="$ARTIFACTS/$flow" + mkdir -p "$out" + echo "::group::maestro $flow" + # shellcheck disable=SC2086 + if maestro --device "$UDID" test \ + ${LAUNCH_ENV[@]+"${LAUNCH_ENV[@]}"} \ + --format junit --output "$out/junit.xml" \ + --test-output-dir "$out" \ + ${MAESTRO_FLAGS:-} \ + "$file" 2>&1 | tee "$out/maestro.log"; then + echo "PASS $flow" + else + echo "FAIL $flow" + failed+=("$flow") + # A failed flow can leave the app on any screen; the next flow cold-starts + # it, but keep a screenshot of where this one ended. + xcrun simctl io "$UDID" screenshot "$out/final-screen.png" >/dev/null 2>&1 || true + fi + echo "::endgroup::" +done + +if [ ${#failed[@]} -gt 0 ]; then + echo "Failed flows: ${failed[*]}" >&2 + exit 1 +fi +echo "All ${#FLOWS[@]} flows passed" diff --git a/apps/mobile/e2e/scripts/connect-stub-control.js b/apps/mobile/e2e/scripts/connect-stub-control.js new file mode 100644 index 0000000000..9308c1639c --- /dev/null +++ b/apps/mobile/e2e/scripts/connect-stub-control.js @@ -0,0 +1,19 @@ +// Maestro `runScript`: drive the mobile-e2e connect stub's control endpoint +// (tests/integration/mobile-e2e/connect-stub.ts) over its plain-HTTP control +// port. Env: STUB_ACTION (expire-session | revoke-machine | reset), +// STUB_CONTROL_URL (default http://127.0.0.1:42997). +const action = STUB_ACTION; +const base = + typeof STUB_CONTROL_URL === "string" && STUB_CONTROL_URL.length > 0 + ? STUB_CONTROL_URL + : "http://127.0.0.1:42997"; +const response = http.post(`${base}/__stub/${action}`, { + headers: { "content-type": "application/json" }, + body: "{}", +}); +if (!response.ok) { + throw new Error( + `connect stub ${action} failed: HTTP ${response.status} ${response.body}`, + ); +} +output.stubControl = response.body; diff --git a/apps/mobile/e2e/scripts/create-idle-thread.sh b/apps/mobile/e2e/scripts/create-idle-thread.sh new file mode 100755 index 0000000000..1f8d6d3a9f --- /dev/null +++ b/apps/mobile/e2e/scripts/create-idle-thread.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Creates (or reuses) an idle thread titled "$THREAD_TITLE" in the harness +# project of a running mobile e2e backend, for the flows that open a thread by +# a fixed title (`phase4b-send.yaml` → "P4b send", `phase4b-ask-user.yaml` → +# "P4b ask user", …). The thread runs on the harness host with no managed +# worktree, like the seeded "Idle thread"; the bootstrap turn finishes before +# the script returns. Prints `{threadId}` as JSON. +# +# SERVER_URL=http://127.0.0.1:41999 THREAD_TITLE="P4b send" e2e/scripts/create-idle-thread.sh +# +# Requires curl + python3. Idempotent per title. +set -euo pipefail + +SERVER_URL="${SERVER_URL:-http://127.0.0.1:41999}" +THREAD_TITLE="${THREAD_TITLE:-P4b send}" +API="$SERVER_URL/api/v1" + +json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; } + +project_id="$(curl -fsS "$API/projects" | json 'd[0]["id"] if isinstance(d, list) else d["projects"][0]["id"]')" +host_id="$(curl -fsS "$API/hosts" | json 'd[0]["id"] if isinstance(d, list) else d["hosts"][0]["id"]')" + +existing="$(curl -fsS "$API/threads?projectId=$project_id" | python3 -c " +import json,sys +d=json.load(sys.stdin) +threads=d if isinstance(d, list) else d.get('threads', d.get('items', [])) +for t in threads: + if t.get('title') == '$THREAD_TITLE' and not t.get('archivedAt'): + print(t['id']); break +")" + +if [ -n "$existing" ]; then + thread_id="$existing" +else + thread_id="$(curl -fsS -X POST "$API/threads" -H 'content-type: application/json' -d "$(cat <<JSON +{ + "environment": {"type": "host", "hostId": "$host_id", "workspace": {"type": "unmanaged", "path": null}}, + "input": [{"type": "text", "text": "Reply with exactly READY and nothing else.", "mentions": []}], + "origin": "app", + "model": "fake-model", + "projectId": "$project_id", + "providerId": "fake", + "title": "$THREAD_TITLE", + "startedOnBehalfOf": null, + "originKind": null +} +JSON +)" | json 'd["id"]')" +fi + +# Wait for the bootstrap turn to finish so the flow finds an idle composer. +for _ in $(seq 1 60); do + thread_status="$(curl -fsS "$API/threads/$thread_id" | json 'd["status"]')" + if [ "$thread_status" = "idle" ]; then break; fi + sleep 0.5 +done +if [ "$thread_status" != "idle" ]; then + echo "thread $thread_id did not become idle (status: $thread_status)" >&2 + exit 1 +fi + +python3 -c "import json; print(json.dumps({'threadId': '$thread_id', 'title': '$THREAD_TITLE'}))" diff --git a/apps/mobile/e2e/scripts/phase6-commit.js b/apps/mobile/e2e/scripts/phase6-commit.js new file mode 100644 index 0000000000..7f4e2448f7 --- /dev/null +++ b/apps/mobile/e2e/scripts/phase6-commit.js @@ -0,0 +1,22 @@ +// Maestro runScript: commit the dirty worktree of the thread titled +// THREAD_TITLE through `POST /environments/:id/actions` so the Diff tab's +// target picker gains the "Committed changes" target. Env: SERVER_URL, +// THREAD_TITLE (Maestro exposes flow env as globals). +const threads = json(http.get(`${SERVER_URL}/api/v1/threads`).body); +const thread = threads.find( + (entry) => entry.title === THREAD_TITLE && !entry.archivedAt, +); +if (!thread) { + throw new Error(`No thread titled ${THREAD_TITLE}`); +} +const response = http.post( + `${SERVER_URL}/api/v1/environments/${thread.environmentId}/actions`, + { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "commit" }), + }, +); +if (!response.ok) { + throw new Error(`commit failed: ${response.status} ${response.body}`); +} +output.commitSha = json(response.body).commitSha; diff --git a/apps/mobile/e2e/scripts/phase6-diff-setup.sh b/apps/mobile/e2e/scripts/phase6-diff-setup.sh new file mode 100755 index 0000000000..ff080386cb --- /dev/null +++ b/apps/mobile/e2e/scripts/phase6-diff-setup.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Seeds the Phase 6 Diff tab flow against a running mobile e2e backend: +# creates a managed-worktree thread titled "$THREAD_TITLE" (default "P6 diff") +# in the harness project, waits for its environment, then dirties the worktree +# (appends to the first tracked file, deletes the second, adds a new file) so +# the Diff tab has modified / deleted / added cards to show. Prints the thread id, +# environment id and worktree path as JSON. +# +# SERVER_URL=http://127.0.0.1:41999 e2e/scripts/phase6-diff-setup.sh +# maestro test e2e/flows/phase6-diff.yaml +# +# Requires curl + python3 (for JSON). The fake provider never edits files, so +# the worktree is dirtied from this shell. Idempotent per title: re-running +# reuses an existing thread with that title and re-dirties its worktree. +set -euo pipefail + +SERVER_URL="${SERVER_URL:-http://127.0.0.1:41999}" +THREAD_TITLE="${THREAD_TITLE:-P6 diff}" +API="$SERVER_URL/api/v1" + +json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; } + +project_id="$(curl -fsS "$API/projects" | json 'd[0]["id"] if isinstance(d, list) else d["projects"][0]["id"]')" +host_id="$(curl -fsS "$API/hosts" | json 'd[0]["id"] if isinstance(d, list) else d["hosts"][0]["id"]')" + +existing="$(curl -fsS "$API/threads?projectId=$project_id" | python3 -c " +import json,sys +d=json.load(sys.stdin) +threads=d if isinstance(d, list) else d.get('threads', d.get('items', [])) +for t in threads: + if t.get('title') == '$THREAD_TITLE' and not t.get('archivedAt'): + print(t['id']); break +")" + +if [ -n "$existing" ]; then + thread_id="$existing" +else + thread_id="$(curl -fsS -X POST "$API/threads" -H 'content-type: application/json' -d "$(cat <<EOF +{ + "environment": {"type": "host", "hostId": "$host_id", "workspace": {"type": "managed-worktree", "baseBranch": {"kind": "default"}}}, + "input": [{"type": "text", "text": "Reply with exactly READY and nothing else.", "mentions": []}], + "origin": "app", + "model": "fake-model", + "projectId": "$project_id", + "providerId": "fake", + "title": "$THREAD_TITLE", + "startedOnBehalfOf": null, + "originKind": null +} +EOF +)" | json 'd["id"]')" +fi + +# Wait for the thread to settle and its environment to be ready. +for _ in $(seq 1 60); do + thread_json="$(curl -fsS "$API/threads/$thread_id?include=environment")" + env_status="$(printf '%s' "$thread_json" | json '(d.get("environment") or {}).get("status", "")')" + thread_status="$(printf '%s' "$thread_json" | json 'd["status"]')" + if [ "$env_status" = "ready" ] && [ "$thread_status" = "idle" ]; then break; fi + sleep 0.5 +done +environment_id="$(printf '%s' "$thread_json" | json 'd["environment"]["id"]')" +worktree="$(printf '%s' "$thread_json" | json 'd["environment"]["path"]')" +if [ -z "$worktree" ] || [ ! -d "$worktree" ]; then + echo "worktree not found for thread $thread_id: '$worktree'" >&2 + exit 1 +fi + +# Dirty the worktree (after resetting it so re-runs start clean): modify +# the first tracked file, delete the second, add a new one. +git -C "$worktree" checkout -- . >/dev/null 2>&1 || true +git -C "$worktree" clean -fdq >/dev/null 2>&1 || true +modified=""; deleted="" +while IFS= read -r tracked; do + [ -z "$tracked" ] && continue + # A previous run may have committed the added file; never pick it. + [ "$tracked" = "phase6-added.ts" ] && continue + if [ -z "$modified" ]; then + printf '\nPhase 6 diff tab check: %s\n' "$(date +%s)" >> "$worktree/$tracked" + modified="$tracked" + elif [ -z "$deleted" ]; then + rm -f "$worktree/$tracked"; deleted="$tracked" + fi +done < <(git -C "$worktree" ls-files) +printf 'export const phase6 = "diff tab %s";\n' "$(date +%s)" > "$worktree/phase6-added.ts" + +python3 -c "import json; print(json.dumps({'threadId': '$thread_id', 'environmentId': '$environment_id', 'worktree': '$worktree', 'modified': '$modified', 'deleted': '$deleted', 'added': 'phase6-added.ts'}))" diff --git a/apps/mobile/e2e/scripts/phase6-files-setup.sh b/apps/mobile/e2e/scripts/phase6-files-setup.sh new file mode 100755 index 0000000000..414b57cb2d --- /dev/null +++ b/apps/mobile/e2e/scripts/phase6-files-setup.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Seeds the Phase 6 Files flow against a running mobile e2e backend: writes +# a README.md, src/app.ts, data.csv, docs/index.html and assets/dot.png into +# the harness project checkout (the "Idle thread" / any thread's workspace — +# the seed threads share the project repo), plus notes/plan.md and report.csv +# into the named thread's storage directory. Prints the thread id and the +# paths as JSON. +# +# SERVER_URL=http://127.0.0.1:41999 THREAD_TITLE="Idle thread" e2e/scripts/phase6-files-setup.sh +# maestro test e2e/flows/phase6-files.yaml +# +# Requires curl + python3. Idempotent: re-running rewrites the same files. +set -euo pipefail + +SERVER_URL="${SERVER_URL:-http://127.0.0.1:41999}" +THREAD_TITLE="${THREAD_TITLE:-Idle thread}" +API="$SERVER_URL/api/v1" + +python3 - "$API" "$THREAD_TITLE" <<'PY' +import json, sys, urllib.request, base64 + +api, title = sys.argv[1], sys.argv[2] + +def get(path): + with urllib.request.urlopen(f"{api}{path}") as response: + return json.load(response) + +def post(path, body): + request = urllib.request.Request( + f"{api}{path}", + data=json.dumps(body).encode(), + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request) as response: + return response.status + +threads = get("/threads") +threads = threads if isinstance(threads, list) else threads.get("threads", threads.get("items", [])) +thread = next((t for t in threads if t.get("title") == title and not t.get("archivedAt")), None) +if thread is None: + raise SystemExit(f"no thread titled {title!r}") +environment = get(f"/environments/{thread['environmentId']}") +host_id, repo = environment["hostId"], environment["path"] +storage = get(f"/threads/{thread['id']}/thread-storage/files")["storageRootPath"] + +def write(path, content, encoding=None): + body = {"hostId": host_id, "path": path, "content": content, "createParents": True} + if encoding: + body["contentEncoding"] = encoding + post("/files/write", body) + +readme = ["# Mobile E2E Project", "", "This README exercises the mobile file preview.", "", + "See [the app source](src/app.ts:12) and [data](data.csv).", "", "## Sections", ""] +readme += [f"- Item {i}: lorem ipsum dolor sit amet, line {i} of the README." for i in range(1, 70)] +readme += ["", "```ts", "export const answer = 42;", "```", ""] +write(f"{repo}/README.md", "\n".join(readme)) +write(f"{repo}/src/app.ts", "// app.ts — sample source for the file preview\n" + "".join( + f"export function fn{i}(value: number): number {{ return value * {i}; }} // line {i}\n" for i in range(1, 121))) +write(f"{repo}/data.csv", 'name,qty,price,note\nalpha,1,2.50,"quoted, comma"\nbeta,20,13.00,plain\ngamma,3,0.99,"with ""quotes"""\n') +write(f"{repo}/docs/index.html", '<!doctype html><html><head><meta charset="utf-8"><title>Preview' + '' + '

HTML preview works

static

' + '') +write(f"{repo}/assets/dot.png", "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAE0lEQVR4nGP4z8DwHwyBNAMDAwA0TQP9ZSqa6wAAAABJRU5ErkJggg==", "base64") +write(f"{storage}/notes/plan.md", "# Plan\n\n1. Build the files tab\n2. Preview a storage file\n\nSibling: [report](report.csv).\n") +write(f"{storage}/report.csv", "week,done\n1,3\n2,5\n") +print(json.dumps({"threadId": thread["id"], "environmentId": environment["id"], "repo": repo, "storage": storage})) +PY diff --git a/apps/mobile/e2e/scripts/phase7-machine-name.js b/apps/mobile/e2e/scripts/phase7-machine-name.js new file mode 100644 index 0000000000..f5a2648c1a --- /dev/null +++ b/apps/mobile/e2e/scripts/phase7-machine-name.js @@ -0,0 +1,21 @@ +// Maestro runScript: the harness has exactly one host (the in-process +// daemon). Export its id and name for the machines part of the flow, and +// when RESTORE_NAME is set, rename it back to that name. Env: SERVER_URL, +// RESTORE_NAME (optional). +const hosts = json(http.get(`${SERVER_URL}/api/v1/hosts`).body); +const host = hosts[0]; +if (!host) { + throw new Error("The harness reported no hosts"); +} +if (typeof RESTORE_NAME === "string" && RESTORE_NAME.length > 0) { + const response = http.request(`${SERVER_URL}/api/v1/hosts/${host.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: RESTORE_NAME }), + }); + if (!response.ok) { + throw new Error(`rename back failed: ${response.status} ${response.body}`); + } +} +output.hostId = host.id; +output.hostName = host.name; diff --git a/apps/mobile/e2e/scripts/phase7-settings-assert.js b/apps/mobile/e2e/scripts/phase7-settings-assert.js new file mode 100644 index 0000000000..e222fa4e1a --- /dev/null +++ b/apps/mobile/e2e/scripts/phase7-settings-assert.js @@ -0,0 +1,15 @@ +// Maestro runScript: read the server-persisted settings the Phase 7 flow +// changed through the UI and fail unless they landed. Env: SERVER_URL, +// EXPECT_NEW_ONBOARDING ("true" | "false"), EXPECT_THEME_ID. +const config = json(http.get(`${SERVER_URL}/api/v1/system/config`).body); +const newOnboarding = String(config.experiments.newOnboarding); +if (newOnboarding !== EXPECT_NEW_ONBOARDING) { + throw new Error( + `experiments.newOnboarding is ${newOnboarding}, expected ${EXPECT_NEW_ONBOARDING}`, + ); +} +if (config.appearance.themeId !== EXPECT_THEME_ID) { + throw new Error( + `appearance.themeId is ${config.appearance.themeId}, expected ${EXPECT_THEME_ID}`, + ); +} diff --git a/apps/mobile/e2e/scripts/phase7-settings-reset.js b/apps/mobile/e2e/scripts/phase7-settings-reset.js new file mode 100644 index 0000000000..9e3f1a67f4 --- /dev/null +++ b/apps/mobile/e2e/scripts/phase7-settings-reset.js @@ -0,0 +1,23 @@ +// Maestro runScript: put the harness server's settings back to the defaults +// the Phase 7 settings flow toggles (experiments, appearance) so the flow +// starts and ends from a known state on a shared backend. Env: SERVER_URL. +const headers = { "Content-Type": "application/json" }; +const experiments = http.put(`${SERVER_URL}/api/v1/settings/experiments`, { + headers, + body: JSON.stringify({ + editMessages: true, + mobileApp: false, + newOnboarding: false, + providerSessionReaping: false, + }), +}); +if (!experiments.ok) { + throw new Error(`experiments reset failed: ${experiments.status}`); +} +const appearance = http.put(`${SERVER_URL}/api/v1/settings/appearance`, { + headers, + body: JSON.stringify({ themeId: "default", faviconColor: "default" }), +}); +if (!appearance.ok) { + throw new Error(`appearance reset failed: ${appearance.status}`); +} diff --git a/apps/mobile/e2e/scripts/pick-simulator.mjs b/apps/mobile/e2e/scripts/pick-simulator.mjs new file mode 100644 index 0000000000..d77f8b0c2a --- /dev/null +++ b/apps/mobile/e2e/scripts/pick-simulator.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// Prints the UDID of the iPhone simulator CI should use: the newest available +// iOS runtime that has one of the preferred device types, preferring the +// models the flows were developed on. Reads `xcrun simctl list devices +// available -j`. Usage: node pick-simulator.mjs [preferred name ...] +// +// UDID=$(node e2e/scripts/pick-simulator.mjs) +import { execFileSync } from "node:child_process"; + +const preferred = + process.argv.length > 2 + ? process.argv.slice(2) + : ["iPhone 17 Pro", "iPhone 17", "iPhone 16 Pro", "iPhone 16"]; + +const listing = JSON.parse( + execFileSync("xcrun", ["simctl", "list", "devices", "available", "-j"], { + encoding: "utf8", + }), +); + +/** `com.apple.CoreSimulator.SimRuntime.iOS-26-2` → [26, 2] */ +function runtimeVersion(runtimeId) { + const match = /SimRuntime\.iOS-(\d+)-(\d+)/.exec(runtimeId); + return match ? [Number(match[1]), Number(match[2])] : null; +} + +const runtimes = Object.entries(listing.devices) + .map(([runtimeId, devices]) => ({ + runtimeId, + version: runtimeVersion(runtimeId), + devices, + })) + .filter((entry) => entry.version !== null) + .sort((a, b) => b.version[0] - a.version[0] || b.version[1] - a.version[1]); + +let pick = null; +for (const name of preferred) { + for (const runtime of runtimes) { + const device = runtime.devices.find( + (candidate) => candidate.name === name && candidate.isAvailable !== false, + ); + if (device) { + pick = { device, runtime }; + break; + } + } + if (pick) break; +} +if (!pick) { + for (const runtime of runtimes) { + const device = runtime.devices.find( + (candidate) => + candidate.name.startsWith("iPhone") && candidate.isAvailable !== false, + ); + if (device) { + pick = { device, runtime }; + break; + } + } +} +if (!pick) { + console.error("No available iPhone simulator found"); + process.exit(1); +} +console.error( + `Simulator: ${pick.device.name} (${pick.runtime.runtimeId}) ${pick.device.udid}`, +); +process.stdout.write(`${pick.device.udid}\n`); diff --git a/apps/mobile/e2e/subflows/launch-app.yaml b/apps/mobile/e2e/subflows/launch-app.yaml new file mode 100644 index 0000000000..96a84b44d0 --- /dev/null +++ b/apps/mobile/e2e/subflows/launch-app.yaml @@ -0,0 +1,55 @@ +# Subflow: cold-start the app. Two launch modes, decided by the caller's env: +# +# - Dev client (default, local runs): `openLink` the Expo dev-client URL so +# Metro (`METRO_URL`) serves the bundle, then dismiss the dev client's +# "Open" confirmation and its first-launch onboarding sheet. +# - Embedded bundle (`-e BB_E2E_EMBEDDED_BUNDLE=1`, the CI Release build): +# plain `launchApp`. A Release build embeds the JS bundle and skips the dev +# launcher entirely, so there is no Metro and no dev-client chrome; the +# bundle must have been built with `EXPO_PUBLIC_BB_E2E=1` (state wipe on +# launch, e2e-only affordances). +# +# Called with `runFlow: ../subflows/launch-app.yaml` (flows) or +# `runFlow: ./launch-app.yaml` (sibling subflows). Flow-file `env:` values +# beat `-e`, so the switch is a variable no flow defines in its header. +appId: app.getbb.mobile +--- +- stopApp +- runFlow: + when: + true: ${typeof BB_E2E_EMBEDDED_BUNDLE !== "undefined" && BB_E2E_EMBEDDED_BUNDLE === "1"} + commands: + - launchApp + # `launchApp` returns when the process starts, before the embedded JS + # bundle and Expo Router necessarily mount. Wait for the first React + # screen so callers can safely deliver a live custom-scheme link. + - extendedWaitUntil: + visible: + id: "add-server-screen" + timeout: 30000 +- runFlow: + when: + true: ${typeof BB_E2E_EMBEDDED_BUNDLE === "undefined" || BB_E2E_EMBEDDED_BUNDLE !== "1"} + commands: + - openLink: "exp+bb-app://expo-development-client/?url=${METRO_URL}" + # A fresh simulator confirms the first custom-scheme link. + - runFlow: + when: + visible: "Open" + commands: + - tapOn: "Open" + # First launch of a dev client shows the developer-menu onboarding + # sheet once the bundle is loaded; dismiss it if it appears. + - extendedWaitUntil: + visible: ".*(Continue|Close|Connect to a bb server|Threads).*" + timeout: 120000 + - runFlow: + when: + visible: "Continue" + commands: + - tapOn: "Continue" + - runFlow: + when: + visible: "Close" + commands: + - tapOn: "Close" diff --git a/apps/mobile/e2e/subflows/launch-to-home.yaml b/apps/mobile/e2e/subflows/launch-to-home.yaml new file mode 100644 index 0000000000..7cfd905807 --- /dev/null +++ b/apps/mobile/e2e/subflows/launch-to-home.yaml @@ -0,0 +1,29 @@ +# Subflow: cold-start the app (./launch-app.yaml: the dev client against +# Metro, or the embedded Release bundle with `-e BB_E2E_EMBEDDED_BUNDLE=1`), +# add the harness server when the app asks for one (the E2E build resets +# profiles on every launch), and wait for Home. Called from flows under +# ../flows with `runFlow: ../subflows/launch-to-home.yaml`; the caller's env +# supplies METRO_URL and SERVER_URL. +appId: app.getbb.mobile +--- +- runFlow: ./launch-app.yaml +- runFlow: + when: + visible: "Connect to a bb server" + commands: + - tapOn: + id: "server-url-input" + - inputText: "${SERVER_URL}" + - tapOn: + id: "server-label-input" + - inputText: "E2E backend" + # Tapping static text dismisses the keyboard (the screen sets + # keyboardShouldPersistTaps="handled"); Maestro's hideKeyboard swipe is + # unreliable on this screen since the bb connect row lengthened it. + - tapOn: "Server URL" + - tapOn: + id: "add-server-submit" +- extendedWaitUntil: + visible: + id: "home-thread-list" + timeout: 30000 diff --git a/apps/mobile/e2e/subflows/open-settings.yaml b/apps/mobile/e2e/subflows/open-settings.yaml new file mode 100644 index 0000000000..0eb301b8bd --- /dev/null +++ b/apps/mobile/e2e/subflows/open-settings.yaml @@ -0,0 +1,11 @@ +# Home → workspace menu (the header's server avatar) → Settings. +appId: ${APP_ID} +--- +- tapOn: + id: "home-workspace-menu" +- extendedWaitUntil: + visible: + id: "workspace-settings" + timeout: 10000 +- tapOn: + id: "workspace-settings" diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 0000000000..11f9326c5e --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,38 @@ +{ + "cli": { + "version": ">= 14.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "ios": { + "simulator": true + } + }, + "development-device": { + "extends": "development", + "ios": { + "simulator": false + } + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": { + "ios": { + "appleTeamId": "9QCU24SXK5", + "ascAppId": "6803559210", + "ascApiKeyPath": "./asc-api-key.p8", + "ascApiKeyId": "XPRY95WQUJ", + "ascApiKeyIssuerId": "4cd3d471-a7c3-4152-8d48-72fff4a226a8" + } + } + } +} diff --git a/apps/mobile/global.css b/apps/mobile/global.css new file mode 100644 index 0000000000..b700e22a47 --- /dev/null +++ b/apps/mobile/global.css @@ -0,0 +1,138 @@ +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/preflight.css" layer(base); +@import "tailwindcss/utilities.css"; + +@import "nativewind/theme"; + +/* + * Tailwind class vocabulary for the bb mobile app. + * + * Every `--color-*` below points at a CSS variable (`--background`, `--border`, + * …) that `src/theme/ThemeProvider.tsx` supplies at runtime through + * NativeWind's variable context, per palette × light/dark, using the values + * generated into `src/theme/theme.native.ts`. The token names mirror the + * `@theme inline` block in `apps/app/src/components/ui/theme.css` so class + * names port from the web app unchanged (`bg-background`, `text-foreground`, + * `border-border`, `bg-sidebar-accent`, …). `src/theme/theme-vars.test.ts` + * fails when this list drifts from the web token list. + */ +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-subtle-foreground: var(--subtle-foreground); + --color-readback-foreground: var(--readback-foreground); + --color-timeline-accent: var(--timeline-accent); + --color-version-upgrade: var(--version-upgrade); + --color-file-accent: var(--file-accent); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-state-hover: var(--state-hover); + --color-state-active: var(--state-active); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-destructive-text: var(--destructive-text); + --color-attention: var(--attention); + --color-warning: var(--warning); + --color-warning-text: var(--warning-text); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-diff-added: var(--diff-added); + --color-pr-merged: var(--pr-merged); + --color-diff-removed: var(--diff-removed); + --color-border: var(--border); + --color-border-hairline: var(--border-hairline); + --color-border-seam: var(--border-seam); + --color-border-seam-vertical: var(--border-seam-vertical); + --color-input: var(--input); + --color-ring: var(--ring); + --color-surface-recessed: var(--surface-recessed); + --color-surface-recessed-solid: var(--surface-recessed-solid); + --color-surface-recessed-soft-solid: var(--surface-recessed-soft-solid); + --color-surface-raised: var(--surface-raised); + --color-surface-raised-solid: var(--surface-raised-solid); + --color-surface-scrim: var(--surface-scrim); + --color-surface-destructive: var(--surface-destructive); + --color-surface-destructive-border: var(--surface-destructive-border); + --color-surface-attention: var(--surface-attention); + --color-surface-selected: var(--surface-selected); + --color-surface-selected-border: var(--surface-selected-border); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + /* Mobile-only additions (not in the web @theme block): the anchors, the + sidebar search match, the pill chrome and the shadow color are exposed so + screens can use them as classes too. */ + --color-canvas: var(--canvas); + --color-ink: var(--ink); + --color-pill-foreground: var(--pill-foreground); + --color-pill-icon: var(--pill-icon); + --color-pill-surface-border: var(--pill-surface-border); + --color-pill-surface-selected-border: var(--pill-surface-selected-border); + --color-sidebar-search-match: var(--sidebar-search-match); + --color-sidebar-search-match-border: var(--sidebar-search-match-border); + --color-shadow-color: var(--shadow-color); + + /* + * Fonts. Expo Google Fonts register one family name per weight, so the + * `font-sans-*` / `font-mono-*` utilities select the weight-specific family. + * `` (src/ui/Text.tsx) picks the right family for you and + * also derives it from web-style `font-medium|semibold|bold` classes. + */ + --font-sans: "Inter_400Regular"; + --font-sans-medium: "Inter_500Medium"; + --font-sans-semibold: "Inter_600SemiBold"; + --font-sans-bold: "Inter_700Bold"; + --font-mono: "FiraCode_400Regular"; + --font-mono-medium: "FiraCode_500Medium"; + --font-mono-semibold: "FiraCode_600SemiBold"; + --font-mono-bold: "FiraCode_700Bold"; + + /* Radii mirror `--radius: 0.5rem` and its steps (nativeRadii). */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; +} + +/* + * Typography scale: the touch (coarse-pointer) values from theme.css, which + * the mobile PWA renders on a phone. Sizes are px so react-native-css does + * not depend on the rem multiplier. Line heights are unitless ratios + * (`calc(line-height / font-size)`): Tailwind emits them as the fallback of + * `var(--tw-leading, …)`, and react-native-css drops the unit inside that + * fallback and treats the number as an em multiplier at runtime (`22px` + * became `22 × 15 = 330`). A ratio survives that path and yields the px value. + * Mirrors nativeTypography in theme.native.ts. + */ +@theme { + --text-2xs: 11px; + --text-2xs--line-height: calc(15 / 11); + --text-xs: 14px; + --text-xs--line-height: calc(20 / 14); + --text-sm: 15px; + --text-sm--line-height: calc(22 / 15); + --text-base: 16px; + --text-base--line-height: calc(24 / 16); +} + +/* + * react-native-css inlines `rem` at compile time and defaults to 14px; the + * web app uses the browser default of 16px, so Tailwind spacing (`--spacing: + * 0.25rem`) must resolve to the same 4px grid here. + */ +:root { + font-size: 16px; +} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 0000000000..d54bfbfcf0 --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,128 @@ +// Metro config for the bb mobile app inside the pnpm monorepo. +// +// Workspace packages (`@bb/*`) publish TypeScript source through +// `exports` conditions (`source` → `./src/*.ts`) and use NodeNext-style +// relative specifiers (`./foo.js` for `./foo.ts`). Metro needs: +// 1. package `exports` resolution (on by default in RN ≥ 0.79), +// 2. the `source` condition — applied ONLY to `@bb/*` packages here, because +// third-party packages built with builder-bob also ship a `source` +// condition and we do not want Metro compiling their raw sources, +// 3. a `.js` → `.ts(x)` fallback for relative imports inside workspace +// sources. +const path = require("node:path"); +const fs = require("node:fs"); +const { getDefaultConfig } = require("expo/metro-config"); +const { withNativewind } = require("nativewind/metro"); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, "../.."); + +const config = getDefaultConfig(projectRoot); + +config.watchFolders = [workspaceRoot]; +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, "node_modules"), + path.resolve(workspaceRoot, "node_modules"), +]; +config.resolver.unstable_enablePackageExports = true; + +const WORKSPACE_SCOPES = ["@bb/", "@get-bb/"]; +const TS_EXTENSIONS = [".ts", ".tsx"]; +const workspaceSourceRoots = ["packages", "apps", "plugins"].map((dir) => + path.join(workspaceRoot, dir), +); + +function isWorkspaceSource(filePath) { + return workspaceSourceRoots.some( + (root) => + filePath.startsWith(root + path.sep) && + !filePath.includes(`${path.sep}node_modules${path.sep}`), + ); +} + +function fileWithTsExtension(base) { + for (const ext of TS_EXTENSIONS) { + if (fs.existsSync(base + ext)) return base + ext; + } + for (const ext of TS_EXTENSIONS) { + const indexPath = path.join(base, `index${ext}`); + if (fs.existsSync(indexPath)) return indexPath; + } + return null; +} + +/** Split `@scope/name/sub/path` into package name and `./sub/path` subpath. */ +function splitScopedSpecifier(moduleName) { + const parts = moduleName.split("/"); + const packageName = parts.slice(0, 2).join("/"); + const subpath = parts.length > 2 ? "./" + parts.slice(2).join("/") : "."; + return { packageName, subpath }; +} + +const workspacePackageDirCache = new Map(); +function findWorkspacePackageDir(packageName) { + if (workspacePackageDirCache.has(packageName)) { + return workspacePackageDirCache.get(packageName); + } + let found = null; + for (const nodeModules of config.resolver.nodeModulesPaths) { + const candidate = path.join(nodeModules, packageName); + if (fs.existsSync(path.join(candidate, "package.json"))) { + found = fs.realpathSync(candidate); + break; + } + } + workspacePackageDirCache.set(packageName, found); + return found; +} + +/** Resolve a `@bb/*` specifier through its `exports[subpath].source` entry. */ +function resolveWorkspaceSource(moduleName) { + const { packageName, subpath } = splitScopedSpecifier(moduleName); + const packageDir = findWorkspacePackageDir(packageName); + if (!packageDir || !isWorkspaceSource(packageDir + path.sep)) return null; + const packageJson = JSON.parse( + fs.readFileSync(path.join(packageDir, "package.json"), "utf8"), + ); + const entry = packageJson.exports?.[subpath]; + if (!entry) return null; + const source = + typeof entry === "string" ? entry : (entry.source ?? entry.default); + if (typeof source !== "string") return null; + return path.resolve(packageDir, source); +} + +const defaultResolveRequest = config.resolver.resolveRequest; + +config.resolver.resolveRequest = (context, moduleName, platform) => { + const resolve = defaultResolveRequest ?? context.resolveRequest; + + if (WORKSPACE_SCOPES.some((scope) => moduleName.startsWith(scope))) { + const filePath = resolveWorkspaceSource(moduleName); + if (filePath && fs.existsSync(filePath)) { + return { type: "sourceFile", filePath }; + } + } + + // NodeNext `./x.js` → `./x.ts` inside workspace TS sources. + if ( + moduleName.startsWith(".") && + moduleName.endsWith(".js") && + isWorkspaceSource(context.originModulePath) + ) { + const base = path.resolve( + path.dirname(context.originModulePath), + moduleName.slice(0, -3), + ); + const filePath = fileWithTsExtension(base); + if (filePath) return { type: "sourceFile", filePath }; + } + + return resolve(context, moduleName, platform); +}; + +// `inlineRem: 16` matches the browser default the web app's Tailwind values +// assume (react-native-css defaults to 14, which would shrink every spacing +// utility to a 3.5px grid). global.css also declares `:root { font-size: +// 16px }` for the runtime `rem` variable. +module.exports = withNativewind(config, { inlineRem: 16 }); diff --git a/apps/mobile/nativewind-env.d.ts b/apps/mobile/nativewind-env.d.ts new file mode 100644 index 0000000000..2939089819 --- /dev/null +++ b/apps/mobile/nativewind-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000000..d6ac0336e1 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,111 @@ +{ + "name": "@bb/mobile", + "version": "0.0.1", + "private": true, + "main": "expo-router/entry", + "scripts": { + "dev": "expo start --dev-client", + "ios": "expo run:ios", + "android": "expo run:android", + "prebuild": "expo prebuild", + "typecheck": "tsc --noEmit", + "lint": "oxlint app src scripts metro.config.js", + "test": "vitest run --config vitest.config.ts", + "theme:generate": "node --conditions=source --import tsx scripts/generate-native-theme.ts", + "terminal:build": "node --conditions=source --import tsx scripts/build-terminal-page.ts", + "e2e:ios": "JAVA_HOME=${JAVA_HOME:-/opt/homebrew/opt/openjdk@17} maestro test e2e/flows" + }, + "dependencies": { + "@bb/client-core": "workspace:*", + "@bb/connect-client": "workspace:*", + "@bb/core-ui": "workspace:*", + "@bb/domain": "workspace:*", + "@bb/fuzzy-match": "workspace:*", + "@bb/host-daemon-contract": "workspace:*", + "@bb/plugin-interaction-contracts": "workspace:*", + "@bb/sdk": "workspace:*", + "@bb/server-contract": "workspace:*", + "@bb/thread-view": "workspace:*", + "@expo-google-fonts/fira-code": "^0.4.1", + "@expo-google-fonts/inter": "^0.4.2", + "@gorhom/bottom-sheet": "^5.2.14", + "@hugeicons/core-free-icons": "^4.1.3", + "@hugeicons/react-native": "^1.0.15", + "@react-native-cookies/cookies": "^6.2.1", + "@shopify/flash-list": "2.0.2", + "@tanstack/react-query": "^5.62.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "expo": "~57.0.14", + "expo-asset": "~57.0.12", + "expo-audio": "~57.0.3", + "expo-build-properties": "~57.0.12", + "expo-camera": "~57.0.3", + "expo-clipboard": "~57.0.1", + "expo-constants": "~57.0.12", + "expo-crypto": "~57.0.1", + "expo-dev-client": "~57.0.13", + "expo-document-picker": "~57.0.1", + "expo-file-system": "~57.0.4", + "expo-font": "~57.0.1", + "expo-haptics": "~57.0.1", + "expo-image": "~57.0.3", + "expo-image-picker": "~57.0.11", + "expo-keep-awake": "~57.0.1", + "expo-linking": "~57.0.6", + "expo-notifications": "~57.0.12", + "expo-router": "~57.0.14", + "expo-secure-store": "~57.0.1", + "expo-splash-screen": "~57.0.7", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.2", + "expo-web-browser": "~57.0.2", + "mdast-util-to-string": "^4.0.0", + "nativewind": "5.0.0-preview.4", + "parse-git-diff": "^0.0.20", + "react": "^19.0.0", + "react-native": "0.86.2", + "react-native-css": "^3.0.7", + "react-native-gesture-handler": "~2.32.0", + "react-native-keyboard-controller": "1.21.9", + "react-native-mmkv": "^4.3.2", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", + "react-native-webview": "13.16.1", + "react-native-worklets": "0.10.1", + "remark-breaks": "^4.0.0", + "remark-directive": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "sonner-native": "^0.27.0", + "sugar-high": "^2.0.1", + "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@bb/tsconfig": "workspace:*", + "@tailwindcss/postcss": "^4.3.3", + "@types/culori": "^4.0.1", + "@types/mdast": "^4.0.4", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@xterm/addon-fit": "0.12.0-beta.292", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "0.13.0-beta.292", + "@xterm/xterm": "6.1.0-beta.292", + "culori": "^4.0.2", + "eas-cli": "22.0.0", + "esbuild": "^0.28.0", + "lightningcss": "1.30.1", + "mdast-util-directive": "^3.1.0", + "postcss": "^8.5.26", + "tailwindcss": "^4.3.0", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitest": "^4.1.1" + } +} diff --git a/apps/mobile/postcss.config.mjs b/apps/mobile/postcss.config.mjs new file mode 100644 index 0000000000..c2ddf74822 --- /dev/null +++ b/apps/mobile/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/apps/mobile/scripts/build-terminal-page.ts b/apps/mobile/scripts/build-terminal-page.ts new file mode 100644 index 0000000000..660e10d38b --- /dev/null +++ b/apps/mobile/scripts/build-terminal-page.ts @@ -0,0 +1,126 @@ +/// +/** + * Bundles the terminal WebView page (`src/screens/terminal/page/ + * terminal-page.ts` + xterm.js + the fit / unicode11 / web-links addons + + * xterm.css + the page CSS) into one self-contained HTML document, + * `assets/terminal/index.html`. The React Native side loads that asset and + * hands the HTML string to `react-native-webview` (`source={{ html }}`), so + * the terminal needs no network access of its own. + * + * The result is committed; `src/screens/terminal/terminal-page.test.ts` + * rebuilds it in memory and fails when the asset is stale. + * + * Run: `pnpm --filter @bb/mobile terminal:build` + * (`node --conditions=source --import tsx scripts/build-terminal-page.ts`). + */ +import { build } from "esbuild"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const MOBILE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const PAGE_DIR = join(MOBILE_ROOT, "src", "screens", "terminal", "page"); +const PAGE_ENTRY = join(PAGE_DIR, "terminal-page.ts"); +const PAGE_CSS = join(PAGE_DIR, "terminal-page.css"); +export const TERMINAL_PAGE_OUTPUT_PATH = join( + MOBILE_ROOT, + "assets", + "terminal", + "index.html", +); + +const require = createRequire(import.meta.url); + +function readXtermCss(): string { + return readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"); +} + +function readXtermVersion(): string { + const pkg = JSON.parse( + readFileSync(require.resolve("@xterm/xterm/package.json"), "utf8"), + ) as { version: string }; + return pkg.version; +} + +async function bundlePageScript(): Promise { + const result = await build({ + entryPoints: [PAGE_ENTRY], + bundle: true, + write: false, + minify: true, + format: "iife", + platform: "browser", + // WKWebView on iOS 16+ / Chrome WebView on Android 10+. + target: ["safari16", "chrome100"], + legalComments: "none", + logLevel: "silent", + absWorkingDir: MOBILE_ROOT, + }); + const file = result.outputFiles[0]; + if (!file) throw new Error("esbuild produced no output"); + // An inline script must not contain a literal closing script tag. + return file.text.replace(/<\/script/giu, "<\\/script"); +} + +export function renderTerminalPageHtml(args: { + script: string; + xtermCss: string; + pageCss: string; + xtermVersion: string; +}): string { + return [ + "", + ``, + '', + "", + '', + '', + '', + "", + "", + "", + '
', + '
', + "", + "", + "", + "", + ].join("\n"); +} + +/** Full pipeline: bundle the page script and inline everything. */ +export async function buildTerminalPageHtml(): Promise { + const [script, xtermCss, pageCss] = await Promise.all([ + bundlePageScript(), + readXtermCss(), + readFileSync(PAGE_CSS, "utf8"), + ]); + return renderTerminalPageHtml({ + script, + xtermCss, + pageCss, + xtermVersion: readXtermVersion(), + }); +} + +async function main(): Promise { + const html = await buildTerminalPageHtml(); + mkdirSync(dirname(TERMINAL_PAGE_OUTPUT_PATH), { recursive: true }); + writeFileSync(TERMINAL_PAGE_OUTPUT_PATH, html); + console.log( + `wrote ${TERMINAL_PAGE_OUTPUT_PATH} (${(html.length / 1024).toFixed(0)} KiB)`, + ); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/apps/mobile/scripts/data-smoke.mts b/apps/mobile/scripts/data-smoke.mts new file mode 100644 index 0000000000..5b004c8d0a --- /dev/null +++ b/apps/mobile/scripts/data-smoke.mts @@ -0,0 +1,170 @@ +// Ad-hoc verification of the Phase 3 data layer against the mobile e2e backend. +// Run: cd apps/mobile && node --conditions=source --import tsx scripts/data-smoke.ts +import { createBrowserBbSdk } from "@bb/sdk/browser"; +import { QueryClient } from "@tanstack/react-query"; +import { buildCreateThreadRequest } from "../src/data/compose/create-thread-request"; +import { + buildReuseEnvironmentOptions, + resolveEffectiveEnvironmentSelection, +} from "../src/data/compose/environment-selection"; +import { + buildPermissionModeOptions, + resolveModelSelection, +} from "../src/data/compose/execution-options"; +import { buildSidebarModel } from "../src/data/sidebar/sidebar-model"; +import { selectRecentThreads } from "../src/data/sidebar/thread-search-query"; +import { sidebarNavigationQueryKey } from "../src/lib/query/query-keys"; +import { + beginPinThreadTransaction, + rollbackThreadListMutation, +} from "../src/data/threads/thread-state-cache"; +import { findCachedThreadListEntry } from "../src/data/threads/thread-list-cache"; + +// Explicit: BB_SERVER_URL in this shell may point at a real server. +const baseUrl = process.env.MOBILE_E2E_SERVER_URL ?? "http://127.0.0.1:41999"; +const sdk = createBrowserBbSdk({ baseUrl }); + +const bootstrap = await sdk.projects.sidebarBootstrap(); +console.log("sidebar-bootstrap:", { + sections: bootstrap.sections.length, + projects: bootstrap.projects.map((p) => `${p.name}(${p.threads.length})`), + personal: bootstrap.personalProject.threads.length, + defaults: bootstrap.projects[0]?.defaultExecutionOptions, +}); +const hosts = await sdk.hosts.list(); +console.log( + "hosts:", + hosts.map((h) => `${h.name}:${h.status}:${h.maxPermissionMode}`), +); + +for (const organize of ["project", "machine", "manual"] as const) { + const model = buildSidebarModel({ + bootstrap, + hosts, + organize, + sort: "updated", + }); + console.log( + `model[${organize}]:`, + model.groups.map((g) => `${g.id}=${g.threads.length}`), + "pinned:", + model.pinned?.rootNodes.length ?? 0, + ); +} +console.log( + "recent:", + selectRecentThreads( + bootstrap.projects.flatMap((p) => p.threads), + 5, + ).map((t) => t.title), +); + +const search = await sdk.threads.search({ query: "hello", limitPerGroup: "5" }); +console.log("search:", { + active: search.active.total, + archived: search.archived.total, +}); + +const project = bootstrap.projects[0]; +const options = await sdk.system.executionOptions({ hostId: hosts[0]?.id }); +const resolved = resolveModelSelection({ + executionOptions: options, + selectedModel: project.defaultExecutionOptions?.model, + catalogVerified: options.modelLoadError === null, +}); +console.log("execution-options:", { + providers: options.providers.map((p) => p.id), + ceiling: options.permissionCeiling, + selectedModel: resolved.selectedModel, + models: resolved.options.length, + permission: buildPermissionModeOptions({ + permissionModes: options.providers[0]?.capabilities.permissionModes, + ceiling: options.permissionCeiling, + }).map((o) => `${o.value}${o.disabled ? "(x)" : ""}`), +}); + +const reuse = buildReuseEnvironmentOptions(project.threads); +const selection = resolveEffectiveEnvironmentSelection({ + selection: { type: "reuse", environmentId: reuse[0]?.environmentId ?? null }, + projectId: project.id, + knownHostIds: new Set(hosts.map((h) => h.id)), + projectSources: project.sources, + reuseOptions: reuse, + reuseOptionsLoading: false, +}); +console.log("reuse options:", reuse.length, "effective selection:", selection); + +const branches = await sdk.projects.branches({ + projectId: project.id, + hostId: hosts[0].id, + limit: "5", +}); +console.log("branches:", branches.checkout, branches.defaultWorktreeBaseBranch); + +const build = buildCreateThreadRequest({ + projectId: project.id, + text: "Response to: smoke test from the mobile data layer", + providerId: project.defaultExecutionOptions?.providerId, + environment: { type: "project-default" }, + title: "data-smoke", +}); +if (!build.request) throw new Error(`blocked: ${build.blocker}`); +const created = await sdk.threads.spawn({ + ...build.request, + origin: "app", + originKind: null, + startedOnBehalfOf: null, +}); +console.log("created:", created.id, created.title, created.environmentId); + +// Optimistic pin against a real QueryClient seeded with the live bootstrap. +const queryClient = new QueryClient(); +queryClient.setQueryData( + sidebarNavigationQueryKey(), + await sdk.projects.sidebarBootstrap(), +); +const tx = await beginPinThreadTransaction({ + queryClient, + threadId: created.id, + pinnedAt: 1, +}); +console.log( + "optimistic pinnedAt:", + findCachedThreadListEntry(queryClient, created.id)?.pinnedAt, +); +rollbackThreadListMutation({ queryClient, threadId: created.id }, tx); +console.log( + "rolled back pinnedAt:", + findCachedThreadListEntry(queryClient, created.id)?.pinnedAt, +); + +const pinned = await sdk.threads.pin({ threadId: created.id }); +console.log("server pin:", pinned.pinnedAt !== null); +await sdk.threads.update({ threadId: created.id, title: "data-smoke renamed" }); +await sdk.threads.markUnread({ threadId: created.id }); +const summary = await sdk.threads.childSummary({ threadId: created.id }); +console.log("child summary:", summary); +const section = await sdk.threadSections.create({ + name: `smoke-${Date.now()}`, +}); +await sdk.threads.update({ threadId: created.id, sectionId: section.id }); +const after = await sdk.projects.sidebarBootstrap(); +const row = after.projects + .flatMap((p) => p.threads) + .find((t) => t.id === created.id); +console.log("after mutations:", { + title: row?.title, + pinned: row?.pinnedAt !== null, + section: row?.sectionId === section.id, + unread: row?.lastReadAt === null, +}); +await sdk.threadSections.delete({ id: section.id }); +const archived = await sdk.threads.archiveAll({ threadId: created.id }); +console.log("archived:", archived.archivedThreadIds); +await sdk.threads.unarchive({ threadId: created.id }); +await sdk.threads.delete({ + threadId: created.id, + childThreadsConfirmed: false, +}); +console.log("deleted:", created.id); +console.log("OK"); diff --git a/apps/mobile/scripts/generate-native-theme.ts b/apps/mobile/scripts/generate-native-theme.ts new file mode 100644 index 0000000000..4db23693cb --- /dev/null +++ b/apps/mobile/scripts/generate-native-theme.ts @@ -0,0 +1,807 @@ +/// +/** + * Generates `src/theme/theme.native.ts` from the web app's CSS theme tokens. + * + * React Native has no `var()`, `color-mix()`, or `oklch()`, so this script + * replays the web cascade (theme.css light/dark blocks, then a built-in + * palette's overrides) for every palette × mode and resolves each token to a + * plain color string. The result is committed; a vitest drift test regenerates + * it in memory and fails when it no longer matches. + * + * Run: `pnpm --filter @bb/mobile theme:generate` + * (`node --conditions=source --import tsx scripts/generate-native-theme.ts`). + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BUILTIN_THEME_IDS, type BuiltInThemeId } from "@bb/domain"; +import { converter, parse, type Color, type Oklab, type Oklch } from "culori"; + +const MOBILE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const APP_ROOT = join(MOBILE_ROOT, "..", "app"); +const THEME_CSS_PATH = join(APP_ROOT, "src", "components", "ui", "theme.css"); +const PALETTES_DIR = join(APP_ROOT, "src", "lib", "themes"); +export const NATIVE_THEME_OUTPUT_PATH = join( + MOBILE_ROOT, + "src", + "theme", + "theme.native.ts", +); + +export const MODES = ["light", "dark"] as const; +export type Mode = (typeof MODES)[number]; + +/** + * Chrome treats the hue of a color *converted* into oklch as powerless + * (missing) when its chroma is at or below this value, so it carries the other + * mix operand's hue forward. Colors written directly in `oklch()` keep their + * hue even at chroma 0. Measured against Chrome 151 (`color-mix(in oklch, …)` + * with `oklab(0.5 0.02 0)` → hue missing, `oklab(0.5 0.0200001 0)` → kept). + */ +const POWERLESS_HUE_CHROMA = 0.02; + +/** Web-only tokens that are deliberately not part of the native theme. */ +const WEB_ONLY_TOKEN_PATTERNS: readonly { pattern: RegExp; reason: string }[] = + [ + { + pattern: /^diffs-/, + reason: "@pierre/diffs bridge; defined per mode only", + }, + ]; + +// --------------------------------------------------------------------------- +// Tolerant CSS reading: rules → declarations. Only custom properties matter. +// --------------------------------------------------------------------------- + +interface CssRule { + prelude: string; + body: string; +} + +function stripComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ""); +} + +/** Splits `prelude { body }` rules at one nesting level; skips `@x …;`. */ +function splitRules(css: string): CssRule[] { + const rules: CssRule[] = []; + let index = 0; + let start = 0; + while (index < css.length) { + const char = css[index]; + if (char === ";") { + start = index + 1; + index += 1; + continue; + } + if (char !== "{") { + index += 1; + continue; + } + const prelude = css.slice(start, index).trim(); + let depth = 1; + let cursor = index + 1; + while (cursor < css.length && depth > 0) { + if (css[cursor] === "{") depth += 1; + else if (css[cursor] === "}") depth -= 1; + cursor += 1; + } + if (depth !== 0) { + throw new Error(`Unbalanced braces after "${prelude.slice(0, 40)}"`); + } + rules.push({ prelude, body: css.slice(index + 1, cursor - 1) }); + start = cursor; + index = cursor; + } + return rules; +} + +/** Splits on `separator` outside parentheses. */ +function splitTopLevel(input: string, separator: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of input) { + if (char === "(") depth += 1; + else if (char === ")") depth -= 1; + if (char === separator && depth === 0) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + parts.push(current); + return parts.map((part) => part.trim()).filter((part) => part.length > 0); +} + +/** `--name: value` pairs of a rule body, in source order. */ +function parseCustomProperties(body: string): [string, string][] { + const declarations: [string, string][] = []; + for (const declaration of splitTopLevel(body, ";")) { + const colon = declaration.indexOf(":"); + if (colon === -1) continue; + const name = declaration.slice(0, colon).trim(); + if (!name.startsWith("--")) continue; + const value = declaration + .slice(colon + 1) + .replace(/\s+/g, " ") + .trim(); + declarations.push([name.slice(2), value]); + } + return declarations; +} + +interface ModeRule { + modes: Mode[]; + declarations: [string, string][]; +} + +/** + * Selectors that match `` in each mode. `.dark` is toggled on the root + * element, so `:root` applies in both modes and `.light` only in light; all + * three have equal specificity, which makes source order the whole cascade. + */ +function modesForSelector(prelude: string): Mode[] { + const selectors = prelude.split(",").map((selector) => selector.trim()); + const modes = new Set(); + for (const selector of selectors) { + if (selector === ":root") { + modes.add("light"); + modes.add("dark"); + } else if (selector === ".light") { + modes.add("light"); + } else if (selector === ".dark") { + modes.add("dark"); + } + } + return MODES.filter((mode) => modes.has(mode)); +} + +function modeRules(css: string): ModeRule[] { + const rules: ModeRule[] = []; + for (const rule of splitRules(stripComments(css))) { + if (rule.prelude.startsWith("@")) continue; + const modes = modesForSelector(rule.prelude); + if (modes.length === 0) continue; + rules.push({ modes, declarations: parseCustomProperties(rule.body) }); + } + return rules; +} + +/** Final token → raw value map for one mode after the given rule lists. */ +function cascade(mode: Mode, ruleSets: ModeRule[][]): Map { + const tokens = new Map(); + for (const rules of ruleSets) { + for (const rule of rules) { + if (!rule.modes.includes(mode)) continue; + for (const [name, value] of rule.declarations) tokens.set(name, value); + } + } + return tokens; +} + +// --------------------------------------------------------------------------- +// Value resolution: var() substitution, color-mix(), output formatting. +// --------------------------------------------------------------------------- + +function substituteVars( + value: string, + tokens: ReadonlyMap, + stack: string[] = [], +): string { + let result = ""; + let index = 0; + while (index < value.length) { + const at = value.indexOf("var(", index); + if (at === -1) { + result += value.slice(index); + break; + } + result += value.slice(index, at); + let depth = 1; + let cursor = at + 4; + while (cursor < value.length && depth > 0) { + if (value[cursor] === "(") depth += 1; + else if (value[cursor] === ")") depth -= 1; + cursor += 1; + } + const [reference, ...fallbackParts] = splitTopLevel( + value.slice(at + 4, cursor - 1), + ",", + ); + const name = reference?.replace(/^--/, "") ?? ""; + if (stack.includes(name)) { + throw new Error( + `Cyclic var() reference: ${[...stack, name].join(" → ")}`, + ); + } + const referenced = tokens.get(name); + if (referenced === undefined) { + if (fallbackParts.length === 0) { + throw new Error(`var(--${name}) is not defined`); + } + result += substituteVars(fallbackParts.join(","), tokens, stack); + } else { + result += substituteVars(referenced, tokens, [...stack, name]); + } + index = cursor; + } + return result; +} + +type MixSpace = "oklch" | "oklab"; + +interface MixOperand { + color: Color; + percentage: number | null; +} + +const toOklch = converter("oklch"); +const toOklab = converter("oklab"); +const toRgb = converter("rgb"); + +function parseMixOperand(input: string): MixOperand | null { + const trailing = input.match(/^(.*?)\s+(-?[\d.]+)%$/); + const leading = input.match(/^(-?[\d.]+)%\s+(.*)$/); + const colorText = trailing?.[1] ?? leading?.[2] ?? input; + const percentageText = trailing?.[2] ?? leading?.[1]; + const color = parseColorValue(colorText); + if (color === null) return null; + return { + color, + percentage: percentageText === undefined ? null : Number(percentageText), + }; +} + +/** + * A color expression → culori color, or null when the value is not a color + * (font stacks, lengths, gradients, shadows). Nested `color-mix()` is allowed. + */ +function parseColorValue(input: string): Color | null { + const value = input.trim(); + const mix = value.match(/^color-mix\((.*)\)$/s); + if (!mix) { + const parsed = parse(value); + return parsed ?? null; + } + const args = splitTopLevel(mix[1] ?? "", ","); + const [spaceArg, firstArg, secondArg] = args; + if (args.length !== 3 || !spaceArg || !firstArg || !secondArg) { + throw new Error(`Unsupported color-mix() shape: ${value}`); + } + const space = spaceArg.match(/^in\s+(oklch|oklab)$/)?.[1]; + if (space !== "oklch" && space !== "oklab") { + throw new Error( + `Unsupported color-mix() interpolation space in: ${value} (only oklch/oklab)`, + ); + } + const first = parseMixOperand(firstArg); + const second = parseMixOperand(secondArg); + if (first === null || second === null) { + throw new Error(`color-mix() operand is not a color: ${value}`); + } + return mixColors(space, first, second); +} + +/** Normalizes the two operand percentages per CSS Color 5 §3.1. */ +function normalizeWeights( + first: MixOperand, + second: MixOperand, +): { p1: number; p2: number; alphaMultiplier: number } { + // Omitted percentages: both → 50/50; one → the complement of the other. + const p1 = + first.percentage ?? + (second.percentage === null ? 50 : 100 - second.percentage); + const p2 = second.percentage ?? 100 - p1; + const sum = p1 + p2; + if (sum <= 0) throw new Error("color-mix() percentages sum to zero"); + return { + p1: p1 / sum, + p2: p2 / sum, + alphaMultiplier: sum < 100 ? sum / 100 : 1, + }; +} + +/** Premultiplied-alpha linear interpolation of one non-hue channel. */ +function mixChannel( + c1: number | undefined, + c2: number | undefined, + a1: number, + a2: number, + p1: number, + p2: number, + alpha: number, +): number { + // A missing channel takes the other operand's value (CSS Color 4 §12.2). + const v1 = c1 ?? c2 ?? 0; + const v2 = c2 ?? c1 ?? 0; + if (alpha === 0) return 0; + return (v1 * a1 * p1 + v2 * a2 * p2) / alpha; +} + +/** Shorter-arc hue interpolation; hue is never premultiplied. */ +function mixHue( + h1: number | undefined, + h2: number | undefined, + p1: number, + p2: number, +): number | undefined { + if (h1 === undefined) return h2; + if (h2 === undefined) return h1; + let delta = h2 - h1; + if (delta > 180) delta -= 360; + else if (delta < -180) delta += 360; + const hue = (h1 * p1 + (h1 + delta) * p2) % 360; + return hue < 0 ? hue + 360 : hue; +} + +/** + * `color-mix()` as Chrome computes it: convert both operands to the + * interpolation space, carry missing components across, interpolate with + * premultiplied alpha (hue excepted, shorter arc), then apply the alpha + * multiplier for percentages summing below 100%. + */ +function mixColors( + space: MixSpace, + first: MixOperand, + second: MixOperand, +): Color { + const { p1, p2, alphaMultiplier } = normalizeWeights(first, second); + const a1 = first.color.alpha ?? 1; + const a2 = second.color.alpha ?? 1; + const alpha = a1 * p1 + a2 * p2; + const outAlpha = alpha * alphaMultiplier; + if (space === "oklab") { + const c1 = toOklab(first.color); + const c2 = toOklab(second.color); + const result: Oklab = { + mode: "oklab", + l: mixChannel(c1.l, c2.l, a1, a2, p1, p2, alpha), + a: mixChannel(c1.a, c2.a, a1, a2, p1, p2, alpha), + b: mixChannel(c1.b, c2.b, a1, a2, p1, p2, alpha), + }; + if (outAlpha !== 1) result.alpha = outAlpha; + return result; + } + const c1 = toOklch(first.color); + const c2 = toOklch(second.color); + const hueOf = (source: Color, converted: Oklch): number | undefined => { + if (converted.h === undefined) return undefined; + const wasConverted = source.mode !== "oklch"; + return wasConverted && converted.c <= POWERLESS_HUE_CHROMA + ? undefined + : converted.h; + }; + const result: Oklch = { + mode: "oklch", + l: mixChannel(c1.l, c2.l, a1, a2, p1, p2, alpha), + c: mixChannel(c1.c, c2.c, a1, a2, p1, p2, alpha), + }; + const hue = mixHue(hueOf(first.color, c1), hueOf(second.color, c2), p1, p2); + if (hue !== undefined) result.h = hue; + if (outAlpha !== 1) result.alpha = outAlpha; + return result; +} + +function channel255(value: number): number { + return Math.round(Math.max(0, Math.min(1, value)) * 255); +} + +/** `#rrggbb` for opaque colors, `rgba(r, g, b, a)` (3-decimal alpha) otherwise. */ +export function formatNativeColor(color: Color): string { + const rgb = toRgb(color); + const r = channel255(rgb.r); + const g = channel255(rgb.g); + const b = channel255(rgb.b); + const alpha = Math.round((rgb.alpha ?? 1) * 1000) / 1000; + if (alpha >= 1) { + return `#${[r, g, b].map((part) => part.toString(16).padStart(2, "0")).join("")}`; + } + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** `0.5rem` / `4px` / `calc( ± )` → CSS pixels (1rem = 16px). */ +function lengthToPx(input: string): number { + const value = input.trim(); + const calc = value.match(/^calc\((.*)\)$/s); + if (calc) { + const expression = calc[1] ?? ""; + const parts = expression.split(/\s+([+-])\s+/); + let total = lengthToPx(parts[0] ?? ""); + for (let index = 1; index < parts.length; index += 2) { + const operand = lengthToPx(parts[index + 1] ?? ""); + total += parts[index] === "-" ? -operand : operand; + } + return total; + } + const match = value.match(/^(-?[\d.]+)(rem|px)$/); + if (!match) throw new Error(`Unsupported length: ${input}`); + const number = Number(match[1]); + return match[2] === "rem" ? number * 16 : number; +} + +function camelCase(tokenName: string): string { + return tokenName.replace(/-+([a-z0-9])/g, (_, char: string) => + char.toUpperCase(), + ); +} + +// --------------------------------------------------------------------------- +// Theme model +// --------------------------------------------------------------------------- + +export interface NativeTextStyle { + fontSize: number; + lineHeight: number; +} + +export interface SkippedToken { + name: string; + reason: string; +} + +export interface NativeThemeModel { + /** palette → mode → camelCase token → RN color string. */ + themes: Map>>; + /** Sorted camelCase color token names (identical across palettes/modes). */ + tokenKeys: string[]; + radii: { base: number; sm: number; md: number; lg: number; xl: number }; + /** Ordered by font size. */ + typography: [name: string, style: NativeTextStyle][]; + skipped: SkippedToken[]; +} + +export interface ThemeSources { + themeCss: string; + /** Palette override CSS per built-in id ("" for `default`). */ + paletteCss: ReadonlyMap; +} + +function readSources(): ThemeSources { + const themeCss = readFileSync(THEME_CSS_PATH, "utf8"); + const paletteCss = new Map(); + for (const id of BUILTIN_THEME_IDS) { + // "default" is theme.css itself (the registry maps it to ""). + if (id === "default") { + paletteCss.set(id, ""); + continue; + } + const source = readFileSync(join(PALETTES_DIR, `${id}.ts`), "utf8"); + const css = source.match(/ThemeCss\s*=\s*`([\s\S]*?)`;/)?.[1]; + if (css === undefined) { + throw new Error(`No \`ThemeCss\` template literal in ${id}.ts`); + } + paletteCss.set(id, css); + } + return { themeCss, paletteCss }; +} + +function webOnlyReason(name: string): string | null { + for (const { pattern, reason } of WEB_ONLY_TOKEN_PATTERNS) { + if (pattern.test(name)) return reason; + } + return null; +} + +/** Radii from the `@theme inline` block: `--radius-*` derived from `--radius`. */ +function readRadii( + themeCss: string, + lightTokens: ReadonlyMap, +): NativeThemeModel["radii"] { + const inline = splitRules(stripComments(themeCss)).find( + (rule) => rule.prelude === "@theme inline", + ); + if (!inline) throw new Error("theme.css has no `@theme inline` block"); + const declared = new Map(parseCustomProperties(inline.body)); + const radius = (name: string): number => { + const raw = declared.get(name); + if (raw === undefined) + throw new Error(`--${name} missing in @theme inline`); + return lengthToPx(substituteVars(raw, lightTokens)); + }; + return { + base: lengthToPx(substituteVars("var(--radius)", lightTokens)), + sm: radius("radius-sm"), + md: radius("radius-md"), + lg: radius("radius-lg"), + xl: radius("radius-xl"), + }; +} + +/** + * `--text-*` scale: the `@theme` overrides, then the coarse-pointer + * `@media … (pointer: coarse) { :root {…} }` block on top. Touch sizes are the + * native base (there is no fine pointer on a phone). + */ +function readTypography(themeCss: string): NativeThemeModel["typography"] { + const declared = new Map(); + const rules = splitRules(stripComments(themeCss)); + for (const rule of rules) { + if (rule.prelude !== "@theme") continue; + for (const [name, value] of parseCustomProperties(rule.body)) { + if (name.startsWith("text-")) declared.set(name, value); + } + } + for (const rule of rules) { + if ( + !rule.prelude.startsWith("@media") || + !/pointer:\s*coarse/.test(rule.prelude) + ) { + continue; + } + for (const inner of splitRules(rule.body)) { + if (!modesForSelector(inner.prelude).includes("light")) continue; + for (const [name, value] of parseCustomProperties(inner.body)) { + if (name.startsWith("text-")) declared.set(name, value); + } + } + } + const styles: [string, NativeTextStyle][] = []; + for (const [name, value] of declared) { + if (name.includes("--")) continue; + const size = name.slice("text-".length); + const lineHeight = declared.get(`${name}--line-height`); + if (lineHeight === undefined) { + throw new Error(`--${name} has no --${name}--line-height`); + } + styles.push([ + size, + { fontSize: lengthToPx(value), lineHeight: lengthToPx(lineHeight) }, + ]); + } + return styles.sort( + (a, b) => a[1].fontSize - b[1].fontSize || a[0].localeCompare(b[0]), + ); +} + +/** Short label for a non-color token, for the generated header. */ +function describeNonColor(name: string, value: string): string { + if (name.startsWith("font-")) return "font stack"; + if (value.startsWith("linear-gradient(")) return "gradient"; + if (/(^|\s)-?[\d.]+px\s+-?[\d.]+px\b/.test(value)) return "box-shadow"; + if (/^-?[\d.]+(px|rem|em|%)?$/.test(value)) return `dimension (${value})`; + if (/^(calc\(|var\()/.test(value)) return "computed dimension"; + return `unsupported value (${value.slice(0, 40)})`; +} + +/** Builds the full native theme model from theme.css + palette CSS strings. */ +export function buildNativeThemeModel( + sources: ThemeSources = readSources(), +): NativeThemeModel { + const baseRules = modeRules(sources.themeCss); + const defaultTokens = { + light: cascade("light", [baseRules]), + dark: cascade("dark", [baseRules]), + }; + + // Classify every token once, from the default palette. The set of native + // color tokens must be identical in both modes: a token added to one mode + // only is the regression theme.test.ts guards against on the web. + const allNames = [ + ...new Set([...defaultTokens.light.keys(), ...defaultTokens.dark.keys()]), + ].sort(); + const skipped: SkippedToken[] = []; + const knownNames = new Set(); + const colorNames: string[] = []; + const oneModeOnly: string[] = []; + for (const name of allNames) { + const webOnly = webOnlyReason(name); + if (webOnly !== null) { + skipped.push({ name, reason: webOnly }); + continue; + } + knownNames.add(name); + const rawByMode = MODES.map((mode) => defaultTokens[mode].get(name)); + if (rawByMode.some((raw) => raw === undefined)) { + oneModeOnly.push(name); + continue; + } + if (name === "radius") { + skipped.push({ name, reason: "emitted as nativeRadii" }); + continue; + } + const resolvedByMode = MODES.map((mode) => + parseColorValue( + substituteVars( + defaultTokens[mode].get(name) ?? "", + defaultTokens[mode], + ), + ), + ); + const isColor = resolvedByMode.every((color) => color !== null); + if (!isColor && resolvedByMode.some((color) => color !== null)) { + throw new Error(`--${name} is a color in one mode but not the other`); + } + if (isColor) { + colorNames.push(name); + } else { + skipped.push({ + name, + reason: describeNonColor(name, defaultTokens.light.get(name) ?? ""), + }); + } + } + if (oneModeOnly.length > 0) { + throw new Error( + `theme.css defines tokens in one mode only: ${oneModeOnly.map((name) => `--${name}`).join(", ")}`, + ); + } + + const themes: NativeThemeModel["themes"] = new Map(); + for (const id of BUILTIN_THEME_IDS) { + const css = sources.paletteCss.get(id); + if (css === undefined) throw new Error(`No palette CSS for "${id}"`); + const paletteRules = modeRules(css); + for (const rule of paletteRules) { + for (const [name] of rule.declarations) { + if (!knownNames.has(name) && webOnlyReason(name) === null) { + throw new Error( + `Palette "${id}" declares --${name}, which theme.css does not define`, + ); + } + } + } + const resolveMode = (mode: Mode): Record => { + const tokens = cascade(mode, [baseRules, paletteRules]); + const resolved: Record = {}; + for (const name of colorNames) { + const raw = tokens.get(name); + if (raw === undefined) + throw new Error(`--${name} lost in ${id}/${mode}`); + const color = parseColorValue(substituteVars(raw, tokens)); + if (color === null) { + throw new Error( + `Palette "${id}" (${mode}) sets --${name} to a non-color: ${raw}`, + ); + } + resolved[camelCase(name)] = formatNativeColor(color); + } + return resolved; + }; + themes.set(id, { light: resolveMode("light"), dark: resolveMode("dark") }); + } + + return { + themes, + tokenKeys: colorNames.map(camelCase).sort(), + radii: readRadii(sources.themeCss, defaultTokens.light), + typography: readTypography(sources.themeCss), + skipped, + }; +} + +// --------------------------------------------------------------------------- +// Emission (Oxfmt-canonical so `oxfmt` is a no-op). +// --------------------------------------------------------------------------- + +function quoteKey(key: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key); +} + +function emitTokenObject( + tokens: Record, + keys: readonly string[], + indent: string, +): string { + const lines = keys.map( + (key) => `${indent} ${quoteKey(key)}: ${JSON.stringify(tokens[key])},`, + ); + return `{\n${lines.join("\n")}\n${indent}}`; +} + +export function renderNativeThemeSource(model: NativeThemeModel): string { + const skippedLines = model.skipped.map( + ({ name, reason }) => ` * --${name}: ${reason}`, + ); + const header = [ + "/**", + " * GENERATED FILE — run pnpm --filter @bb/mobile theme:generate", + " *", + " * Source: apps/app/src/components/ui/theme.css and the built-in palettes in", + " * apps/app/src/lib/themes/*.ts, replayed through the web cascade per palette", + " * and mode by apps/mobile/scripts/generate-native-theme.ts.", + " *", + " * `var()` is substituted textually; `color-mix(in oklch|oklab, …)` is", + " * evaluated like Chrome (premultiplied alpha, shorter hue arc, converted", + " * near-achromatic operands lose their hue). Opaque results are `#rrggbb`,", + " * translucent ones `rgba(r, g, b, a)`. Typography uses the coarse-pointer", + " * (touch) sizes as the base scale, in CSS pixels.", + " *", + " * Tokens deliberately left out (edit the generator to add them):", + ...skippedLines, + " */", + 'import type { BuiltInThemeId } from "@bb/domain";', + "", + ]; + + const tokenInterface = [ + "/**", + " * theme.css custom-property color tokens, keyed by camelCase name. Every", + " * value is a React Native color string.", + " */", + "export interface NativeThemeTokens {", + ...model.tokenKeys.map((key) => ` ${quoteKey(key)}: string;`), + "}", + "", + "export interface NativeThemeModes {", + " light: NativeThemeTokens;", + " dark: NativeThemeTokens;", + "}", + "", + ]; + + const palettes = [...model.themes.entries()].sort(([a], [b]) => + a.localeCompare(b), + ); + const themesLines = [ + "export const nativeThemes: Record = {", + ...palettes.flatMap(([id, modes]) => [ + ` ${quoteKey(id)}: {`, + ...MODES.map( + (mode) => + ` ${mode}: ${emitTokenObject(modes[mode], model.tokenKeys, " ")},`, + ), + " },", + ]), + "};", + "", + ]; + + const radiiLines = [ + "/** `--radius` and the Tailwind `--radius-*` steps, in CSS pixels. */", + "export const nativeRadii = {", + ...(["base", "sm", "md", "lg", "xl"] as const).map( + (key) => ` ${key}: ${model.radii[key]},`, + ), + "};", + "", + ]; + + const typographyLines = [ + "export interface NativeTextStyle {", + " fontSize: number;", + " lineHeight: number;", + "}", + "", + "/**", + " * The `--text-*` scale theme.css overrides, using the coarse-pointer (touch)", + " * values as the base, in CSS pixels. Sizes theme.css does not override keep", + " * Tailwind's defaults.", + " */", + "export const nativeTypography = {", + ...model.typography.flatMap(([name, style]) => [ + ` ${quoteKey(name)}: {`, + ` fontSize: ${style.fontSize},`, + ` lineHeight: ${style.lineHeight},`, + " },", + ]), + "} satisfies Record;", + "", + "export type NativeTextSize = keyof typeof nativeTypography;", + ]; + + return [ + ...header, + ...tokenInterface, + ...themesLines, + ...radiiLines, + ...typographyLines, + "", + ].join("\n"); +} + +/** Full pipeline: read sources → model → file contents. */ +export function generateNativeThemeSource(): string { + return renderNativeThemeSource(buildNativeThemeModel()); +} + +function main(): void { + const source = generateNativeThemeSource(); + writeFileSync(NATIVE_THEME_OUTPUT_PATH, source); + console.log(`wrote ${NATIVE_THEME_OUTPUT_PATH}`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/apps/mobile/scripts/testflight-distribute.mjs b/apps/mobile/scripts/testflight-distribute.mjs new file mode 100644 index 0000000000..72fe57e9d9 --- /dev/null +++ b/apps/mobile/scripts/testflight-distribute.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node +// Add one TestFlight build to an external beta group through the App Store +// Connect API. +// +// Apple only offers "Automatically distribute builds" for internal groups. +// An external group needs each build added by hand, and the first build of a +// new marketing version goes to Beta App Review at that moment. This script +// is the automatic path: the EAS workflow runs it after `eas submit` so every +// nightly reaches the external group without a click in App Store Connect. +// +// Usage (from apps/mobile): +// node scripts/testflight-distribute.mjs --version 0.39.0 --build 5 \ +// [--group "External testers"] [--key-path ./asc-api-key.p8] \ +// [--timeout-minutes 45] +// +// The key id, issuer id, and app id come from eas.json +// (submit.production.ios). The private key is the gitignored .p8 file that +// the submit profile also reads. No npm dependencies: the JWT is signed with +// node:crypto. + +import { readFileSync } from "node:fs"; +import { createSign } from "node:crypto"; +import { setTimeout as sleep } from "node:timers/promises"; + +const ASC_BASE_URL = "https://api.appstoreconnect.apple.com"; +const POLL_INTERVAL_MS = 30_000; + +function parseArgs(argv) { + const options = { + version: "", + build: "", + group: "External testers", + keyPath: "./asc-api-key.p8", + timeoutMinutes: 45, + }; + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + const value = argv[index + 1]; + switch (flag) { + case "--version": + options.version = value ?? ""; + break; + case "--build": + options.build = value ?? ""; + break; + case "--group": + options.group = value ?? ""; + break; + case "--key-path": + options.keyPath = value ?? ""; + break; + case "--timeout-minutes": + options.timeoutMinutes = Number(value); + break; + default: + throw new Error(`Unknown argument: ${flag}`); + } + index += 1; + } + if (!/^\d+\.\d+\.\d+$/u.test(options.version)) { + throw new Error(`--version must be X.Y.Z, got '${options.version}'.`); + } + if (!/^\d+$/u.test(options.build)) { + throw new Error(`--build must be a build number, got '${options.build}'.`); + } + if (!options.group) { + throw new Error("--group must be a beta group name."); + } + if (!Number.isFinite(options.timeoutMinutes) || options.timeoutMinutes <= 0) { + throw new Error("--timeout-minutes must be a positive number."); + } + return options; +} + +function readSubmitConfig() { + const easConfig = JSON.parse(readFileSync("eas.json", "utf8")); + const ios = easConfig?.submit?.production?.ios; + const keyId = ios?.ascApiKeyId; + const issuerId = ios?.ascApiKeyIssuerId; + const appId = ios?.ascAppId; + if ( + typeof keyId !== "string" || + typeof issuerId !== "string" || + typeof appId !== "string" + ) { + throw new Error( + "eas.json submit.production.ios needs ascApiKeyId, ascApiKeyIssuerId, and ascAppId.", + ); + } + return { keyId, issuerId, appId }; +} + +function signJwt({ keyId, issuerId, privateKey }) { + const base64url = (value) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const header = base64url({ alg: "ES256", kid: keyId, typ: "JWT" }); + const payload = base64url({ + iss: issuerId, + iat: now, + // Apple rejects tokens that live longer than 20 minutes. + exp: now + 15 * 60, + aud: "appstoreconnect-v1", + }); + const signature = createSign("SHA256") + .update(`${header}.${payload}`) + .sign({ key: privateKey, dsaEncoding: "ieee-p1363" }) + .toString("base64url"); + return `${header}.${payload}.${signature}`; +} + +function createClient(auth) { + let token = signJwt(auth); + let tokenIssuedAt = Date.now(); + return async function request(method, path, body) { + if (Date.now() - tokenIssuedAt > 10 * 60 * 1000) { + token = signJwt(auth); + tokenIssuedAt = Date.now(); + } + const response = await fetch(`${ASC_BASE_URL}${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${method} ${path} -> ${response.status}: ${text}`); + } + return text ? JSON.parse(text) : null; + }; +} + +async function findBetaGroup(request, appId, groupName) { + const result = await request( + "GET", + `/v1/betaGroups?filter[app]=${appId}&fields[betaGroups]=name,isInternalGroup&limit=200`, + ); + const group = result.data.find( + (entry) => entry.attributes.name === groupName, + ); + if (!group) { + const names = result.data.map((entry) => entry.attributes.name); + throw new Error( + `Beta group '${groupName}' not found. Groups: ${names.join(", ") || "(none)"}.`, + ); + } + return { + id: group.id, + isInternal: group.attributes.isInternalGroup === true, + }; +} + +async function waitForBuild( + request, + { appId, version, build, timeoutMinutes }, +) { + const deadline = Date.now() + timeoutMinutes * 60 * 1000; + const query = new URLSearchParams({ + "filter[app]": appId, + "filter[preReleaseVersion.version]": version, + "filter[version]": build, + "fields[builds]": + "version,processingState,expired,betaAppReviewSubmission,betaGroups", + include: "betaAppReviewSubmission,betaGroups", + "fields[betaAppReviewSubmissions]": "betaReviewState", + "fields[betaGroups]": "name", + }); + for (;;) { + const result = await request("GET", `/v1/builds?${query}`); + const found = result.data.find((entry) => !entry.attributes.expired); + if (found) { + const state = found.attributes.processingState; + if (state === "VALID") { + const included = result.included ?? []; + const submission = included.find( + (entry) => + entry.type === "betaAppReviewSubmissions" && + entry.id === found.relationships.betaAppReviewSubmission?.data?.id, + ); + return { + id: found.id, + reviewState: submission?.attributes.betaReviewState ?? null, + groupIds: (found.relationships.betaGroups?.data ?? []).map( + (entry) => entry.id, + ), + }; + } + if (state === "FAILED" || state === "INVALID") { + throw new Error( + `Build ${version} (${build}) has processingState ${state}.`, + ); + } + console.log(`Build ${version} (${build}) is ${state}; waiting.`); + } else { + console.log( + `Build ${version} (${build}) is not in App Store Connect yet; waiting.`, + ); + } + if (Date.now() > deadline) { + throw new Error( + `Build ${version} (${build}) did not become VALID within ${timeoutMinutes} minutes.`, + ); + } + await sleep(POLL_INTERVAL_MS); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const { keyId, issuerId, appId } = readSubmitConfig(); + const privateKey = readFileSync(options.keyPath, "utf8"); + const request = createClient({ keyId, issuerId, privateKey }); + + const group = await findBetaGroup(request, appId, options.group); + const build = await waitForBuild(request, { + appId, + version: options.version, + build: options.build, + timeoutMinutes: options.timeoutMinutes, + }); + console.log( + `Build ${options.version} (${options.build}) is VALID; review state: ${build.reviewState ?? "none"}.`, + ); + + if (build.groupIds.includes(group.id)) { + console.log(`Build is already in '${options.group}'. Nothing to do.`); + return; + } + + // An external group needs a Beta App Review submission. Apple usually + // approves a later build of an approved marketing version in minutes. An + // internal group has no review, so it skips this step. + if (!group.isInternal && build.reviewState === null) { + await request("POST", "/v1/betaAppReviewSubmissions", { + data: { + type: "betaAppReviewSubmissions", + relationships: { build: { data: { type: "builds", id: build.id } } }, + }, + }); + console.log("Submitted the build for Beta App Review."); + } + + await request("POST", `/v1/betaGroups/${group.id}/relationships/builds`, { + data: [{ type: "builds", id: build.id }], + }); + console.log( + `Added build ${options.version} (${options.build}) to '${options.group}'.`, + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/apps/mobile/src/ansi/AnsiText.tsx b/apps/mobile/src/ansi/AnsiText.tsx new file mode 100644 index 0000000000..9de2a3c0b8 --- /dev/null +++ b/apps/mobile/src/ansi/AnsiText.tsx @@ -0,0 +1,113 @@ +import { memo, useMemo } from "react"; +import { + Text as RNText, + type StyleProp, + type TextProps as RNTextProps, + type TextStyle, +} from "react-native"; +import { useTheme } from "@/theme/ThemeProvider"; +import { nativeTypography } from "@/theme/theme.native"; +import { resolveAnsiColors } from "./ansi-styles"; +import { ansiToSpans, type AnsiSpan } from "./ansi-to-spans"; + +/** Web terminal/code blocks: `font-mono text-xs leading-tight` (touch xs = 14px, tight = 1.25). */ +export const TERMINAL_FONT_SIZE = nativeTypography.xs.fontSize; +export const TERMINAL_LINE_HEIGHT = Math.round(TERMINAL_FONT_SIZE * 1.25); + +export interface AnsiSpansTextProps extends Omit { + spans: readonly AnsiSpan[]; + fontSize?: number; + lineHeight?: number; + style?: StyleProp; +} + +/** + * One `Text` with a nested `Text` per styled span. Bold swaps to the bold + * mono face (Android cannot synthesize weights), dim lowers opacity, inverse + * swaps fg/bg, and colors resolve through the theme's ANSI palette. + */ +export const AnsiSpansText = memo(function AnsiSpansText({ + spans, + fontSize = TERMINAL_FONT_SIZE, + lineHeight = TERMINAL_LINE_HEIGHT, + style, + ...rest +}: AnsiSpansTextProps) { + const { tokens, fonts } = useTheme(); + const defaults = useMemo( + () => ({ + foreground: tokens.mutedForeground, + background: tokens.background, + }), + [tokens], + ); + const rootStyle = useMemo( + () => ({ + fontFamily: fonts.mono.regular, + fontWeight: "400", + fontSize, + lineHeight, + color: defaults.foreground, + includeFontPadding: false, + }), + [defaults.foreground, fontSize, fonts.mono.regular, lineHeight], + ); + + const children = spans.map((span, index) => { + const plainStyle = + span.fg === null && + span.bg === null && + !span.bold && + !span.dim && + !span.italic && + !span.underline && + !span.strikethrough && + !span.inverse; + if (plainStyle) { + return span.text; + } + const colors = resolveAnsiColors(span, tokens, defaults); + const spanStyle: TextStyle = { + color: colors.color, + backgroundColor: colors.backgroundColor, + fontFamily: span.bold ? fonts.mono.bold : fonts.mono.regular, + fontWeight: span.bold ? "700" : "400", + fontStyle: span.italic ? "italic" : "normal", + opacity: span.dim ? 0.6 : 1, + textDecorationLine: + span.underline && span.strikethrough + ? "underline line-through" + : span.underline + ? "underline" + : span.strikethrough + ? "line-through" + : "none", + }; + return ( + + {span.text} + + ); + }); + + return ( + + {/* An empty Text collapses to zero height; keep blank lines tall. */} + {children.length === 0 ? " " : children} + + ); +}); + +export interface AnsiTextProps extends Omit { + /** Raw terminal output (may contain escape sequences). */ + text: string; +} + +/** Parses `text` and renders it with `AnsiSpansText`. */ +export const AnsiText = memo(function AnsiText({ + text, + ...rest +}: AnsiTextProps) { + const spans = useMemo(() => ansiToSpans(text), [text]); + return ; +}); diff --git a/apps/mobile/src/ansi/TerminalOutputBlock.tsx b/apps/mobile/src/ansi/TerminalOutputBlock.tsx new file mode 100644 index 0000000000..783d84e1dc --- /dev/null +++ b/apps/mobile/src/ansi/TerminalOutputBlock.tsx @@ -0,0 +1,166 @@ +import { memo, useMemo, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { cn, Text } from "@/ui"; +import { + AnsiSpansText, + TERMINAL_FONT_SIZE, + TERMINAL_LINE_HEIGHT, +} from "./AnsiText"; +import { ansiToLines } from "./ansi-to-spans"; +import { + selectTerminalTail, + TERMINAL_DEFAULT_MAX_LINES, +} from "./terminal-output"; + +export interface TerminalOutputBlockProps { + /** Raw command output; ANSI escapes are rendered, cursor codes stripped. */ + output: string; + /** Shown above the output, clamped to two lines until tapped. */ + commandLine?: string; + exitCode?: number | null; + metadataLines?: readonly string[]; + /** + * Whether the producing row is still pending. Collapsed output keeps the + * tail, so newly streamed lines stay visible. + */ + streaming?: boolean; + /** Visible line cap before "N earlier lines"; `Infinity` disables it. */ + maxLines?: number; + className?: string; + testID?: string; +} + +/** + * Command card mirroring the web `TerminalOutputBlock`: command line, + * metadata, ANSI-colored output in a horizontally scrolling monospace block + * that collapses to its tail with an "N earlier lines" toggle, exit code. + * The web dims the whole card to 70%; so does this one. + */ +export const TerminalOutputBlock = memo(function TerminalOutputBlock({ + output, + commandLine, + exitCode = null, + metadataLines = [], + streaming = false, + maxLines = TERMINAL_DEFAULT_MAX_LINES, + className, + testID, +}: TerminalOutputBlockProps) { + const [expanded, setExpanded] = useState(false); + const [commandExpanded, setCommandExpanded] = useState(false); + const lines = useMemo( + () => (output.length > 0 ? ansiToLines(output) : []), + [output], + ); + const { visible, hiddenLines } = useMemo( + () => selectTerminalTail(lines, maxLines, expanded), + [lines, maxLines, expanded], + ); + const hasOutput = lines.length > 0; + const hasHeader = Boolean(commandLine) || metadataLines.length > 0; + + return ( + + + {commandLine ? ( + setCommandExpanded((value) => !value)} + accessibilityRole="button" + accessibilityLabel={ + commandExpanded ? "Collapse command" : "Expand command" + } + > + + {commandLine} + + + ) : null} + {metadataLines.map((line, index) => ( + + {line} + + ))} + {hasOutput ? ( + + {hiddenLines > 0 ? ( + setExpanded(true)} + accessibilityRole="button" + accessibilityLabel={`Show ${hiddenLines} earlier lines`} + className="self-start rounded-sm py-0.5 active:bg-state-hover" + testID={testID ? `${testID}-show-earlier` : undefined} + > + + … {hiddenLines.toLocaleString("en-US")} earlier lines + + + ) : null} + + + {visible.map((spans, index) => ( + + ))} + + + {expanded && lines.length > maxLines ? ( + setExpanded(false)} + accessibilityRole="button" + accessibilityLabel="Collapse output" + className="self-start rounded-sm py-0.5 active:bg-state-hover" + > + + Collapse + + + ) : null} + + ) : null} + {exitCode !== null ? ( + + exit code {exitCode} + + ) : null} + + + ); +}); diff --git a/apps/mobile/src/ansi/ansi-styles.test.ts b/apps/mobile/src/ansi/ansi-styles.test.ts new file mode 100644 index 0000000000..23bd969793 --- /dev/null +++ b/apps/mobile/src/ansi/ansi-styles.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { nativeThemes } from "@/theme/theme.native"; +import { resolveAnsiColors } from "./ansi-styles"; + +const tokens = nativeThemes.default.dark; +const defaults = { foreground: "#fg", background: "#bg" }; + +describe("resolveAnsiColors", () => { + it("uses the defaults when no color is set", () => { + expect( + resolveAnsiColors( + { fg: null, bg: null, inverse: false }, + tokens, + defaults, + ), + ).toEqual({ color: "#fg", backgroundColor: undefined }); + }); + + it("maps palette indexes to the theme tokens", () => { + expect( + resolveAnsiColors({ fg: 1, bg: null, inverse: false }, tokens, defaults), + ).toEqual({ color: tokens.ansi1, backgroundColor: undefined }); + expect( + resolveAnsiColors({ fg: 15, bg: 4, inverse: false }, tokens, defaults), + ).toEqual({ color: tokens.ansi15, backgroundColor: tokens.ansi4 }); + }); + + it("forces the contrast foreground on a background-only span", () => { + expect( + resolveAnsiColors({ fg: null, bg: 3, inverse: false }, tokens, defaults), + ).toEqual({ color: tokens.ansiBgFg3, backgroundColor: tokens.ansi3 }); + }); + + it("swaps sides for inverse video", () => { + expect( + resolveAnsiColors({ fg: 2, bg: null, inverse: true }, tokens, defaults), + ).toEqual({ color: "#bg", backgroundColor: tokens.ansi2 }); + expect( + resolveAnsiColors( + { fg: null, bg: null, inverse: true }, + tokens, + defaults, + ), + ).toEqual({ color: "#bg", backgroundColor: "#fg" }); + }); +}); diff --git a/apps/mobile/src/ansi/ansi-styles.ts b/apps/mobile/src/ansi/ansi-styles.ts new file mode 100644 index 0000000000..b71380ab41 --- /dev/null +++ b/apps/mobile/src/ansi/ansi-styles.ts @@ -0,0 +1,55 @@ +/** + * Maps parsed ANSI spans onto theme colors. Pure (no React Native) so the + * palette rules are testable: 16-color indexes resolve through `ansi0`… + * `ansi15`; a background without an explicit foreground forces the matching + * `ansiBgFg*` contrast color (the web's `addBackgroundContrastColors`); + * inverse swaps the two sides. + */ +import type { NativeThemeTokens } from "@/theme/theme.native"; +import type { AnsiPaletteIndex, AnsiSpan } from "./ansi-to-spans"; + +export interface AnsiDefaultColors { + /** Text color for spans with no ANSI foreground. */ + foreground: string; + /** Surface color used as the "background" side of an inverse span. */ + background: string; +} + +export interface ResolvedAnsiColors { + color: string; + backgroundColor: string | undefined; +} + +function ansiPaletteColor( + tokens: NativeThemeTokens, + index: AnsiPaletteIndex, +): string { + return tokens[`ansi${index}`]; +} + +function ansiBackgroundContrastColor( + tokens: NativeThemeTokens, + index: AnsiPaletteIndex, +): string { + return tokens[`ansiBgFg${index}`]; +} + +export function resolveAnsiColors( + span: Pick, + tokens: NativeThemeTokens, + defaults: AnsiDefaultColors, +): ResolvedAnsiColors { + let color = + span.fg !== null ? ansiPaletteColor(tokens, span.fg) : defaults.foreground; + let backgroundColor = + span.bg !== null ? ansiPaletteColor(tokens, span.bg) : undefined; + if (span.bg !== null && span.fg === null) { + color = ansiBackgroundContrastColor(tokens, span.bg); + } + if (span.inverse) { + const swappedColor = backgroundColor ?? defaults.background; + backgroundColor = color; + color = swappedColor; + } + return { color, backgroundColor }; +} diff --git a/apps/mobile/src/ansi/ansi-to-spans.test.ts b/apps/mobile/src/ansi/ansi-to-spans.test.ts new file mode 100644 index 0000000000..c6f01f34bf --- /dev/null +++ b/apps/mobile/src/ansi/ansi-to-spans.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { + ansiToLines, + ansiToSpans, + nearestPaletteIndex, + paletteIndexFrom256, + stripAnsi, + type AnsiSpan, +} from "./ansi-to-spans"; + +const ESC = "\u001b"; +const plain = (text: string): AnsiSpan => ({ + text, + fg: null, + bg: null, + bold: false, + dim: false, + italic: false, + underline: false, + strikethrough: false, + inverse: false, +}); + +describe("ansiToSpans", () => { + it("returns one plain span for text without escapes", () => { + expect(ansiToSpans("hello\nworld")).toEqual([plain("hello\nworld")]); + expect(ansiToSpans("")).toEqual([]); + }); + + it("applies 16-color foreground/background and resets", () => { + const spans = ansiToSpans( + `${ESC}[31mred ${ESC}[1;42mbold on green${ESC}[0m plain ${ESC}[94mbright blue${ESC}[m end`, + ); + expect(spans).toEqual([ + { ...plain("red "), fg: 1 }, + { ...plain("bold on green"), fg: 1, bg: 2, bold: true }, + plain(" plain "), + { ...plain("bright blue"), fg: 12 }, + plain(" end"), + ]); + }); + + it("handles individual attribute toggles and default color resets", () => { + const spans = ansiToSpans( + `${ESC}[2;3;4;9mA${ESC}[22;23mB${ESC}[24;29mC${ESC}[33;44mD${ESC}[39mE${ESC}[49mF${ESC}[7mG${ESC}[27mH`, + ); + expect(spans.map((span) => span.text)).toEqual([ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + ]); + expect(spans[0]).toMatchObject({ + dim: true, + italic: true, + underline: true, + strikethrough: true, + }); + expect(spans[1]).toMatchObject({ + dim: false, + italic: false, + underline: true, + strikethrough: true, + }); + expect(spans[2]).toMatchObject({ underline: false, strikethrough: false }); + expect(spans[3]).toMatchObject({ fg: 3, bg: 4 }); + expect(spans[4]).toMatchObject({ fg: null, bg: 4 }); + expect(spans[5]).toMatchObject({ fg: null, bg: null }); + expect(spans[6]).toMatchObject({ inverse: true }); + expect(spans[7]).toMatchObject({ inverse: false }); + }); + + it("snaps 256-color and truecolor to the nearest of the 16 palette slots", () => { + const spans = ansiToSpans( + `${ESC}[38;5;196mR${ESC}[48;5;21mB${ESC}[38;2;0;200;0mG${ESC}[38:5:226mY${ESC}[38;5;244mgray${ESC}[0m`, + ); + expect(spans.map((span) => [span.text, span.fg, span.bg])).toEqual([ + ["R", 9, null], + ["B", 9, 4], + ["G", 2, 4], + ["Y", 11, 4], + ["gray", 8, 4], + ]); + expect(paletteIndexFrom256(0)).toBe(0); + expect(paletteIndexFrom256(15)).toBe(15); + expect(paletteIndexFrom256(16)).toBe(0); + expect(paletteIndexFrom256(231)).toBe(15); + expect(paletteIndexFrom256(232)).toBe(0); + expect(paletteIndexFrom256(255)).toBe(7); + expect(paletteIndexFrom256(999)).toBe(7); + expect(nearestPaletteIndex(255, 255, 255)).toBe(15); + expect(nearestPaletteIndex(130, 0, 0)).toBe(1); + }); + + it("ignores malformed extended color sequences without crashing", () => { + expect(ansiToSpans(`${ESC}[38;5mX`)).toEqual([plain("X")]); + expect(ansiToSpans(`${ESC}[38;2;1;2mX`)).toEqual([plain("X")]); + expect(ansiToSpans(`${ESC}[38mX`)).toEqual([plain("X")]); + }); + + it("strips cursor, erase, OSC, and other non-SGR sequences", () => { + const input = + `${ESC}[2J${ESC}[H${ESC}[?25l${ESC}[1A${ESC}[2K` + + `${ESC}]0;window title` + + `${ESC}]8;;https://example.com${ESC}\\link${ESC}]8;;${ESC}\\` + + `${ESC}(B${ESC}7${ESC}[31m text${ESC}[0m${ESC}[?25h`; + expect(ansiToSpans(input)).toEqual([ + plain("link"), + { ...plain(" text"), fg: 1 }, + ]); + expect(stripAnsi(input)).toBe("link text"); + }); + + it("keeps an unterminated escape from swallowing nothing but itself", () => { + expect(stripAnsi(`abc${ESC}[31`)).toBe("abc"); + expect(stripAnsi(`abc${ESC}`)).toBe("abc"); + }); + + it("rewinds the current line on a lone carriage return and keeps CRLF", () => { + expect(stripAnsi("10%\r50%\r100%\ndone\r\n")).toBe("100%\ndone\n"); + expect(stripAnsi("a\b")).toBe("a"); + const spans = ansiToSpans( + `${ESC}[32mfirst${ESC}[0m\n${ESC}[33mprogress 1${ESC}[0m\rfinal`, + ); + expect(spans).toEqual([{ ...plain("first"), fg: 2 }, plain("\nfinal")]); + }); + + it("merges adjacent spans with identical style", () => { + expect(ansiToSpans(`${ESC}[31ma${ESC}[31mb${ESC}[1m${ESC}[22mc`)).toEqual([ + { ...plain("abc"), fg: 1 }, + ]); + }); +}); + +describe("ansiToLines", () => { + it("splits styled spans into lines and drops the trailing empty line", () => { + const lines = ansiToLines(`${ESC}[1mbold\nstill bold${ESC}[0m plain\n`); + expect(lines).toEqual([ + [{ ...plain("bold"), bold: true }], + [{ ...plain("still bold"), bold: true }, plain(" plain")], + ]); + expect(ansiToLines("a\n\nb")).toEqual([[plain("a")], [], [plain("b")]]); + expect(ansiToLines("")).toEqual([[]]); + }); +}); diff --git a/apps/mobile/src/ansi/ansi-to-spans.ts b/apps/mobile/src/ansi/ansi-to-spans.ts new file mode 100644 index 0000000000..92ccec24a1 --- /dev/null +++ b/apps/mobile/src/ansi/ansi-to-spans.ts @@ -0,0 +1,399 @@ +/** + * Small SGR (Select Graphic Rendition) parser: turns terminal output with ANSI + * escapes into styled spans whose colors are indexes into the 16-color theme + * palette (`ansi0`…`ansi15` in theme.native.ts). 256-color and truecolor + * sequences snap to the nearest of the 16 so every color follows the active + * palette; cursor movement, erase, OSC (titles, hyperlinks), and other + * non-SGR control sequences are stripped. A lone carriage return rewinds the + * current line (progress bars render their final state). + * + * Pure TypeScript (no React Native), vitest-tested. + */ + +/** Index into the theme's 16-color ANSI palette. */ +export type AnsiPaletteIndex = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15; + +export interface AnsiSpan { + text: string; + fg: AnsiPaletteIndex | null; + bg: AnsiPaletteIndex | null; + bold: boolean; + dim: boolean; + italic: boolean; + underline: boolean; + strikethrough: boolean; + /** Swap foreground and background when rendering. */ + inverse: boolean; +} + +type SpanStyle = Omit; + +const DEFAULT_STYLE: SpanStyle = { + fg: null, + bg: null, + bold: false, + dim: false, + italic: false, + underline: false, + strikethrough: false, + inverse: false, +}; + +/** + * Reference RGB values for the 16 xterm colors, used only to pick the nearest + * palette slot for 256-color / truecolor requests. The rendered color comes + * from the theme, not from this table. + */ +const XTERM_16: readonly (readonly [number, number, number])[] = [ + [0, 0, 0], + [205, 0, 0], + [0, 205, 0], + [205, 205, 0], + [0, 0, 238], + [205, 0, 205], + [0, 205, 205], + [229, 229, 229], + [127, 127, 127], + [255, 0, 0], + [0, 255, 0], + [255, 255, 0], + [92, 92, 255], + [255, 0, 255], + [0, 255, 255], + [255, 255, 255], +]; + +const CUBE_STEPS = [0, 95, 135, 175, 215, 255] as const; + +function asPaletteIndex(value: number): AnsiPaletteIndex { + const clamped = Math.min(15, Math.max(0, Math.trunc(value))); + return clamped as AnsiPaletteIndex; +} + +/** Nearest of the 16 reference colors by Euclidean RGB distance. */ +export function nearestPaletteIndex( + r: number, + g: number, + b: number, +): AnsiPaletteIndex { + let best = 0; + let bestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < XTERM_16.length; index += 1) { + const [cr, cg, cb] = XTERM_16[index]!; + const distance = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2; + if (distance < bestDistance) { + bestDistance = distance; + best = index; + } + } + return asPaletteIndex(best); +} + +/** Maps an xterm 256-color index onto the 16-color palette. */ +export function paletteIndexFrom256(index: number): AnsiPaletteIndex { + if (!Number.isFinite(index)) return 7; + const value = Math.trunc(index); + if (value < 0) return 7; + if (value < 16) return asPaletteIndex(value); + if (value < 232) { + const cube = value - 16; + const r = CUBE_STEPS[Math.floor(cube / 36) % 6]!; + const g = CUBE_STEPS[Math.floor(cube / 6) % 6]!; + const b = CUBE_STEPS[cube % 6]!; + return nearestPaletteIndex(r, g, b); + } + if (value < 256) { + const gray = 8 + (value - 232) * 10; + return nearestPaletteIndex(gray, gray, gray); + } + return 7; +} + +function applySgr(style: SpanStyle, params: readonly number[]): SpanStyle { + let next = { ...style }; + if (params.length === 0) { + return { ...DEFAULT_STYLE }; + } + for (let index = 0; index < params.length; index += 1) { + const code = params[index]!; + if (code === 0) { + next = { ...DEFAULT_STYLE }; + } else if (code === 1) { + next.bold = true; + } else if (code === 2) { + next.dim = true; + } else if (code === 3) { + next.italic = true; + } else if (code === 4) { + next.underline = true; + } else if (code === 7) { + next.inverse = true; + } else if (code === 9) { + next.strikethrough = true; + } else if (code === 22) { + next.bold = false; + next.dim = false; + } else if (code === 23) { + next.italic = false; + } else if (code === 24) { + next.underline = false; + } else if (code === 27) { + next.inverse = false; + } else if (code === 29) { + next.strikethrough = false; + } else if (code >= 30 && code <= 37) { + next.fg = asPaletteIndex(code - 30); + } else if (code === 39) { + next.fg = null; + } else if (code >= 40 && code <= 47) { + next.bg = asPaletteIndex(code - 40); + } else if (code === 49) { + next.bg = null; + } else if (code >= 90 && code <= 97) { + next.fg = asPaletteIndex(code - 90 + 8); + } else if (code >= 100 && code <= 107) { + next.bg = asPaletteIndex(code - 100 + 8); + } else if (code === 38 || code === 48) { + const mode = params[index + 1]; + let color: AnsiPaletteIndex | null = null; + if (mode === 5 && params.length > index + 2) { + color = paletteIndexFrom256(params[index + 2]!); + index += 2; + } else if (mode === 2 && params.length > index + 4) { + color = nearestPaletteIndex( + params[index + 2]!, + params[index + 3]!, + params[index + 4]!, + ); + index += 4; + } else { + // Malformed extended color: consume the rest, as terminals do. + index = params.length; + } + if (color !== null) { + if (code === 38) next.fg = color; + else next.bg = color; + } + } + // Anything else (blink, fonts, ideogram, …) is ignored. + } + return next; +} + +function parseSgrParams(raw: string): number[] { + if (raw.length === 0) return []; + const params: number[] = []; + for (const part of raw.split(/[;:]/u)) { + params.push(part.length === 0 ? 0 : Number.parseInt(part, 10)); + } + return params.filter((value) => Number.isFinite(value)); +} + +function sameStyle(a: SpanStyle, b: SpanStyle): boolean { + return ( + a.fg === b.fg && + a.bg === b.bg && + a.bold === b.bold && + a.dim === b.dim && + a.italic === b.italic && + a.underline === b.underline && + a.strikethrough === b.strikethrough && + a.inverse === b.inverse + ); +} + +const ESC = "\u001b"; +const BEL = "\u0007"; + +/** + * Length of the control sequence starting at `input[start]` (which is ESC), + * and its SGR parameter string when it is an SGR sequence. + */ +function readEscape( + input: string, + start: number, +): { length: number; sgr: string | null } { + const next = input[start + 1]; + if (next === "[") { + // CSI: ESC [ params intermediates final (0x40–0x7E) + let index = start + 2; + while (index < input.length) { + const code = input.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) { + const final = input[index]; + const body = input.slice(start + 2, index); + return { + length: index - start + 1, + sgr: final === "m" && /^[0-9;:]*$/u.test(body) ? body : null, + }; + } + index += 1; + } + return { length: input.length - start, sgr: null }; + } + if (next === "]") { + // OSC: ESC ] … BEL | ESC \ + let index = start + 2; + while (index < input.length) { + if (input[index] === BEL) { + return { length: index - start + 1, sgr: null }; + } + if (input[index] === ESC && input[index + 1] === "\\") { + return { length: index - start + 2, sgr: null }; + } + index += 1; + } + return { length: input.length - start, sgr: null }; + } + if (next === "(" || next === ")" || next === "#" || next === "%") { + // Character-set designations and similar two-byte intermediates. + return { length: Math.min(3, input.length - start), sgr: null }; + } + if (next === undefined) { + return { length: 1, sgr: null }; + } + // Other two-byte escapes (ESC 7, ESC 8, ESC =, ESC M, …). + return { length: 2, sgr: null }; +} + +class SpanBuilder { + readonly spans: AnsiSpan[] = []; + private buffer = ""; + private style: SpanStyle = { ...DEFAULT_STYLE }; + + append(text: string): void { + this.buffer += text; + } + + setStyle(style: SpanStyle): void { + if (sameStyle(style, this.style)) return; + this.flush(); + this.style = style; + } + + currentStyle(): SpanStyle { + return this.style; + } + + /** Drops everything after the last newline: terminal `\r` overwrite. */ + rewindLine(): void { + const bufferBreak = this.buffer.lastIndexOf("\n"); + if (bufferBreak !== -1) { + this.buffer = this.buffer.slice(0, bufferBreak + 1); + return; + } + this.buffer = ""; + while (this.spans.length > 0) { + const last = this.spans[this.spans.length - 1]!; + const lineBreak = last.text.lastIndexOf("\n"); + if (lineBreak !== -1) { + last.text = last.text.slice(0, lineBreak + 1); + return; + } + this.spans.pop(); + } + } + + flush(): void { + if (this.buffer.length === 0) return; + const last = this.spans[this.spans.length - 1]; + if (last && sameStyle(last, this.style)) { + last.text += this.buffer; + } else { + this.spans.push({ text: this.buffer, ...this.style }); + } + this.buffer = ""; + } +} + +/** Parses terminal output into styled spans. */ +export function ansiToSpans(input: string): AnsiSpan[] { + const builder = new SpanBuilder(); + let index = 0; + let plainStart = 0; + + const flushPlain = (end: number) => { + if (end > plainStart) builder.append(input.slice(plainStart, end)); + }; + + while (index < input.length) { + const char = input[index]!; + if (char === ESC) { + flushPlain(index); + const { length, sgr } = readEscape(input, index); + if (sgr !== null) { + builder.setStyle(applySgr(builder.currentStyle(), parseSgrParams(sgr))); + } + index += length; + plainStart = index; + continue; + } + if (char === "\r") { + flushPlain(index); + if (input[index + 1] !== "\n") { + builder.rewindLine(); + } + index += 1; + plainStart = index; + continue; + } + if (char === "\b" || char === BEL) { + flushPlain(index); + index += 1; + plainStart = index; + continue; + } + index += 1; + } + flushPlain(input.length); + builder.flush(); + return builder.spans; +} + +/** Splits spans into lines (each line is a list of spans without `\n`). */ +function splitSpansIntoLines(spans: readonly AnsiSpan[]): AnsiSpan[][] { + const lines: AnsiSpan[][] = [[]]; + for (const span of spans) { + const parts = span.text.split("\n"); + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]!; + if (index > 0) lines.push([]); + if (part.length > 0) { + lines[lines.length - 1]!.push({ ...span, text: part }); + } + } + } + return lines; +} + +/** Lines of styled spans; a trailing newline does not produce an empty last line. */ +export function ansiToLines(input: string): AnsiSpan[][] { + const lines = splitSpansIntoLines(ansiToSpans(input)); + if (lines.length > 1 && lines[lines.length - 1]!.length === 0) { + lines.pop(); + } + return lines; +} + +/** The text with every escape sequence removed (and `\r` overwrites applied). */ +export function stripAnsi(input: string): string { + let out = ""; + for (const span of ansiToSpans(input)) out += span.text; + return out; +} diff --git a/apps/mobile/src/ansi/index.ts b/apps/mobile/src/ansi/index.ts new file mode 100644 index 0000000000..b0905f9ec7 --- /dev/null +++ b/apps/mobile/src/ansi/index.ts @@ -0,0 +1,2 @@ +export { AnsiText, TERMINAL_FONT_SIZE, TERMINAL_LINE_HEIGHT } from "./AnsiText"; +export { TerminalOutputBlock } from "./TerminalOutputBlock"; diff --git a/apps/mobile/src/ansi/terminal-output.test.ts b/apps/mobile/src/ansi/terminal-output.test.ts new file mode 100644 index 0000000000..12afb0ff3e --- /dev/null +++ b/apps/mobile/src/ansi/terminal-output.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { selectTerminalTail } from "./terminal-output"; + +describe("selectTerminalTail", () => { + const lines = Array.from({ length: 40 }, (_, index) => `line ${index}`); + + it("keeps the tail when collapsed", () => { + const tail = selectTerminalTail(lines, 10, false); + expect(tail.hiddenLines).toBe(30); + expect(tail.visible).toEqual(lines.slice(30)); + }); + + it("shows everything when expanded, uncapped, or within slack", () => { + expect(selectTerminalTail(lines, 10, true)).toEqual({ + visible: lines, + hiddenLines: 0, + }); + expect( + selectTerminalTail(lines, Number.POSITIVE_INFINITY, false).hiddenLines, + ).toBe(0); + expect(selectTerminalTail(lines.slice(0, 15), 10, false).hiddenLines).toBe( + 0, + ); + }); +}); diff --git a/apps/mobile/src/ansi/terminal-output.ts b/apps/mobile/src/ansi/terminal-output.ts new file mode 100644 index 0000000000..08a0b16f23 --- /dev/null +++ b/apps/mobile/src/ansi/terminal-output.ts @@ -0,0 +1,37 @@ +/** + * Pure helpers for `TerminalOutputBlock`: which lines are visible when the + * output is collapsed. The collapsed view keeps the *tail* so streaming + * output behaves like the web's sticky-bottom scroll without a nested + * vertical scroll view. + */ + +/** + * Lines shown before output collapses. The web caps the block at 288px ≈ 16 + * lines of `text-xs leading-tight`; phones have a taller viewport ratio, so + * the cap is a little more generous. + */ +export const TERMINAL_DEFAULT_MAX_LINES = 24; +/** Below this many hidden lines, collapsing is not worth a button row. */ +const TERMINAL_COLLAPSE_SLACK = 6; + +export interface TerminalTail { + visible: readonly T[]; + /** Lines hidden above `visible` (0 when everything is shown). */ + hiddenLines: number; +} + +export function selectTerminalTail( + lines: readonly T[], + maxLines: number, + expanded: boolean, +): TerminalTail { + if ( + expanded || + !Number.isFinite(maxLines) || + lines.length <= maxLines + TERMINAL_COLLAPSE_SLACK + ) { + return { visible: lines, hiddenLines: 0 }; + } + const hiddenLines = lines.length - maxLines; + return { visible: lines.slice(hiddenLines), hiddenLines }; +} diff --git a/apps/mobile/src/app-shell/PaletteProvider.tsx b/apps/mobile/src/app-shell/PaletteProvider.tsx new file mode 100644 index 0000000000..f25a2ba3d3 --- /dev/null +++ b/apps/mobile/src/app-shell/PaletteProvider.tsx @@ -0,0 +1,65 @@ +import { isBuiltInThemeId, type BuiltInThemeId } from "@bb/domain"; +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useSystemConfig } from "@/data/system/system-queries"; +import { useProfiles } from "./ProfilesProvider"; + +interface PaletteContextValue { + palette: BuiltInThemeId; + setPalette: (palette: BuiltInThemeId) => void; +} + +const PaletteContext = createContext(null); + +/** + * Holds the palette id above the theme provider so a component under the + * active profile's QueryClient can push the server's `appearance.themeId` + * up. Custom/plugin palettes fall back to `default` (plan: built-ins only). + */ +export function PaletteProvider({ + children, +}: { + children: (palette: BuiltInThemeId) => ReactNode; +}) { + const [palette, setPalette] = useState("default"); + const value = useMemo(() => ({ palette, setPalette }), [palette]); + return ( + + {children(palette)} + + ); +} + +function usePaletteSetter(): (palette: BuiltInThemeId) => void { + const value = useContext(PaletteContext); + if (!value) { + throw new Error("usePaletteSetter must be used inside "); + } + return value.setPalette; +} + +function paletteFromThemeId(themeId: string): BuiltInThemeId { + return isBuiltInThemeId(themeId) ? themeId : "default"; +} + +function ActiveServerPaletteSync() { + const setPalette = usePaletteSetter(); + const config = useSystemConfig(); + const themeId = config.data?.appearance.themeId; + useEffect(() => { + if (themeId !== undefined) setPalette(paletteFromThemeId(themeId)); + }, [themeId, setPalette]); + return null; +} + +/** Mount once under `ProfilesProvider`: mirrors the server palette. */ +export function ServerPaletteSync() { + const { connection } = useProfiles(); + return connection ? : null; +} diff --git a/apps/mobile/src/app-shell/ProfilesProvider.tsx b/apps/mobile/src/app-shell/ProfilesProvider.tsx new file mode 100644 index 0000000000..e8260de38f --- /dev/null +++ b/apps/mobile/src/app-shell/ProfilesProvider.tsx @@ -0,0 +1,131 @@ +import { + QueryClientProvider, + focusManager, + type QueryClient, +} from "@tanstack/react-query"; +import { + createContext, + useContext, + useEffect, + useMemo, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { ActiveProfileConnection } from "@/lib/connection"; +import { getProfileStore, nativeAppState } from "@/lib/native"; +import { + useProfileStoreState, + type NewServerProfile, + type ProfileStoreStatus, + type ServerProfile, + type ServerProfilePatch, +} from "@/lib/profiles"; +import { installAppStateQueryEvents } from "@/lib/query/app-state-query-events"; +import { createProfileQueryClient } from "@/lib/query/query-client"; +import type { ProfileClient } from "@/lib/sdk"; +import { getAppProfileClientRegistry } from "./client-registry"; +import { getActiveProfileConnector } from "./connector"; + +export interface ProfilesContextValue { + status: ProfileStoreStatus; + profiles: readonly ServerProfile[]; + activeProfile: ServerProfile | null; + /** Non-null when a saved profile could not be read (it was skipped). */ + loadError: string | null; + /** Live client/socket/session for `activeProfile`; null until activated. */ + connection: ActiveProfileConnection | null; + addProfile(input: NewServerProfile): Promise; + updateProfile(id: string, patch: ServerProfilePatch): Promise; + removeProfile(id: string): Promise; + setActiveProfile(id: string): Promise; +} + +const ProfilesContext = createContext(null); + +// Keeps the React tree shape stable while no profile is active (first run, +// after removing the last server). Nothing queries through it: hooks that +// need a client go through `useProfileClient`, which requires a connection. +let placeholderQueryClient: QueryClient | null = null; +function getPlaceholderQueryClient(): QueryClient { + placeholderQueryClient ??= createProfileQueryClient(); + return placeholderQueryClient; +} + +/** + * Owns the profile store, activates the selected profile (client + realtime + * + connect session), and scopes TanStack Query to the active profile's + * QueryClient. Mount once, inside the theme provider. + */ +export function ProfilesProvider({ children }: { children: ReactNode }) { + const store = getProfileStore(); + const connector = getActiveProfileConnector(); + const storeState = useProfileStoreState(store); + const connection = useSyncExternalStore( + connector.subscribe, + connector.getSnapshot, + connector.getSnapshot, + ); + + const activeProfile = useMemo( + () => + storeState.profiles.find((p) => p.id === storeState.activeProfileId) ?? + null, + [storeState.profiles, storeState.activeProfileId], + ); + + useEffect( + () => + installAppStateQueryEvents({ AppState: nativeAppState, focusManager }), + [], + ); + + useEffect(() => { + if (storeState.status !== "ready") return; + connector.activate(activeProfile); + }, [connector, storeState.status, activeProfile]); + + const value = useMemo( + () => ({ + status: storeState.status, + profiles: storeState.profiles, + activeProfile, + loadError: storeState.loadError, + connection, + addProfile: (input) => store.addProfile(input), + updateProfile: (id, patch) => store.updateProfile(id, patch), + async removeProfile(id) { + await store.removeProfile(id); + getAppProfileClientRegistry().disposeClient(id); + }, + setActiveProfile: (id) => store.setActiveProfile(id), + }), + [store, storeState, activeProfile, connection], + ); + + return ( + + + {children} + + + ); +} + +export function useProfiles(): ProfilesContextValue { + const value = useContext(ProfilesContext); + if (!value) { + throw new Error("useProfiles must be used inside "); + } + return value; +} + +/** The active profile's SDK client. Only call under an active connection. */ +export function useProfileClient(): ProfileClient { + const { connection } = useProfiles(); + if (!connection) { + throw new Error("useProfileClient requires an active server profile"); + } + return connection.client; +} diff --git a/apps/mobile/src/app-shell/ShareIntentHandler.tsx b/apps/mobile/src/app-shell/ShareIntentHandler.tsx new file mode 100644 index 0000000000..6cfa8f6a91 --- /dev/null +++ b/apps/mobile/src/app-shell/ShareIntentHandler.tsx @@ -0,0 +1,56 @@ +import { useRouter } from "expo-router"; +import { useEffect, useMemo } from "react"; +import { + composeSeedFromShareIntent, + loadShareIntentModule, + type ShareIntentModule, +} from "@/lib/share"; +import { newThreadHref } from "@/screens/shell/hrefs"; +import { toast } from "@/ui"; +import { useProfiles } from "./ProfilesProvider"; + +/** + * Inbound "Send to bb": when the binary bundles `expo-share-intent`, a share + * from another app (text / URL) opens the composer seeded with it + * (home, `/?initialPrompt=`). Without the native module (the current dev + * client; see apps/mobile/README.md "Share sheet") this renders nothing, so + * the JS side ships ahead of the native rebuild. Render once inside the + * ProfilesProvider. + */ +export function ShareIntentHandler() { + const module = useMemo(() => loadShareIntentModule(), []); + if (module === null) return null; + return ; +} + +function ShareIntentHandlerWithModule({ + module, +}: { + module: ShareIntentModule; +}) { + const router = useRouter(); + const { activeProfile } = useProfiles(); + const { hasShareIntent, shareIntent, resetShareIntent, error } = + module.useShareIntent({ resetOnBackground: true }); + useEffect(() => { + if (error) { + toast.error("Could not read the shared content", { description: error }); + } + }, [error]); + useEffect(() => { + if (!hasShareIntent) return; + // Consume the intent exactly once per share, whatever happens next. + resetShareIntent(); + if (activeProfile === null) { + toast.info("Add a server first, then share again."); + return; + } + const seed = composeSeedFromShareIntent(shareIntent); + if (seed === null) { + toast.info("Only text and links can be sent to bb for now."); + return; + } + router.navigate(newThreadHref({ initialPrompt: seed.initialPrompt })); + }, [activeProfile, hasShareIntent, resetShareIntent, router, shareIntent]); + return null; +} diff --git a/apps/mobile/src/app-shell/ThreadOpenSignalHandler.tsx b/apps/mobile/src/app-shell/ThreadOpenSignalHandler.tsx new file mode 100644 index 0000000000..a3d7ac0c99 --- /dev/null +++ b/apps/mobile/src/app-shell/ThreadOpenSignalHandler.tsx @@ -0,0 +1,34 @@ +import { usePathname, useRouter } from "expo-router"; +import { useEffect } from "react"; +import { threadHref } from "@/screens/shell/hrefs"; +import { useProfiles } from "./ProfilesProvider"; + +/** + * The realtime `thread-open` signal (`POST /threads/:id/open`, the CLI's + * `bb thread open`, agents handing a thread to the user): navigate to the + * thread on the active profile, like the web's `wsManager.onThreadOpen` → + * `navigate(route)`. The socket is closed in the background, so this only + * fires while the app is foregrounded; already being on the thread is a + * no-op. Render once inside the ProfilesProvider. + */ +export function ThreadOpenSignalHandler() { + const { connection } = useProfiles(); + const router = useRouter(); + const pathname = usePathname(); + const realtime = connection?.client.realtime ?? null; + useEffect(() => { + if (!realtime) return; + return realtime.onThreadOpen((signal) => { + if (pathnameIsThread(pathname, signal.threadId)) return; + router.push(threadHref(signal.threadId)); + }); + }, [realtime, router, pathname]); + return null; +} + +function pathnameIsThread(pathname: string, threadId: string): boolean { + return ( + pathname === `/threads/${threadId}` || + pathname.endsWith(`/threads/${threadId}`) + ); +} diff --git a/apps/mobile/src/app-shell/client-registry.ts b/apps/mobile/src/app-shell/client-registry.ts new file mode 100644 index 0000000000..94bf33231b --- /dev/null +++ b/apps/mobile/src/app-shell/client-registry.ts @@ -0,0 +1,36 @@ +import { describeMutationErrorToast } from "@/lib/query/mutation-errors"; +import { createProfileQueryClient } from "@/lib/query/query-client"; +import { + createProfileClientRegistry, + type ProfileClientRegistry, +} from "@/lib/sdk"; +import { toast } from "@/ui/Toast"; + +let instance: ProfileClientRegistry | null = null; + +/** + * App-wide profile client registry. Every profile QueryClient it builds + * routes failed mutations (those that did not opt out with + * `meta.showErrorToast: false`) to the global error toast, with + * `meta.errorMessage` as the headline — the same contract as the web app's + * mutation cache, so data hooks never toast themselves. + */ +export function getAppProfileClientRegistry(): ProfileClientRegistry { + if (!instance) { + instance = createProfileClientRegistry({ + createQueryClient: () => + createProfileQueryClient({ + onMutationError: (error, mutation) => { + const described = describeMutationErrorToast(error, mutation.meta); + if (!described) return; + toast.error(described.title, { + ...(described.description + ? { description: described.description } + : {}), + }); + }, + }), + }); + } + return instance; +} diff --git a/apps/mobile/src/app-shell/connector.ts b/apps/mobile/src/app-shell/connector.ts new file mode 100644 index 0000000000..955437037f --- /dev/null +++ b/apps/mobile/src/app-shell/connector.ts @@ -0,0 +1,50 @@ +import { + createActiveProfileConnector, + type ActiveProfileConnector, +} from "@/lib/connection"; +import { nativeAppState, nativeCookieStore } from "@/lib/native"; +import { getAppProfileClientRegistry } from "./client-registry"; +import { createSessionScheduler } from "@/lib/session"; + +let instance: ActiveProfileConnector | null = null; + +/** App-wide connector: the live socket/session for the active profile. */ +export function getActiveProfileConnector(): ActiveProfileConnector { + if (!instance) { + instance = createActiveProfileConnector({ + registry: getAppProfileClientRegistry(), + appState: nativeAppState, + createSessionScheduler: () => + createSessionScheduler({ cookieStore: nativeCookieStore }), + }); + } + return instance; +} + +/** + * Resolve once the connector has activated `profileId` (the ProfilesProvider + * activates the store's active profile on its next render), or after 5 s so + * a deep link / notification never hangs on a profile that fails to come up. + * Resolves immediately when it is already live. + */ +export function waitForActiveConnection(profileId: string): Promise { + const timeoutMs = 5_000; + const connector = getActiveProfileConnector(); + if (connector.getSnapshot()?.profile.id === profileId) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + unsubscribe(); + resolve(ok); + }; + const unsubscribe = connector.subscribe(() => { + if (connector.getSnapshot()?.profile.id === profileId) finish(true); + }); + const timer = setTimeout(() => finish(false), timeoutMs); + }); +} diff --git a/apps/mobile/src/app-shell/e2e.ts b/apps/mobile/src/app-shell/e2e.ts new file mode 100644 index 0000000000..b9542a2c4f --- /dev/null +++ b/apps/mobile/src/app-shell/e2e.ts @@ -0,0 +1,27 @@ +import { + isE2eModeEnabled, + resetAppState, + shouldResetOnLaunch, + type E2eEnv, +} from "@/lib/e2e"; +import { getProfileStore } from "@/lib/native"; +import { getAppProfileClientRegistry } from "./client-registry"; +import { getPreferencesStorage } from "./preferences-storage"; + +// `EXPO_PUBLIC_*` values are inlined by Metro at bundle time. +const env: E2eEnv = { EXPO_PUBLIC_BB_E2E: process.env.EXPO_PUBLIC_BB_E2E }; + +/** Dev builds and `EXPO_PUBLIC_BB_E2E=1` bundles expose the reset entry. */ +export const e2eModeEnabled = isE2eModeEnabled(env, __DEV__); + +/** Only `EXPO_PUBLIC_BB_E2E=1` bundles wipe state on every launch. */ +export const resetOnLaunch = shouldResetOnLaunch(env); + +/** Wipe profiles, live clients, and preferences (first-run state). */ +export async function resetLocalState(): Promise { + await resetAppState({ + profileStore: getProfileStore(), + preferences: getPreferencesStorage(), + disposeClients: () => getAppProfileClientRegistry().disposeAll(), + }); +} diff --git a/apps/mobile/src/app-shell/index.ts b/apps/mobile/src/app-shell/index.ts new file mode 100644 index 0000000000..258dce3942 --- /dev/null +++ b/apps/mobile/src/app-shell/index.ts @@ -0,0 +1,15 @@ +// App shell glue: providers, boot, and hooks screens read from. RN-dependent. +export { e2eModeEnabled, resetLocalState } from "./e2e"; +export { PaletteProvider, ServerPaletteSync } from "./PaletteProvider"; +export { + ProfilesProvider, + useProfileClient, + useProfiles, +} from "./ProfilesProvider"; +export { ThreadOpenSignalHandler } from "./ThreadOpenSignalHandler"; +export { ShareIntentHandler } from "./ShareIntentHandler"; +export { useAppBoot } from "./useAppBoot"; +export { + useConnectionBanner, + useRealtimeConnectionState, +} from "./useRealtimeState"; diff --git a/apps/mobile/src/app-shell/preferences-storage.ts b/apps/mobile/src/app-shell/preferences-storage.ts new file mode 100644 index 0000000000..582de8ecc6 --- /dev/null +++ b/apps/mobile/src/app-shell/preferences-storage.ts @@ -0,0 +1,11 @@ +import { createMMKV } from "react-native-mmkv"; +import type { ClearableStorage } from "@/lib/e2e"; + +/** + * The client-local preferences store (`bb.preferences`; theme mode lives + * here, see `src/theme/theme-storage.ts`). MMKV instances with the same id + * share one backing store, so this handle can wipe it for the e2e reset. + */ +export function getPreferencesStorage(): ClearableStorage { + return createMMKV({ id: "bb.preferences" }); +} diff --git a/apps/mobile/src/app-shell/useAppBoot.ts b/apps/mobile/src/app-shell/useAppBoot.ts new file mode 100644 index 0000000000..79cc8edfe3 --- /dev/null +++ b/apps/mobile/src/app-shell/useAppBoot.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { getProfileStore } from "@/lib/native"; +import { resetLocalState, resetOnLaunch } from "./e2e"; + +export interface AppBootState { + ready: boolean; + /** Set when boot failed; the app still renders so the user can recover. */ + error: string | null; +} + +/** + * Work that must finish before the first frame: read the saved server + * profiles (SecureStore) and, for e2e bundles, wipe local state so every + * Maestro run starts from first-run. The root layout keeps the splash up + * until this and the fonts are ready. + */ +export function useAppBoot(): AppBootState { + const [state, setState] = useState({ + ready: false, + error: null, + }); + useEffect(() => { + let cancelled = false; + (async () => { + await getProfileStore().load(); + if (resetOnLaunch) await resetLocalState(); + })() + .then(() => { + if (!cancelled) setState({ ready: true, error: null }); + }) + .catch((error: unknown) => { + if (!cancelled) { + setState({ + ready: true, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + return () => { + cancelled = true; + }; + }, []); + return state; +} diff --git a/apps/mobile/src/app-shell/useRealtimeState.ts b/apps/mobile/src/app-shell/useRealtimeState.ts new file mode 100644 index 0000000000..78fe49e9f3 --- /dev/null +++ b/apps/mobile/src/app-shell/useRealtimeState.ts @@ -0,0 +1,51 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { + CONNECTING_BANNER_GRACE_MS, + deriveConnectionBanner, + type ConnectionBannerKind, +} from "@/lib/connection"; +import type { MobileRealtimeConnectionState } from "@/lib/realtime"; +import { useProfiles } from "./ProfilesProvider"; + +/** Realtime socket state of the active profile (`connecting` when none). */ +export function useRealtimeConnectionState(): MobileRealtimeConnectionState { + const { connection } = useProfiles(); + const realtime = connection?.client.realtime ?? null; + return useSyncExternalStore( + (listener) => realtime?.onConnectionStateChange(listener) ?? (() => {}), + () => realtime?.getConnectionState() ?? "connecting", + () => "connecting", + ); +} + +/** + * What the connection banner should show for the active profile. The + * initial-connect grace period is timed here (the pure derivation only sees + * elapsed time). + */ +export function useConnectionBanner(): ConnectionBannerKind { + const { connection } = useProfiles(); + const realtimeState = useRealtimeConnectionState(); + const clientKey = connection?.client.profileId ?? null; + // The profile whose initial connect has outlived the grace period. Derived + // (not reset) so the effect never sets state synchronously. + const [graceElapsedFor, setGraceElapsedFor] = useState(null); + + useEffect(() => { + if (realtimeState !== "connecting" || clientKey === null) return; + const timer = setTimeout( + () => setGraceElapsedFor(clientKey), + CONNECTING_BANNER_GRACE_MS, + ); + return () => clearTimeout(timer); + }, [realtimeState, clientKey]); + + if (!connection) return "hidden"; + return deriveConnectionBanner({ + session: connection.session, + realtime: realtimeState, + suspended: connection.client.realtime.isSuspended(), + connectingForMs: + graceElapsedFor === clientKey ? CONNECTING_BANNER_GRACE_MS : 0, + }); +} diff --git a/apps/mobile/src/composer/AttachmentChips.tsx b/apps/mobile/src/composer/AttachmentChips.tsx new file mode 100644 index 0000000000..55dfa59316 --- /dev/null +++ b/apps/mobile/src/composer/AttachmentChips.tsx @@ -0,0 +1,318 @@ +import type { PromptDraftAttachment } from "@bb/client-core"; +import { Image } from "expo-image"; +import { useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { + ImageLightbox, + openLightbox, + stepLightbox, + type LightboxImage, + type LightboxState, +} from "@/screens/thread/timeline"; +import { useTheme } from "@/theme"; +import { Icon, Spinner, Text } from "@/ui"; +import type { PendingAttachment } from "./useComposerAttachments"; + +export interface AttachmentChipsProps { + attachments: readonly PromptDraftAttachment[]; + pending: readonly PendingAttachment[]; + /** Local preview URIs for images uploaded from this device. */ + previewUriByPath: ReadonlyMap; + /** Remote URL for an uploaded attachment path (images without a local preview). */ + resolveImageUrl?: (attachment: PromptDraftAttachment) => string | null; + onRemove: (path: string) => void; + disabled?: boolean; + testID?: string; +} + +const THUMB = 64; +const THUMB_RADIUS = 12; +/** The corner remove button on an image thumbnail. */ +const REMOVE_BUTTON = 20; +// The remove button sits on the photograph, so it is black/white like the +// lightbox chrome (web `bg-black/55 text-white`), not a palette token. +const REMOVE_BUTTON_BACKGROUND = "rgba(0, 0, 0, 0.6)"; +const REMOVE_BUTTON_PRESSED_BACKGROUND = "rgba(0, 0, 0, 0.8)"; +const REMOVE_BUTTON_FOREGROUND = "#ffffff"; + +/** + * An image thumbnail with a small remove button in its top-right corner. A + * tap on the picture opens the lightbox. + */ +function ImageChip({ + uri, + label, + onPress, + onRemove, + testID, +}: { + uri: string; + label: string; + onPress: () => void; + onRemove?: () => void; + testID: string; +}) { + const { tokens } = useTheme(); + return ( + + ({ + width: THUMB, + height: THUMB, + borderRadius: THUMB_RADIUS, + borderWidth: 1, + borderColor: tokens.border, + backgroundColor: tokens.surfaceRaisedSolid, + overflow: "hidden", + opacity: pressed ? 0.8 : 1, + })} + > + + + {onRemove ? ( + ({ + position: "absolute", + top: 4, + right: 4, + width: REMOVE_BUTTON, + height: REMOVE_BUTTON, + borderRadius: REMOVE_BUTTON / 2, + alignItems: "center", + justifyContent: "center", + backgroundColor: pressed + ? REMOVE_BUTTON_PRESSED_BACKGROUND + : REMOVE_BUTTON_BACKGROUND, + })} + > + + + ) : null} + + ); +} + +function ChipFrame({ + children, + onRemove, + label, + testID, +}: { + children: React.ReactNode; + onRemove?: () => void; + label: string; + testID: string; +}) { + const { tokens } = useTheme(); + return ( + + {children} + {onRemove ? ( + ({ + width: 24, + height: 24, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + backgroundColor: pressed ? tokens.stateHover : "transparent", + })} + > + + + ) : null} + + ); +} + +interface ResolvedAttachment { + attachment: PromptDraftAttachment; + /** Loadable image URI; null for files and for images without a source. */ + uri: string | null; +} + +/** + * Horizontal strip of attached files (image thumbnails, file chips, uploads + * in flight). Image thumbnails open the same lightbox as timeline images. + */ +export function AttachmentChips({ + attachments, + pending, + previewUriByPath, + resolveImageUrl, + onRemove, + disabled = false, + testID = "composer-attachments", +}: AttachmentChipsProps) { + const { tokens } = useTheme(); + const [lightbox, setLightbox] = useState(null); + if (attachments.length === 0 && pending.length === 0) return null; + + const resolved: ResolvedAttachment[] = attachments.map((attachment) => ({ + attachment, + uri: + attachment.type === "localImage" + ? (previewUriByPath.get(attachment.path) ?? + resolveImageUrl?.(attachment) ?? + null) + : null, + })); + const lightboxImages: LightboxImage[] = resolved.flatMap( + ({ attachment, uri }) => + uri === null ? [] : [{ src: uri, alt: attachment.name }], + ); + + return ( + <> + + {resolved.map(({ attachment, uri }, index) => { + const remove = disabled + ? undefined + : () => onRemove(attachment.path); + if (uri !== null) { + const imageIndex = lightboxImages.findIndex( + (image) => image.src === uri, + ); + return ( + + setLightbox(openLightbox(lightboxImages, imageIndex)) + } + onRemove={remove} + testID={`${testID}-${index}`} + /> + ); + } + return ( + + + + + {attachment.name} + + + + ); + })} + {pending.map((entry) => + entry.previewUri ? ( + + + + + + + ) : ( + + + + + {entry.name} + + + + ), + )} + + setLightbox(null)} + onStep={(direction) => + setLightbox((current) => + current === null ? current : stepLightbox(current, direction), + ) + } + /> + + ); +} diff --git a/apps/mobile/src/composer/Composer.tsx b/apps/mobile/src/composer/Composer.tsx new file mode 100644 index 0000000000..fd40ef065b --- /dev/null +++ b/apps/mobile/src/composer/Composer.tsx @@ -0,0 +1,790 @@ +import type { + PromptDraftAttachment, + PromptMentionSuggestion, + ProviderCommandSuggestion, +} from "@bb/client-core"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; +import { haptic } from "@/lib/haptics"; +import { useProfileClient } from "@/app-shell/ProfilesProvider"; +import { buildProjectAttachmentContentUrl } from "@/data/thread-detail"; +import { useSystemConfig, useSystemProviders } from "@/data/system"; +import { useTheme } from "@/theme"; +import { + ActionSheet, + Button, + Icon, + SheetPresenceContext, + Spinner, + useOverlayBounds, + useSheet, + type ActionSheetAction, +} from "@/ui"; +import { AttachmentChips } from "./AttachmentChips"; +import { ComposerInput, type ComposerInputHandle } from "./ComposerInput"; +import { + ExecutionControls, + type ExecutionControlsProps, +} from "./ExecutionControls"; +import { + buildComposerPromptActions, + commandInsertionFromSuggestion, + hasComposerText, + hasWhitespaceAt, + insertMention, + insertText, + mentionInsertionFromSuggestion, + PROMPT_ACTION_PRESENTATION, + resolvePromptActionInsertion, + resolveSubmitAffordance, + resolveTypeaheadMaxHeight, + TYPEAHEAD_GAP, + type ComposerAction, + type ComposerPromptAction, + type ComposerSubmitKind, + type ComposerSubmitMode, + type ComposerValue, + type TextSelection, +} from "./model"; +import { TypeaheadMenu } from "./TypeaheadMenu"; +import { useComposerAttachments } from "./useComposerAttachments"; +import { + useComposerTypeahead, + type ComposerScope, +} from "./useComposerTypeahead"; +import { useComposerVoice } from "./useComposerVoice"; +import { VoiceBar } from "./VoiceBar"; + +export interface ComposerHandle { + focus: () => void; + blur: () => void; + /** Insert text at the caret with smart spacing (voice, quotes, "+" actions). */ + insertText: (text: string) => void; +} + +export interface ComposerProps { + value: ComposerValue; + onChange: (value: ComposerValue) => void; + attachments: readonly PromptDraftAttachment[]; + onAttachmentsChange: (next: PromptDraftAttachment[]) => void; + scope: ComposerScope; + submitMode: ComposerSubmitMode; + /** `send` (ready), `queue` (runtime active), `steer` (long-press while active). */ + onSubmit: (kind: ComposerSubmitKind) => void | Promise; + /** Label for the ready-state submit button ("Send", "Create"). */ + submitLabel?: string; + isSubmitting?: boolean; + disabled?: boolean; + placeholder?: string; + /** Extra rows for the "+" menu (screen-owned: fork, new thread here, …). */ + actions?: readonly ComposerAction[]; + /** Execution pills in the footer; omit to leave the footer to the buttons. */ + executionControls?: ExecutionControlsProps | null; + /** Rendered inside the card above the attachments (context banners). */ + header?: ReactNode; + /** + * Pill row rendered above the input while expanded (the home dock's + * project / environment pickers). Hidden in the collapsed pill. + */ + topControls?: ReactNode; + /** Small trailing element in the footer (context-window readout). */ + footerAccessory?: ReactNode; + /** + * Collapse to a one-line pill ("+ · placeholder · mic") while unfocused + * and empty; focus expands the card (top controls, the footer pills, the + * submit button) and blur folds it again. A picker sheet opened from the + * card keeps it expanded and refocuses the input when it closes. + */ + collapsible?: boolean; + /** Reports pill ↔ card transitions (the home screen drives its scrim). */ + onExpandedChange?: (expanded: boolean) => void; + /** + * Where the suggestion list opens. `above` floats over whatever sits above + * the card (thread screen, composer at the bottom); `below` renders inline + * under the input (the dev showcase, composer near the top of a scroll view). + */ + typeaheadPlacement?: "above" | "below"; + minInputHeight?: number; + testID?: string; +} + +const EMPTY_ACTIONS: readonly ComposerAction[] = []; + +/** A blur this close before a sheet opens is the sheet's keyboard dismissal. */ +const BLUR_FOR_SHEET_MS = 600; + +/** + * The shared native composer (root compose + follow-up): mention pills in a + * native `TextInput`, `@` / `#` / `/` typeahead, attachments (library, + * camera, files → `POST /projects/:id/attachments`), voice (expo-audio → + * `POST /system/voice-transcription`), the "+" actions menu, execution + * pills, and a submit button driven by the client-core submit mode. + */ +export const Composer = forwardRef( + function Composer( + { + value, + onChange, + attachments, + onAttachmentsChange, + scope, + submitMode, + onSubmit, + submitLabel = "Send", + isSubmitting = false, + disabled = false, + placeholder, + actions = EMPTY_ACTIONS, + executionControls, + header, + topControls, + footerAccessory, + collapsible = false, + onExpandedChange, + typeaheadPlacement = "above", + minInputHeight, + testID = "composer", + }, + ref, + ) { + const { tokens } = useTheme(); + const { serverUrl } = useProfileClient(); + const inputRef = useRef(null); + const rootRef = useRef(null); + const valueRef = useRef(value); + useEffect(() => { + valueRef.current = value; + }, [value]); + const [selection, setSelection] = useState({ + start: 0, + end: 0, + }); + const [focused, setFocused] = useState(false); + // Sheets presented from inside the card (pickers, the "+" menu). They + // dismiss the keyboard, which must not fold the card; when the last + // one closes the input takes focus back. + const [openSheetCount, setOpenSheetCount] = useState(0); + const sheetOpen = openSheetCount > 0; + const refocusAfterSheetRef = useRef(false); + const lastBlurAtRef = useRef(0); + const onSheetPresenceChange = useCallback((open: boolean) => { + setOpenSheetCount((count) => Math.max(0, count + (open ? 1 : -1))); + // The keyboard dismissal a sheet triggers reaches the input either + // before or after the sheet reports itself open; either order arms + // the refocus. + if (open && Date.now() - lastBlurAtRef.current < BLUR_FOR_SHEET_MS) { + refocusAfterSheetRef.current = true; + } + }, []); + const sheetPresence = useMemo( + () => ({ onPresenceChange: onSheetPresenceChange }), + [onSheetPresenceChange], + ); + const handleFocus = useCallback(() => setFocused(true), []); + const handleBlur = useCallback(() => { + setFocused(false); + lastBlurAtRef.current = Date.now(); + if (sheetOpen) refocusAfterSheetRef.current = true; + }, [sheetOpen]); + useEffect(() => { + if (sheetOpen || !refocusAfterSheetRef.current) return; + refocusAfterSheetRef.current = false; + inputRef.current?.focus(); + }, [sheetOpen]); + const systemConfig = useSystemConfig(); + const providers = useSystemProviders(); + const provider = useMemo( + () => + providers.data?.find((entry) => entry.id === scope.providerId) ?? null, + [providers.data, scope.providerId], + ); + const promptActionModel = useMemo( + () => buildComposerPromptActions(provider?.composerActions ?? []), + [provider], + ); + + const commit = useCallback( + (next: ComposerValue, caret?: number) => { + valueRef.current = next; + onChange(next); + if (caret !== undefined) setSelection({ start: caret, end: caret }); + }, + [onChange], + ); + + const typeahead = useComposerTypeahead({ + scope, + value, + selection, + active: focused && !disabled, + skillsTrigger: promptActionModel.skillsTrigger, + promptActions: promptActionModel.actions, + }); + const activeTrigger = typeahead.activeTrigger; + const menu = typeahead.menu; + // Only the floating list is bounded by the room above the card; the + // inline `below` list is part of the card and must not feed back into + // its own anchor. + const { spaceAbove, measureSpaceAbove } = useSpaceAboveCard({ + rootRef, + enabled: typeaheadPlacement === "above", + menuOpen: menu !== null, + }); + const typeaheadMaxHeight = resolveTypeaheadMaxHeight(spaceAbove); + + const applyMention = useCallback( + (suggestion: PromptMentionSuggestion) => { + const trigger = activeTrigger; + if (!trigger || trigger.kind !== "mention") return; + const insertion = mentionInsertionFromSuggestion( + suggestion, + trigger.char, + ); + const current = valueRef.current; + const result = insertMention(current, { + from: trigger.from, + to: trigger.to, + ...insertion, + trailingText: hasWhitespaceAt(current.text, trigger.to) ? "" : " ", + }); + commit(result.value, result.caret); + }, + [activeTrigger, commit], + ); + + const applyCommand = useCallback( + (suggestion: ProviderCommandSuggestion) => { + const trigger = activeTrigger; + if (!trigger || trigger.kind !== "command") return; + const insertion = commandInsertionFromSuggestion( + suggestion, + trigger.char, + ); + const current = valueRef.current; + const result = insertMention(current, { + from: trigger.from, + to: trigger.to, + ...insertion, + trailingText: hasWhitespaceAt(current.text, trigger.to) ? "" : " ", + }); + commit(result.value, result.caret); + }, + [activeTrigger, commit], + ); + + const insertAtCaret = useCallback( + (rawText: string) => { + const text = rawText.replace(/\s+/g, " ").trim(); + if (text.length === 0) return; + const current = valueRef.current; + const caret = Math.min( + inputRef.current?.getSelection().start ?? current.text.length, + current.text.length, + ); + const before = current.text.slice(0, caret); + const after = current.text.slice(caret); + const lead = before.length > 0 && !/\s$/u.test(before) ? " " : ""; + const trail = after.length > 0 && !/^\s/u.test(after) ? " " : ""; + const inserted = `${lead}${text}${trail}`; + commit(insertText(current, caret, inserted), caret + inserted.length); + }, + [commit], + ); + + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + insertText: insertAtCaret, + }), + [insertAtCaret], + ); + + // --- Attachments -------------------------------------------------------- + const attachmentsController = useComposerAttachments({ + projectId: scope.projectId, + attachments, + onAttachmentsChange, + }); + const resolveImageUrl = useCallback( + (attachment: PromptDraftAttachment) => + scope.projectId + ? buildProjectAttachmentContentUrl( + serverUrl, + scope.projectId, + attachment.path, + ) + : null, + [scope.projectId, serverUrl], + ); + + // --- Voice -------------------------------------------------------------- + const voice = useComposerVoice({ + enabled: systemConfig.data?.voiceTranscriptionEnabled ?? false, + getPromptContext: () => { + const caret = inputRef.current?.getSelection().start ?? 0; + const before = valueRef.current.text.slice(0, caret).trim(); + return before.length > 0 ? before : undefined; + }, + onTranscript: insertAtCaret, + }); + const voiceBusy = + voice.state === "recording" || voice.state === "transcribing"; + + // --- "+" menu ----------------------------------------------------------- + const actionsSheet = useSheet(); + const applyPromptAction = usePromptActionApplier({ + valueRef, + inputRef, + commit, + }); + const sheetActions = useMemo((): ActionSheetAction[] => { + const rows: ActionSheetAction[] = [ + { + key: "photo-library", + label: "Photo library", + icon: "Eye", + disabled: scope.projectId === null, + onPress: () => void attachmentsController.pickFromLibrary(), + }, + { + key: "camera", + label: "Take photo", + icon: "Smartphone", + disabled: scope.projectId === null, + onPress: () => void attachmentsController.takePhoto(), + }, + { + key: "file", + label: "Attach file", + icon: "Paperclip", + disabled: scope.projectId === null, + onPress: () => void attachmentsController.pickDocument(), + }, + ]; + for (const action of promptActionModel.actions) { + const presentation = PROMPT_ACTION_PRESENTATION[action.kind]; + rows.push({ + key: `prompt-${action.kind}`, + label: action.label ?? presentation.label, + icon: presentation.icon, + disabled: action.disabled, + onPress: () => applyPromptAction(action), + }); + } + for (const action of actions) { + rows.push({ + key: `screen-${action.key}`, + label: action.label, + icon: action.icon, + destructive: action.destructive, + disabled: action.disabled, + onPress: action.onPress, + }); + } + return rows; + }, [ + actions, + applyPromptAction, + attachmentsController, + promptActionModel.actions, + scope.projectId, + ]); + + // --- Submit ------------------------------------------------------------- + const hasInput = hasComposerText(value) || attachments.length > 0; + const affordance = resolveSubmitAffordance({ + mode: submitMode, + hasInput, + isSubmitting, + disabled: disabled || attachmentsController.isUploading || voiceBusy, + readyLabel: submitLabel, + }); + const submit = useCallback( + (kind: ComposerSubmitKind) => { + haptic("impact-medium"); + void onSubmit(kind); + }, + [onSubmit], + ); + const showVoicePrimary = + voice.enabled && !hasInput && !isSubmitting && !disabled; + // Focus, text, attachments, an edit header, a voice session, or a sheet + // opened from the card keep the full card; otherwise the pill folds. + const collapsed = + collapsible && + !focused && + !sheetOpen && + !hasInput && + attachments.length === 0 && + header == null && + !voiceBusy; + const expanded = !collapsed; + const lastExpandedRef = useRef(null); + useEffect(() => { + if (lastExpandedRef.current === expanded) return; + lastExpandedRef.current = expanded; + onExpandedChange?.(expanded); + }, [expanded, onExpandedChange]); + + const menuNode = menu ? ( + + ) : null; + + return ( + + + {menuNode && typeaheadPlacement === "above" ? ( + + {menuNode} + + ) : null} + + {header} + {!collapsed && topControls ? ( + + {topControls} + + ) : null} + + {/* The input keeps its tree position in both layouts so the pill + → card transition never remounts it (that would drop focus). */} + + {collapsed ? ( +