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..30ec3063c7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,8 @@ - + + +## Human comments + + ## What was wrong @@ -15,4 +19,4 @@ Fixes # - + diff --git a/.github/workflows/check-plugin-sdk-version.mjs b/.github/workflows/check-plugin-sdk-version.mjs new file mode 100644 index 0000000000..e8bfb9555b --- /dev/null +++ b/.github/workflows/check-plugin-sdk-version.mjs @@ -0,0 +1,81 @@ +import { execFileSync } from "node:child_process"; + +/** + * A change to the published plugin SDK surface must bump PLUGIN_SDK_VERSION. + * + * Pre-1.0 the major is the compatibility number and never moves for additive + * work, so the patch is the only thing a plugin author can point + * `engines.bbPluginSdk` at to say "I need a host new enough to have this" — + * `isPluginSdkRangeSatisfied` reads that range as a floor within the major. + * Ship a new export without a bump and a plugin using it has no version to + * require, so installing it on an older bb fails at runtime instead of + * legibly at load. + * + * Nothing else catches this: `version.test.ts` only checks that package.json + * and the constant agree with each other, and `app-contract.ts` has no + * enumerated export list the way the backend, rpc, and host contracts do. + */ +const SURFACE_PATHS = [ + "packages/plugin-sdk/src/app-contract.ts", + "packages/plugin-sdk/src/app.ts", + "packages/plugin-sdk/src/backend-contract.ts", + "packages/plugin-sdk/src/host-contract.ts", + "packages/plugin-sdk/src/host.ts", + "packages/plugin-sdk/src/index.ts", + "packages/plugin-sdk/src/provider-bridge.ts", + "packages/plugin-sdk/src/rpc-contract.ts", +]; + +const VERSION_PATH = "packages/domain/src/plugin-sdk-version.ts"; + +function git(...args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +/** + * The commit this branch left the base at. Returns null when it cannot be + * resolved — an unattended push to a fork with no base, say — and the check + * then passes rather than failing on something the author cannot act on. + */ +function resolveMergeBase() { + const baseRef = process.env.GITHUB_BASE_REF || "main"; + for (const candidate of [`origin/${baseRef}`, baseRef]) { + try { + return git("merge-base", candidate, "HEAD"); + } catch { + continue; + } + } + return null; +} + +const mergeBase = resolveMergeBase(); +if (mergeBase === null) { + console.log("No base commit to compare against; skipping."); + process.exit(0); +} + +const changed = new Set( + git("diff", "--name-only", `${mergeBase}...HEAD`).split("\n").filter(Boolean), +); +const changedSurface = SURFACE_PATHS.filter((path) => changed.has(path)); + +if (changedSurface.length === 0) { + console.log("Plugin SDK surface unchanged."); + process.exit(0); +} + +if (!changed.has(VERSION_PATH)) { + console.error( + `The plugin SDK surface changed without a version bump:\n` + + changedSurface.map((path) => ` ${path}`).join("\n") + + `\n\nBump PLUGIN_SDK_VERSION in ${VERSION_PATH} (patch, pre-1.0) and` + + ` keep packages/plugin-sdk/package.json in step, so a plugin using the` + + ` new surface can require a host that has it.`, + ); + process.exit(1); +} + +console.log( + `Plugin SDK surface changed in ${changedSurface.length} file(s) with a version bump.`, +); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0dfa6383d..1f4e36a8c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,12 @@ jobs: pnpm-version: ${{ env.PNPM_VERSION }} cache-prefix: checks + # Four tasks for four vCPUs: TypeScript 7 and esbuild are multi-threaded, + # so turbo's default of ten concurrent tasks only adds contention here. + # Measured pinned to 4 CPUs, two rounds each: 96 s / 110 s at the + # default vs 90 s / 58 s at four. - name: Build, typecheck, and lint - run: pnpm exec turbo run build typecheck lint --cache-dir=.turbo/cache --output-logs=new-only + run: pnpm exec turbo run build typecheck lint --cache-dir=.turbo/cache --output-logs=new-only --concurrency=4 # Runs after build and typecheck, so the guard's own turbo build is a # cache hit and the package it packs is the one this commit really ships. @@ -72,6 +76,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-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/publish-bb-app.yml b/.github/workflows/publish-bb-app.yml index e12cc3552e..71bcac1c14 100644 --- a/.github/workflows/publish-bb-app.yml +++ b/.github/workflows/publish-bb-app.yml @@ -301,10 +301,11 @@ jobs: echo "version=${nightly_version}" >> "$GITHUB_OUTPUT" echo "Prepared bb-app and desktop version ${nightly_version}." - # bb-app has no prepack hook and its `files` list ships built output, so - # `npm publish` packs whatever is on disk. This job runs on a fresh - # runner, so it must build before it packs. smoke:tarball depends on - # build and also proves the packed tarball runs at the nightly version. + # bb-app's prepack hook prunes stale bb CLI chunks immediately before + # `npm publish` packs the built output selected by its `files` list. + # This job runs on a fresh runner, so it must build before it packs. + # smoke:tarball depends on build and also proves the packed tarball + # runs at the nightly version. - name: Smoke bb-app tarball run: pnpm exec turbo run smoke:tarball --filter=bb-app --force --output-logs=new-only @@ -717,8 +718,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/.github/workflows/version-lockstep.yml b/.github/workflows/version-lockstep.yml index 0f9d7c2628..410c336554 100644 --- a/.github/workflows/version-lockstep.yml +++ b/.github/workflows/version-lockstep.yml @@ -25,3 +25,23 @@ jobs: - name: Check version lockstep run: node .github/workflows/check-version-lockstep.mjs + + plugin-sdk: + name: Check plugin SDK version bump + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The check diffs against the merge base, which a shallow clone lacks. + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.x + + - name: Check plugin SDK version bump + run: node .github/workflows/check-plugin-sdk-version.mjs 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 51daa22ed7..0000000000 --- a/.prettierignore +++ /dev/null @@ -1,38 +0,0 @@ -# Dependencies and build output -node_modules/ -dist/ -build/ -coverage/ -.next/ -out/ -.turbo/ - -# Local/runtime state -data/ -# Source directories that happen to be named data/ -!apps/mobile/src/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/ -apps/mobile/assets/terminal/ -# Expo prebuild / typed-routes output. Gitignored in apps/mobile/.gitignore, -# but Prettier only reads the root ignore files. -apps/mobile/ios/ -apps/mobile/android/ -apps/mobile/.expo/ -apps/mobile/expo-env.d.ts -apps/mobile/build-output/ - -# These templates intentionally use aligned plain-text command columns. -packages/templates/src/templates/ diff --git a/AGENTS.md b/AGENTS.md index 60813b6606..d9e2f87f5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,7 @@ - Only write high quality tests that verify where there could be potential bugs. Avoid testing trivial getters/setters, framework wiring, or other code that is unlikely to break. - Pipe slow test output to a file, then read the file. Example: `pnpm exec turbo run test --filter=@bb/integration-tests --force > /tmp/test-out.txt 2>&1`. +- Package `vitest.config.ts` files build their `projects` with `sharedWorkerProjects` from `vitest.shared.ts`. It runs node-environment test files in shared workers (`isolate: false`) and gives a file its own worker when it runs in a DOM environment (`jsdom`) or when the file, or a test helper it imports, mutates worker-global state (`vi.mock`, `vi.stubGlobal`, `process.env`, `globalThis.*` assignments, `Object.defineProperty` on a global). Re-importing the module graph per file was 80–90% of the big suites' CPU. Restore what a test changes anyway; the scan is a safety net, not a license. ## GitHub Issues And Pull Requests @@ -64,10 +65,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/CHANGELOG.md b/CHANGELOG.md index 8d6cd06103..0542c6743c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,110 @@ # Changelog +## 0.40.0 + +This release adds the File Editor and a quick command palette. It also makes bb faster across all devices. + +### New features + +- Press Mod+Shift+P to open the quick command palette. Plugins can add commands to it. +- Archive a thread from its sidebar row. +- See plugin problems in the sidebar or with `bb status`. +- Check for plugin updates from the Plugins page. bb also checks every six hours. +- Use `bb plugin new` to create a complete example plugin. +- Use find and copy-link controls in the desktop browser. +- Match thread titles with spaces in the `@` mention menu. +- Split the thread panel into several tabs. bb restores the layout later. +- Use dark mode on the bb website. + +### Built-in plugin updates + +- **File Editor.** Enable it from Extensions > Installed Plugins. Open, edit, and save text files inside bb. +- **File Viewer.** Preview PDF files. Open an HTML preview as a full page in your browser. +- **Tasks.** Delete folders from Manage > Folders. Presets now support `ultra`, and `bb tasks detach` removes a thread from a task. +- **Docs.** Open thread storage files. Preview and save files on the host that you selected. +- **Plugin API Tester.** Enable this new developer plugin to test panel contributions. + +### Agent providers + +All built-in agent providers now use the provider API. You can use the same API to build your own provider with a first-class bb timeline. + +### CLI + +- Use `--plan` with `bb thread tell` or `bb thread spawn` to enter Plan mode. +- Use `bb thread log --all` to read the full thread history. +- Move a local plugin with `bb plugin install path:`. bb keeps its data and settings. +- Get a clear error when `bb plugin reload` fails. +- Set every general app setting with `bb settings general`. +- Start common CLI commands much faster. `bb --version` now starts in about 27 milliseconds. +- Write automation prompts of any practical length. + +### Performance + +- A thread now opens with about half as many requests. +- Smaller bundles reduce the initial load time for the app and plugins. +- Long threads use less server work and load timeline pages faster. +- Large command results load only after you expand them. +- Search uses a faster full-text index and returns shorter results. +- Large files, lists, and diffs render only the visible rows. +- Safari can recalculate plugin styles up to 40 times faster. +- Large prompt drafts respond faster to a paste or a key press. +- The app restores recent panel data after a reload. This change removes several blank states. +- The `timelineWindowing` experiment renders only visible timeline rows. + +### Experimental iOS app + +[Join our Discord](https://discord.gg/kvBU6tJhcJ) to join the TestFlight. + +### Notable fixes + +- A thread now holds a new message while it waits for your answer. bb delivers the message after your answer. +- Steer messages no longer create duplicate turns or duplicate detail rows. +- Threads keep their scroll position when older messages load. +- Forks show the conversation that they inherit. +- Side chat keeps the selected message as context for its first turn. +- Hosts reconnect more reliably after sleep or a lost server link. +- bb connect renews active sessions and retries rejected tunnel connections. +- The desktop app installs a downloaded macOS update after a relaunch. +- The desktop app no longer stops after a terminal start failure. +- Plugin service failures restart that service instead of the full server. +- Plugin commands, icons, file views, and update checks work correctly again. +- Split panels keep each tab with its panel. + +### Plugin API changes + +- Plugins can add commands to the quick palette. +- Shared host libraries reduce the size of built-in plugin bundles by 55%. +- `storage.database()` now returns one shared handle for each plugin load. +- `sdk.threads.storageLocation()` now returns the thread storage root. + +This release also adds several experimental APIs for code views, links, and pickers. These APIs can change. + +See [the API audit list](https://github.com/get-bb/bb/blob/main/docs/api_to_audit.md) for all new experimental members. + +### Thanks + +Seventeen people outside the core team added code to this release. Thank you: + +- [@jshph](https://github.com/jshph) +- [@ebg1223](https://github.com/ebg1223) +- [@lnittman](https://github.com/lnittman) +- [@kongenpei](https://github.com/kongenpei) +- [@hemaaanth](https://github.com/hemaaanth) +- [@georgecollier-nqu](https://github.com/georgecollier-nqu) +- [@patleeman](https://github.com/patleeman) +- [@Roystbeef](https://github.com/Roystbeef) +- [@bradhallett](https://github.com/bradhallett) +- [@davidondrej](https://github.com/davidondrej) +- [@MateoCerquetella](https://github.com/MateoCerquetella) +- [@Juns-g](https://github.com/Juns-g) +- [@jsilets](https://github.com/jsilets) +- [@sujeito-operator](https://github.com/sujeito-operator) +- [@builtui](https://github.com/builtui) +- [@ryanbbrown](https://github.com/ryanbbrown) +- [@Uttar](https://github.com/Uttar) + +Thank you also to everyone who reported an issue that this release fixes: **[@9amhealth-gregschwartz](https://github.com/9amhealth-gregschwartz)**, **[@aemrebarut](https://github.com/aemrebarut)**, **[@aiyi404](https://github.com/aiyi404)**, **[@ariofrio](https://github.com/ariofrio)**, **[@iamhenry](https://github.com/iamhenry)**, **[@jjcm](https://github.com/jjcm)**, **[@Joesirven](https://github.com/Joesirven)**, **[@markasoftware-tc](https://github.com/markasoftware-tc)**, **[@mattwyckhouse](https://github.com/mattwyckhouse)**, **[@MGrin](https://github.com/MGrin)**, **[@PennybagsCX](https://github.com/PennybagsCX)**, **[@pixexid](https://github.com/pixexid)**, **[@ruudk](https://github.com/ruudk)**, **[@Samuka007](https://github.com/Samuka007)**, **[@sholub-dev](https://github.com/sholub-dev)**, **[@smsunarto](https://github.com/smsunarto)**, **[@swairshah](https://github.com/swairshah)**, **[@toasterman234](https://github.com/toasterman234)**, **[@uje-m](https://github.com/uje-m)**, and **[@yurilaguardia](https://github.com/yurilaguardia)**. + ## 0.39.0 Faster large threads, child threads across projects, and a long list of fixes. diff --git a/README.md b/README.md index 5c7473a5e4..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 diff --git a/apps/app/.ladle/model-picker-query-provider.tsx b/apps/app/.ladle/model-picker-query-provider.tsx index 865956a2f8..7676b2a4f7 100644 --- a/apps/app/.ladle/model-picker-query-provider.tsx +++ b/apps/app/.ladle/model-picker-query-provider.tsx @@ -51,9 +51,11 @@ const STORY_COMPOSER_ACTIONS_BY_PROVIDER: Record< const STORY_PROVIDER_INFOS: ProviderInfo[] = STORY_PROVIDER_OPTIONS.map( (provider) => ({ id: provider.value, + pluginId: `provider-${provider.value}`, displayName: provider.label, logoUrl: null, available: true, + maintenance: { health: true, usage: true, installation: true }, composerActions: [ ...(STORY_COMPOSER_ACTIONS_BY_PROVIDER[provider.value] ?? []), ], @@ -64,6 +66,7 @@ const STORY_PROVIDER_INFOS: ProviderInfo[] = STORY_PROVIDER_OPTIONS.map( supportsNativeUserQuestion: true, supportsFork: true, supportsSessionRewind: true, + modelCatalogScope: "workspace", permissionModes: [...permissionModes], }, }), diff --git a/apps/app/.ladle/settings-story-fixtures.tsx b/apps/app/.ladle/settings-story-fixtures.tsx index f74a9ae8a0..cc570b36ca 100644 --- a/apps/app/.ladle/settings-story-fixtures.tsx +++ b/apps/app/.ladle/settings-story-fixtures.tsx @@ -1,16 +1,10 @@ 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 { PERSONAL_PROJECT_ID } 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"; @@ -19,16 +13,17 @@ import { hostsQueryKey, pluginListQueryKey, pluginMarketplacesQueryKey, + sidebarNavigationQueryKey, systemConfigQueryKey, systemVersionQueryKey, } from "../src/hooks/queries/query-keys"; -import { sidebarNavigationQueryKey } from "../src/hooks/queries/sidebar-navigation-query"; import { buildUpdateInventoryProviderIssues, type UpdateInventoryMachine, } from "../src/hooks/useUpdateInventory"; import { createAppQueryClient } from "../src/lib/query-client"; -import { getSettingsProviderRoutePath } from "../src/lib/route-paths"; +import { makeSystemConfig } from "../src/test/fixtures/system-config"; +import { getSettingsRoutePath } from "../src/lib/route-paths"; import { BbAppUpdateRows, MachineUpdatesRows, @@ -47,12 +42,12 @@ import { const SETTINGS_STORY_NOW = Date.parse("2026-08-19T08:00:00.000Z"); -export const SETTINGS_STORY_PRIMARY_HOST = makeHost({ +const SETTINGS_STORY_PRIMARY_HOST = makeHost({ createdAt: SETTINGS_STORY_NOW - 45 * 24 * 60 * 60_000, lastSeenAt: SETTINGS_STORY_NOW, }); -export const SETTINGS_STORY_HOSTS = [ +const SETTINGS_STORY_HOSTS = [ SETTINGS_STORY_PRIMARY_HOST, makeHost({ id: HOST_IDS.remote, @@ -71,15 +66,14 @@ const localProviderStatus = { installAction: { kind: "update", label: "Update", - commandKind: "exec", command: "codex update", }, }), - claudeCode: makeProviderCliStatus("claudeCode", { + "claude-code": makeProviderCliStatus("claude-code", { currentVersion: "2.1.0", latestVersion: "2.1.0", }), - cursor: makeProviderCliStatus("cursor", { + "acp-cursor": makeProviderCliStatus("acp-cursor", { currentVersion: "0.49.0", latestVersion: "0.49.0", }), @@ -93,15 +87,14 @@ const remoteProviderStatus = { installAction: { kind: "update", label: "Update", - commandKind: "exec", command: "codex update", }, }), - claudeCode: makeProviderCliStatus("claudeCode", { + "claude-code": makeProviderCliStatus("claude-code", { currentVersion: "2.1.0", latestVersion: "2.1.0", }), - cursor: makeProviderCliStatus("cursor", { + "acp-cursor": makeProviderCliStatus("acp-cursor", { installed: false, executablePath: null, currentVersion: null, @@ -131,23 +124,12 @@ const sidebarNavigation = { }, } satisfies SidebarBootstrapResponse; -const systemConfig = { - generalSettings: defaultAppSettings, - keybindings: [], - defaultKeybindings: [], - keybindingOverrides: [], - experiments: defaultExperiments, - appearance: defaultAppTheme, - customThemes: [], - pluginThemes: [], - featureFlags: { placeholder: false, timelineWindowEventBudget: 1_500 }, - hostDaemonPort: null, - serverUrl: "http://localhost:38886", +const systemConfig = makeSystemConfig({ primaryHostId: HOST_IDS.local, primaryHostPlatform: "darwin", voiceTranscriptionEnabled: true, dataDir: "/Users/michael/.bb", -} satisfies SystemConfigResponse; +}); const systemVersion = { currentVersion: "0.39.0", @@ -179,6 +161,7 @@ export function SettingsUpdatesStory() {
- navigate(getSettingsProviderRoutePath(providerId)) - } + onOpenProvider={() => navigate(getSettingsRoutePath("providers"))} />
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 9db8481bda..68258ebc56 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -12,9 +12,7 @@ import type { 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"; -import { PiIcon } from "../src/components/icons/PiIcon"; +import { getProviderIconInfo } from "../src/lib/provider-icon"; import type { PickerOption } from "../src/components/pickers/OptionPicker"; import type { ModelPickerOption } from "../src/components/pickers/model-picker-option"; import type { ProjectSelectorOption } from "../src/components/pickers/ProjectSelector"; @@ -114,10 +112,21 @@ export function makeAttachmentsConfig( // need to pre-format. // --------------------------------------------------------------------------- +// Core vendors no brand marks (they come from the provider plugins' declared +// logos), so stories draw each provider through a declared host glyph. +function storyProviderIcon(providerId: string, glyph: string) { + return getProviderIconInfo(providerId, { logoUrl: null, icon: { glyph } }) + ?.icon; +} + export const STORY_PROVIDER_OPTIONS: readonly PickerOption[] = [ - { value: "codex", label: "Codex", icon: OpenAiIcon }, - { value: "claude-code", label: "Claude Code", icon: ClaudeIcon }, - { value: "pi", label: "Pi", icon: PiIcon }, + { value: "codex", label: "Codex", icon: storyProviderIcon("codex", "Code") }, + { + value: "claude-code", + label: "Claude Code", + icon: storyProviderIcon("claude-code", "Sparkles"), + }, + { value: "pi", label: "Pi", icon: storyProviderIcon("pi", "Zap") }, ]; export const STORY_CODEX_MODELS: readonly PickerOption[] = [ @@ -421,11 +430,12 @@ export function makeProviderCliStatus( provider: ProviderCliKey, overrides: Partial = {}, ): ProviderCliStatus { - const identity = { - codex: { displayName: "Codex", executableName: "codex" }, - claudeCode: { displayName: "Claude Code", executableName: "claude" }, - cursor: { displayName: "Cursor", executableName: "agent" }, - }[provider]; + 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, diff --git a/apps/app/.ladle/story-settings-chrome.tsx b/apps/app/.ladle/story-settings-chrome.tsx index 18b7b4bd2f..9b8a372651 100644 --- a/apps/app/.ladle/story-settings-chrome.tsx +++ b/apps/app/.ladle/story-settings-chrome.tsx @@ -4,7 +4,6 @@ import { AppPageHeader } from "@/components/layout/AppPageHeader"; import { SettingsSidebarContent } from "@/components/settings/SettingsSidebar"; import { SETTINGS_NAV_SECTIONS, - SETTINGS_PROVIDER_ENTRIES, type SettingsSectionId, } from "@/components/settings/settings-nav"; import { @@ -16,13 +15,11 @@ import { PageShell } from "@/components/ui/page-shell"; import { SETTINGS_ROUTE_PATH, SETTINGS_MACHINE_ROUTE_PATH, - getSettingsProviderRoutePath, getSettingsRoutePath, } from "@/lib/route-paths"; export type SettingsStoryRoute = | { kind: "machine"; id: string } - | { kind: "provider"; id: (typeof SETTINGS_PROVIDER_ENTRIES)[number]["id"] } | { kind: "section"; id: SettingsSectionId }; /** Resolve the story's real Settings links without depending on live app data. */ @@ -32,13 +29,6 @@ export function useSettingsStoryRoute(): SettingsStoryRoute { if (machineMatch?.params.hostId !== undefined) { return { kind: "machine", id: machineMatch.params.hostId }; } - const provider = SETTINGS_PROVIDER_ENTRIES.find( - (entry) => getSettingsProviderRoutePath(entry.id) === pathname, - ); - if (provider !== undefined) { - return { kind: "provider", id: provider.id }; - } - const section = SETTINGS_NAV_SECTIONS.find((entry) => entry.id === "general" ? pathname === SETTINGS_ROUTE_PATH @@ -60,14 +50,7 @@ export function SettingsStoryChrome({ }) { const route = useSettingsStoryRoute(); const resolvedActiveSection = - activeSection ?? - (route.kind === "section" - ? route.id - : route.kind === "machine" - ? "machines" - : null); - const activeProviderId = - activeSection === undefined && route.kind === "provider" ? route.id : null; + activeSection ?? (route.kind === "section" ? route.id : "machines"); return ( {}} diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index 74cf6608bf..b667739ad9 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": 429072, "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 35ffbbf93f..2c671181d5 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -13,7 +13,7 @@ "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" }, @@ -84,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", @@ -111,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", @@ -130,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..9c84d51663 --- /dev/null +++ b/apps/app/src/components/code/DiffHost.test.tsx @@ -0,0 +1,442 @@ +// @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 { resetDeprecatedAliasWarningsForTests } from "@/lib/plugin-sdk-deprecated-aliases"; +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(); + resetDeprecatedAliasWarningsForTests(); + 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, 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); + }); +}); + +/** + * A bundle built against an SDK before 0.4.16 reads `experimental_Original` + * (renamed `Original` in 0.4.16). The host passes both for one release. + */ +describe("DiffHost experimental_Original alias", () => { + it("delegates to BB's renderer through the alias and warns once across renders", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let renders = 0; + registerDiffRenderer(({ experimental_Original: LegacyOriginal }) => { + renders += 1; + return LegacyOriginal === undefined ? ( +
alias missing
+ ) : ( + + ); + }); + + const { rerender } = render( + , + ); + expect(await screen.findByTestId("bb-diff")).toBeDefined(); + expect(bbDiff.lastProps?.view).toBe("unified"); + + rerender( + , + ); + expect(await screen.findByText("bb diff split/scroll")).toBeDefined(); + expect(renders).toBe(2); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "experimental_Original is deprecated; use Original. Removed in bb 0.42", + ); + }); + + it("never warns for a renderer that reads Original", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + registerDiffRenderer(({ Original }) => ); + + render( + , + ); + + expect(await screen.findByTestId("bb-diff")).toBeDefined(); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/code/DiffHost.tsx b/apps/app/src/components/code/DiffHost.tsx new file mode 100644 index 0000000000..7f0a17cf33 --- /dev/null +++ b/apps/app/src/components/code/DiffHost.tsx @@ -0,0 +1,111 @@ +import { Suspense, lazy, useMemo, type ReactNode } from "react"; +import type { ExperimentalDiffFullFileContents } from "@get-bb/plugin-sdk"; +import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot"; +import { deprecatedOriginalAlias } from "@/lib/plugin-sdk-deprecated-aliases"; +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 `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..85ae8f6283 --- /dev/null +++ b/apps/app/src/components/code/SourceCodeHost.test.tsx @@ -0,0 +1,217 @@ +// @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 { resetDeprecatedAliasWarningsForTests } from "@/lib/plugin-sdk-deprecated-aliases"; +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(); + resetDeprecatedAliasWarningsForTests(); +}); + +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, 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); + }); +}); + +/** + * A bundle built against an SDK before 0.4.16 reads `experimental_Original` + * (renamed `Original` in 0.4.16). The host passes both for one release. + */ +describe("SourceCodeHost experimental_Original alias", () => { + it("delegates to BB's renderer through the alias and warns once across renders", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let renders = 0; + registerSourceCodeRenderer(({ experimental_Original: LegacyOriginal }) => { + renders += 1; + return LegacyOriginal === undefined ? ( +
alias missing
+ ) : ( + + ); + }); + + const { rerender } = render( + , + ); + expect(await screen.findByTestId("bb-source-code")).toBeDefined(); + expect(bbSourceCode.lastProps?.overflow).toBe("scroll"); + + rerender( + , + ); + await act(async () => { + await Promise.resolve(); + }); + expect(bbSourceCode.lastProps?.overflow).toBe("wrap"); + expect(renders).toBe(2); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "experimental_Original is deprecated; use Original. Removed in bb 0.42", + ); + }); + + it("never warns for a renderer that reads Original", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + registerSourceCodeRenderer(({ Original }) => ); + + render(); + + expect(await screen.findByTestId("bb-source-code")).toBeDefined(); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/code/SourceCodeHost.tsx b/apps/app/src/components/code/SourceCodeHost.tsx new file mode 100644 index 0000000000..82a846a508 --- /dev/null +++ b/apps/app/src/components/code/SourceCodeHost.tsx @@ -0,0 +1,82 @@ +import { Suspense, lazy, type ReactNode } from "react"; +import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot"; +import { deprecatedOriginalAlias } from "@/lib/plugin-sdk-deprecated-aliases"; +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 616f7ff523..b8d7c67323 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx @@ -77,6 +77,8 @@ function connectPlugin( app: { hasApp: false, bundle: null }, logoUrl: null, logoDarkUrl: null, + providerIds: [], + icons: {}, ...overrides, }; } 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..25944f0c55 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,11 +62,17 @@ 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 " }, ]; +function ProviderMaskIcon({ className }: { className?: string }) { + return ; +} + const codexModels: readonly PickerOption[] = [ { value: "gpt-5.5", label: "GPT-5.5" }, ]; @@ -164,7 +171,7 @@ function renderPicker({ pickerReasoningOptions?: readonly PickerOption[]; reasoningValue?: ReasoningLevel; moreModelOptions?: readonly ModelPickerOption[]; - pickerProviderOptions?: readonly PickerOption[]; + pickerProviderOptions?: readonly ProviderPickerOption[]; alternateProviderModels?: AvailableModel[]; providerRouting?: SystemProvidersQuery; selectedProviderId?: string; @@ -244,6 +251,19 @@ afterEach(() => { }); describe("ModelReasoningPicker", () => { + it("gives a non-SVG provider mark the same 16px trigger size as button SVGs", () => { + renderPicker({ + pickerProviderOptions: [ + { ...providerOptions[0], icon: ProviderMaskIcon }, + providerOptions[1], + ], + }); + + expect(screen.getByTestId("provider-mask-icon").classList).toContain( + "size-4", + ); + }); + it("keeps a failed provider tab visible with its provider-plugin error", () => { renderPicker({ modelOptions: [], @@ -475,6 +495,54 @@ describe("ModelReasoningPicker", () => { ).toBe(""); }); + // A short viewport cuts the menu off below the model rows. The models and the + // reasoning rows must share one scroll region: when the model list is its own + // scroller, a wheel or touch gesture that starts over the models is captured + // by it, and the reasoning rows underneath stay unreachable. + it("scrolls the desktop models and reasoning rows as one region", () => { + 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)]", + ); + + const scrollers = [ + ...(menu.className.includes("overflow-y-auto") ? [menu] : []), + ...menu.querySelectorAll("[class*='overflow-y-auto']"), + ]; + expect(scrollers).toHaveLength(1); + + const body = scrollers[0]; + expect(body.className).toContain("overscroll-contain"); + expect(body.contains(screen.getByRole("listbox", { name: "Models" }))).toBe( + true, + ); + expect(body.contains(screen.getByText("High"))).toBe(true); + + const models = screen.getByRole("listbox", { name: "Models" }); + expect(models.className).not.toContain("max-h-"); + }); + + 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("max-h-"); + }); + 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..c6d8cf0862 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -13,9 +13,12 @@ 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 { fastServiceTierLabel } from "@/lib/reasoning-labels"; 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 +44,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 +52,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 +84,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 +156,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 +169,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 +216,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,14 +250,24 @@ 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; + /** + * The committed provider's declared label for its fast tier (the toggle + * reads `