diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f48bb0377ad9..306038007e9a 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -36,8 +36,9 @@ vp run mobile:install This installs the dev client for the checkout's native fingerprint: from the device, the local cache, or the shared cache in seconds, building once only -when no machine has that fingerprint yet (5-15 minutes, logged to the file it -names). The device id may be omitted when exactly one device is booted. It +when no machine has that fingerprint yet: in the `Mobile Dev Client` workflow +on Blacksmith when HEAD is pushed and `gh` may dispatch it (the command waits), +otherwise here (5-15 minutes, logged to the file it names). The device id may be omitted when exactly one device is booted. It prints one JSON line; on failure it names the next command. Mobile verification includes that step unless the user prohibits it. diff --git a/.github/scripts/mobile-preview-comment.cjs b/.github/scripts/mobile-preview-comment.cjs new file mode 100644 index 000000000000..e4876b0842ea --- /dev/null +++ b/.github/scripts/mobile-preview-comment.cjs @@ -0,0 +1,126 @@ +// The Android half of the mobile preview comment. The android job writes the fingerprint and +// the update; the android-build job adds the client's install link a few minutes later. Each +// run merges what it knows into the state kept in the comment's first line. +const MARKER = "mobile-preview-android"; +const PROJECT_ID = "d763fcb8-d37c-41ea-a773-b54a0ab4a454"; +const APP_SCHEME = "t3-code"; + +function readState(body) { + const match = new RegExp(`^`).exec(body ?? ""); + if (!match) return null; + try { + const state = JSON.parse(match[1]); + const text = (value) => (typeof value === "string" ? value : ""); + return { + fingerprint: text(state.fingerprint), + client: text(state.client), + group: text(state.group), + }; + } catch { + return null; + } +} + +/** + * New values win; a new fingerprint drops the old fingerprint's client. A build that finishes + * after the PR moved to another fingerprint changes nothing: its client cannot run the update + * the comment now shows. + */ +function mergeState(previous, next) { + const buildOnly = Boolean(next.client) && !next.group; + if ( + buildOnly && + previous?.fingerprint && + next.fingerprint && + previous.fingerprint !== next.fingerprint + ) { + return previous; + } + const fingerprint = next.fingerprint || previous?.fingerprint || ""; + const sameFingerprint = !previous?.fingerprint || previous.fingerprint === fingerprint; + return { + fingerprint, + client: next.client || (sameFingerprint ? (previous?.client ?? "") : ""), + group: next.group || previous?.group || "", + }; +} + +function render(state, runUrl) { + const qr = `https://qr.expo.dev/eas-update?appScheme=${APP_SCHEME}&projectId=${PROJECT_ID}&groupId=${state.group}`; + const rows = [ + ``, + "๐Ÿค– Android preview", + "", + "  | ๐Ÿค– Android", + "--- | ---", + `Fingerprint | ${state.fingerprint || "n/a"}`, + `Dev client | ${state.client ? `[Install](${state.client})` : `Building on Blacksmith ([run](${runUrl})); this comment gets the install link when it finishes`}`, + ]; + if (state.group) { + rows.push( + `Update Details | [Update Permalink](https://expo.dev/projects/${PROJECT_ID}/updates/${state.group})`, + `Update QR | `, + ); + } + return rows.join("\n"); +} + +async function github(path, init = {}) { + const response = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + authorization: `Bearer ${process.env.GH_TOKEN}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + ...init.headers, + }, + }); + if (!response.ok) { + throw new Error( + `GitHub ${init.method ?? "GET"} ${path}: ${response.status} ${await response.text()}`, + ); + } + return response.json(); +} + +async function main() { + const repository = process.env.GITHUB_REPOSITORY; + const pr = process.env.PR; + if (!repository || !pr || !process.env.GH_TOKEN) { + throw new Error("GITHUB_REPOSITORY, PR and GH_TOKEN are required."); + } + const runUrl = `${process.env.GITHUB_SERVER_URL}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}`; + let existing = null; + for (let page = 1; existing === null; page++) { + const comments = await github( + `/repos/${repository}/issues/${pr}/comments?per_page=100&page=${page}`, + ); + // Only this workflow's own comment: anyone can post the marker, and nobody else's is editable. + existing = + comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && readState(comment.body) !== null, + ) ?? null; + if (comments.length < 100) break; + } + const state = mergeState(readState(existing?.body), { + fingerprint: process.env.FINGERPRINT ?? "", + client: process.env.CLIENT ?? "", + group: process.env.GROUP ?? "", + }); + const body = JSON.stringify({ body: render(state, runUrl) }); + if (existing) { + await github(`/repos/${repository}/issues/comments/${existing.id}`, { method: "PATCH", body }); + } else { + await github(`/repos/${repository}/issues/${pr}/comments`, { method: "POST", body }); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { mergeState, readState, render }; diff --git a/.github/scripts/mobile-preview-comment.test.cjs b/.github/scripts/mobile-preview-comment.test.cjs new file mode 100644 index 000000000000..99bfc45d4d2b --- /dev/null +++ b/.github/scripts/mobile-preview-comment.test.cjs @@ -0,0 +1,62 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { mergeState, readState, render } = require("./mobile-preview-comment.cjs"); + +const fingerprint = "c31608b37a8a2dca35ccee39cfd2ec7305042629"; +const run = "https://github.com/pingdotgg/t3code/actions/runs/1"; + +test("the comment round-trips its state", () => { + const state = { fingerprint, client: "", group: "8e413a0a" }; + const body = render(state, run); + assert.deepEqual(readState(body), state); + assert.match( + body, + /Building on Blacksmith \(\[run\]\(https:\/\/github\.com\/pingdotgg\/t3code\/actions\/runs\/1\)\)/, + ); + assert.match( + body, + /qr\.expo\.dev\/eas-update\?appScheme=t3-code&projectId=[0-9a-f-]+&groupId=8e413a0a/, + ); + assert.equal(readState("๐Ÿš€ Expo continuous deployment is ready!"), null); +}); + +test("the build job adds the install link without losing the update", () => { + const first = mergeState(null, { fingerprint, client: "", group: "8e413a0a" }); + const built = mergeState(first, { fingerprint, client: "https://expo.dev/a/b", group: "" }); + assert.deepEqual(built, { fingerprint, client: "https://expo.dev/a/b", group: "8e413a0a" }); + assert.match(render(built, run), /Dev client \| \[Install\]\(https:\/\/expo\.dev\/a\/b\)/); +}); + +test("a new fingerprint drops the old client; the same one keeps it", () => { + const built = { fingerprint, client: "https://expo.dev/a/b", group: "old" }; + assert.deepEqual(mergeState(built, { fingerprint, client: "", group: "new" }), { + fingerprint, + client: "https://expo.dev/a/b", + group: "new", + }); + assert.deepEqual(mergeState(built, { fingerprint: "f".repeat(40), client: "", group: "new" }), { + fingerprint: "f".repeat(40), + client: "", + group: "new", + }); +}); + +test("a build that finishes after the PR moved to another fingerprint changes nothing", () => { + const current = { fingerprint: "f".repeat(40), client: "", group: "new" }; + assert.deepEqual( + mergeState(current, { fingerprint, client: "https://expo.dev/a/b", group: "" }), + current, + ); +}); + +test("state from the comment is always three strings", () => { + assert.deepEqual( + readState('\nx'), + { + fingerprint: "", + client: "", + group: "", + }, + ); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index beb742eeac12..eef33e1774d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -314,7 +314,7 @@ jobs: run: python3 -B .github/scripts/stage-preview-bundle.test.py - name: Test release checks - run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs + run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs .github/scripts/mobile-preview-comment.test.cjs - name: Finish installing browser secret helper build libraries timeout-minutes: 5 diff --git a/.github/workflows/mobile-dev-client.yml b/.github/workflows/mobile-dev-client.yml new file mode 100644 index 000000000000..d9e8f114acb6 --- /dev/null +++ b/.github/workflows/mobile-dev-client.yml @@ -0,0 +1,308 @@ +name: Mobile Dev Client +run-name: ${{ github.event_name == 'workflow_dispatch' && format('Dev client {0}{1} {2}', inputs.platform, inputs.platform == 'android' && format(' {0}', inputs.abi) || '', inputs.fingerprint) || '' }} + +# Builds the simulator and emulator development client for a commit's native +# fingerprint and shares it through the native-client cache, the one +# `vp run mobile:install` reads on every machine. Android builds with Gradle, +# ccache, and pnpm state on a sticky disk; iOS builds on a macOS runner, so no +# machine needs Xcode or an EAS build for it. +# +# - push to main: main's client is cached before anyone asks for it. +# - workflow_dispatch: `mobile:install` starts this for the checkout's HEAD on a +# cache miss, when the person running it may dispatch workflows here, and +# waits for the client in the cache. The run name is how it finds its run. +# - pull_request (this file or the script): builds without sharing, as a check. +on: + push: + branches: [main] + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - patches/** + - scripts/mobile-native-client.ts + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-dev-client.yml + pull_request: + paths: + - .github/workflows/mobile-dev-client.yml + - scripts/mobile-native-client.ts + workflow_dispatch: + inputs: + ref: + description: Commit to build (default main) + required: false + type: string + platform: + description: Platform + required: true + default: android + type: choice + options: [android, ios] + abi: + description: Android ABI of the device + required: true + default: x86_64 + type: choice + options: [x86_64, arm64-v8a] + fingerprint: + description: Native fingerprint the caller expects; the run fails when the commit's differs + required: false + type: string + +permissions: + contents: read + +jobs: + android: + name: Android ${{ matrix.abi }} + # disabled: Android and iOS builds are not used by this fork + if: false + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + # Pool emulators are x86_64; a dispatch builds the ABI it asks for. + abi: ${{ github.event_name == 'workflow_dispatch' && fromJSON(format('["{0}"]', inputs.abi)) || fromJSON('["x86_64"]') }} + concurrency: + # One run per client: a second request for the same fingerprint waits and finds it + # in the cache. A shared group would drop every waiting run but the newest. + group: mobile-dev-client-android-${{ matrix.abi }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: + NODE_OPTIONS: --max-old-space-size=8192 + ABI: ${{ matrix.abi }} + EXPECTED_FINGERPRINT: ${{ inputs.fingerprint }} + # A pull request checks that the build works: it builds even on a cache hit and shares nothing. + T3_NATIVE_CLIENT_CACHE: ${{ github.event_name == 'pull_request' && 'off' || '' }} + # Gradle and Xcode output goes to the job log, not a file on the runner. + T3_NATIVE_CLIENT_VERBOSE: "1" + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ inputs.ref || github.sha }} + + # A dispatch names any commit GitHub has, including a fork's. Only a commit that + # collaborators pushed here builds with the cache login and the shared disk. + - name: Check the commit belongs to this repository + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + if git ls-remote --heads origin | grep -q "^$sha"; then + exit 0 + fi + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$sha...main" --jq .status || true)" + if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then + exit 0 + fi + echo "::error::$sha is not the head of a branch in $GITHUB_REPOSITORY and not on main; build it locally." + exit 1 + + # The build runs dependency scripts; it gets no token. checkout keeps the token in a + # file under RUNNER_TEMP that .git/config includes, so both go. `persist-credentials: + # false` fails here: its cleanup runs `git submodule foreach`, which stops at the + # vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + git config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git config --local --unset-all "$key" + done + git config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git config --local --unset-all "$key" + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if git config --local --get-regexp 'extraheader|includeif' || ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::checkout credentials are still present" + exit 1 + fi + + - name: Prepare build directories + run: | + cache="$HOME/.cache/t3-android-dev-client" + { + echo "GRADLE_USER_HOME=$cache/gradle" + echo "CCACHE_DIR=$cache/ccache" + echo "CCACHE_MAXSIZE=5G" + # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime. + echo "CCACHE_COMPILERCHECK=%compiler% -v" + # The checkout path is the same on every run, so ccache keys stay stable. + echo "CCACHE_BASEDIR=$GITHUB_WORKSPACE" + # CMake (3.17+) reads these, and AGP passes Gradle's environment through. + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" + } >> "$GITHUB_ENV" + + - name: Configure Gradle and ccache + run: | + mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR" + # No daemon: nothing should hold the sticky disk open when it is committed. + printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties" + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update -qq && sudo apt-get install -y -qq ccache + ccache --zero-stats + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} + install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts... + + - name: Setup Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: 17 + + # Only this step's login can write to the cache; pull requests never get it. + # Only this login can write to the cache; pull requests never get it. + - id: login + name: Log in to the native client cache + if: github.event_name != 'pull_request' + env: + WRITE_TOKEN: ${{ secrets.T3_NATIVE_CLIENT_WRITE_TOKEN }} + run: | + if [ -z "$WRITE_TOKEN" ]; then + echo "::warning::T3_NATIVE_CLIENT_WRITE_TOKEN is not set; nothing can be shared, so nothing is built." + exit 0 + fi + node --input-type=module <<'NODE' + import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; + import { homedir } from "node:os"; + import { join } from "node:path"; + const { endpoint } = JSON.parse(readFileSync(".task-cache.json", "utf8")); + const directory = join(homedir(), ".config/t3-task-cache"); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync( + join(directory, "credentials.json"), + JSON.stringify({ [endpoint]: { name: "ci-native-clients", token: process.env.WRITE_TOKEN } }), + { mode: 0o600 }, + ); + NODE + echo "T3_NATIVE_CLIENT_SHARE=required" >> "$GITHUB_ENV" + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Build and share + if: github.event_name == 'pull_request' || steps.login.outputs.ready == 'true' + shell: bash # pipefail: a failed build fails the step through tee + run: | + node scripts/mobile-native-client.ts ensure android --prepare --abi "$ABI" ${EXPECTED_FINGERPRINT:+--expect-fingerprint "$EXPECTED_FINGERPRINT"} | tee "$RUNNER_TEMP/dev-client.log" + result="$(tail -n 1 "$RUNNER_TEMP/dev-client.log")" + echo "### android dev client: \`$(jq -r '.source' <<<"$result")\` for fingerprint \`$(jq -r '.fingerprint' <<<"$result")\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Cache statistics + if: success() || failure() + continue-on-error: true + run: | + { + echo '```' + ccache --show-stats + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + ios: + name: iOS simulator + # disabled: Android and iOS builds are not used by this fork + if: false + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 60 + concurrency: + group: mobile-dev-client-ios-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: + NODE_OPTIONS: --max-old-space-size=8192 + EXPECTED_FINGERPRINT: ${{ inputs.fingerprint }} + T3_NATIVE_CLIENT_CACHE: ${{ github.event_name == 'pull_request' && 'off' || '' }} + # Gradle and Xcode output goes to the job log, not a file on the runner. + T3_NATIVE_CLIENT_VERBOSE: "1" + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ inputs.ref || github.sha }} + + # A dispatch names any commit GitHub has, including a fork's. Only a commit that + # collaborators pushed here builds with the cache login and the shared disk. + - name: Check the commit belongs to this repository + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + if git ls-remote --heads origin | grep -q "^$sha"; then + exit 0 + fi + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$sha...main" --jq .status || true)" + if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then + exit 0 + fi + echo "::error::$sha is not the head of a branch in $GITHUB_REPOSITORY and not on main; build it locally." + exit 1 + + # The build runs dependency scripts; it gets no token. checkout keeps the token in a + # file under RUNNER_TEMP that .git/config includes, so both go. `persist-credentials: + # false` fails here: its cleanup runs `git submodule foreach`, which stops at the + # vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + git config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git config --local --unset-all "$key" + done + git config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git config --local --unset-all "$key" + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if git config --local --get-regexp 'extraheader|includeif' || ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::checkout credentials are still present" + exit 1 + fi + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} + install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts... + + # Only this login can write to the cache; pull requests never get it. + - id: login + name: Log in to the native client cache + if: github.event_name != 'pull_request' + env: + WRITE_TOKEN: ${{ secrets.T3_NATIVE_CLIENT_WRITE_TOKEN }} + run: | + if [ -z "$WRITE_TOKEN" ]; then + echo "::warning::T3_NATIVE_CLIENT_WRITE_TOKEN is not set; nothing can be shared, so nothing is built." + exit 0 + fi + node --input-type=module <<'NODE' + import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; + import { homedir } from "node:os"; + import { join } from "node:path"; + const { endpoint } = JSON.parse(readFileSync(".task-cache.json", "utf8")); + const directory = join(homedir(), ".config/t3-task-cache"); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync( + join(directory, "credentials.json"), + JSON.stringify({ [endpoint]: { name: "ci-native-clients", token: process.env.WRITE_TOKEN } }), + { mode: 0o600 }, + ); + NODE + echo "T3_NATIVE_CLIENT_SHARE=required" >> "$GITHUB_ENV" + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Build and share + if: github.event_name == 'pull_request' || steps.login.outputs.ready == 'true' + shell: bash # pipefail: a failed build fails the step through tee + run: | + node scripts/mobile-native-client.ts ensure ios --prepare ${EXPECTED_FINGERPRINT:+--expect-fingerprint "$EXPECTED_FINGERPRINT"} | tee "$RUNNER_TEMP/dev-client.log" + result="$(tail -n 1 "$RUNNER_TEMP/dev-client.log")" + echo "### ios dev client: \`$(jq -r '.source' <<<"$result")\` for fingerprint \`$(jq -r '.fingerprint' <<<"$result")\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml index fa7c36cd0d5b..1f4ff2ceb476 100644 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -1,8 +1,8 @@ name: Mobile EAS Preview (Fork) # EAS previews for fork PRs. This runs the PR's code with an Expo token: the -# app config and plugins run on this runner, and native builds run on EAS -# with preview signing credentials. The label is the maintainer's sign-off +# app config and plugins run on this runner, the iOS client builds on EAS and +# the Android one on Blacksmith (android-build), with preview signing credentials. The label is the maintainer's sign-off # that they reviewed that code, so it covers exactly the commit it was applied # to: this pins that commit and consumes the label before running anything. A # later push deploys nothing until the label is applied again. @@ -29,6 +29,9 @@ jobs: permissions: contents: read pull-requests: write + outputs: + build: ${{ steps.client.outputs.build }} + fingerprint: ${{ steps.client.outputs.fingerprint }} env: APP_VARIANT: preview NODE_OPTIONS: --max-old-space-size=8192 @@ -76,9 +79,43 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - persist-credentials: false # No sparse-checkout: see mobile-eas-preview.yml. + # The comment script comes from main, not from the PR. + - name: Checkout trusted scripts + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + path: .trusted + sparse-checkout: .github/scripts + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + if: steps.expo-token.outputs.present == 'true' + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + # No caches: pull_request_target saves them under main's scope, where # trusted workflows with other secrets would restore what PR code wrote. - name: Setup Vite+ @@ -139,7 +176,180 @@ jobs: with: profile: preview:dev branch: pr-${{ github.event.pull_request.number }} - platform: all + # Android is below: its client builds on Blacksmith (android-build). + platform: ios environment: preview working-directory: apps/mobile github-token: ${{ secrets.GITHUB_TOKEN }} + + # Android, as in mobile-eas-preview.yml: the same fingerprint command the Expo + # action runs. A client built for a same-repo PR (its tag) or by EAS is reused. + - id: client + name: Find the Android client for this fingerprint + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + hash="$(eas env:exec --non-interactive preview "npx expo-updates fingerprint:generate --platform android" | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + echo "fingerprint=$hash" >> "$GITHUB_OUTPUT" + tag="mobile-android-preview-build/$hash" + link="" + if git fetch --quiet origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null; then + link="$(git tag -l --format='%(contents:subject)' "$tag")" + else + link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ + | sed -n '/^\[/,$p' \ + | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi + + - id: update + name: Publish the Android update + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + group="$(eas update --auto --branch "pr-$PR" --platform android --environment preview --non-interactive --json | sed -n '/^\[/,$p' | jq -er '.[0].group')" + echo "group=$group" >> "$GITHUB_OUTPUT" + + - name: Comment + if: steps.expo-token.outputs.present == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + FINGERPRINT: ${{ steps.client.outputs.fingerprint }} + CLIENT: ${{ steps.client.outputs.link }} + GROUP: ${{ steps.update.outputs.group }} + run: node .trusted/.github/scripts/mobile-preview-comment.cjs + + # The reviewed commit's Android client, built cold: no sticky disk, because PR + # code could leave Gradle or ccache state that trusted builds would restore, and + # no build record tag, so nobody else is served an APK a fork's code built. + android-build: + name: Android preview client + needs: preview + if: needs.preview.outputs.build == 'true' + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + permissions: + contents: read + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + FINGERPRINT: ${{ needs.preview.outputs.fingerprint }} + # Phones only: emulators use the development client from the native-client cache. + ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a + steps: + - name: Checkout reviewed commit + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # The comment script comes from main, not from the PR. + - name: Checkout trusted scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + path: .trusted + sparse-checkout: .github/scripts + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: package.json + + - name: Enable corepack + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + - name: Install mobile dependencies + run: pnpm install --frozen-lockfile --filter=@t3tools/mobile... + + - name: Setup Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: 17 + + - name: Setup EAS + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + eas-cache: false + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + eas env:pull preview --non-interactive + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE + + - id: build + name: Build and upload + working-directory: apps/mobile + shell: bash + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + eas build --local --platform android --profile preview:dev --non-interactive --output "$RUNNER_TEMP/t3code-preview.apk" + eas upload --platform android --build-path "$RUNNER_TEMP/t3code-preview.apk" --fingerprint "$FINGERPRINT" --non-interactive --json | tee "$RUNNER_TEMP/upload.json" + link="$(sed -n '/^{/,$p' "$RUNNER_TEMP/upload.json" | jq -r '.url // empty')" + if [ -z "$link" ]; then + echo "::error::eas upload returned no install link" + exit 1 + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + + - name: Comment + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + CLIENT: ${{ steps.build.outputs.link }} + run: node .trusted/.github/scripts/mobile-preview-comment.cjs diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index d752166045fd..d8f18d1ea020 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -6,7 +6,7 @@ on: jobs: preview: - name: EAS Preview + name: EAS Preview (iOS) # Fork PRs deploy one reviewed commit per label through mobile-eas-preview-fork.yml. if: | github.event.pull_request.head.repo.full_name == github.repository && @@ -107,7 +107,343 @@ jobs: with: profile: preview:dev branch: pr-${{ github.event.pull_request.number }} - platform: all + # Android is the android job below: its client builds on Blacksmith. + platform: ios environment: preview working-directory: apps/mobile github-token: ${{ secrets.GITHUB_TOKEN }} + + # Android publishes its own update and comment, because the Expo action can only + # find a client that EAS built. A fingerprint with no client is built by + # android-build with `eas build --local` on Blacksmith (about 4 minutes warm, + # against about 40 on EAS) and recorded as a mobile-android-preview-build/ + # tag whose message is the install link. Clients EAS built earlier still count. + android: + name: EAS Preview (Android) + if: | + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, '๐Ÿš€ Mobile Continuous Deployment') && + (github.event.action != 'labeled' || github.event.label.name == '๐Ÿš€ Mobile Continuous Deployment') + runs-on: blacksmith-8vcpu-ubuntu-2404 + concurrency: + group: mobile-eas-preview-android-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + outputs: + build: ${{ steps.client.outputs.build }} + fingerprint: ${{ steps.client.outputs.fingerprint }} + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + steps: + - id: expo-token + name: Check for EXPO_PREVIEW_TOKEN + run: | + if [ -n "$EXPO_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "EXPO_PREVIEW_TOKEN is not available; skipping the Android preview." + fi + + - name: Checkout + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + if: steps.expo-token.outputs.present == 'true' + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + + - name: Setup Vite+ + if: steps.expo-token.outputs.present == 'true' + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/mobile... + + - name: Expose pnpm + if: steps.expo-token.outputs.present == 'true' + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup EAS + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + run: | + eas env:pull preview --non-interactive + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE + + # The same command the Expo action runs, so this is the runtime version the update gets. + - id: client + name: Find the client for this fingerprint + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + run: | + hash="$(eas env:exec --non-interactive preview "npx expo-updates fingerprint:generate --platform android" | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + echo "fingerprint=$hash" >> "$GITHUB_OUTPUT" + tag="mobile-android-preview-build/$hash" + link="" + if git fetch --quiet origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null; then + link="$(git tag -l --format='%(contents:subject)' "$tag")" + else + # A client EAS built before this job existed; expired ones are gone. + link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ + | sed -n '/^\[/,$p' \ + | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi + echo "android fingerprint \`$hash\`: ${link:-no client yet, building on Blacksmith}" >> "$GITHUB_STEP_SUMMARY" + + - id: update + name: Publish the update + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + run: | + group="$(eas update --auto --branch "pr-$PR" --platform android --environment preview --non-interactive --json | sed -n '/^\[/,$p' | jq -er '.[0].group')" + echo "group=$group" >> "$GITHUB_OUTPUT" + + - name: Comment + if: steps.expo-token.outputs.present == 'true' + env: + FINGERPRINT: ${{ steps.client.outputs.fingerprint }} + CLIENT: ${{ steps.client.outputs.link }} + GROUP: ${{ steps.update.outputs.group }} + run: node .github/scripts/mobile-preview-comment.cjs + + android-build: + name: Android preview client + needs: android + # disabled: Android and iOS builds are not used by this fork + if: false + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + concurrency: + # One build per fingerprint; a second PR with the same one finds the tag. + group: mobile-android-preview-build-${{ needs.android.outputs.fingerprint }} + cancel-in-progress: false + permissions: + contents: write # pushes the build record tag + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + FINGERPRINT: ${{ needs.android.outputs.fingerprint }} + BUILD_TAG: mobile-android-preview-build/${{ needs.android.outputs.fingerprint }} + # Phones only: emulators use the development client from the native-client cache. + ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + + - id: record + name: Check build record + run: | + if git fetch --quiet origin "refs/tags/$BUILD_TAG:refs/tags/$BUILD_TAG" 2>/dev/null; then + echo "built=true" >> "$GITHUB_OUTPUT" + echo "link=$(git tag -l --format='%(contents:subject)' "$BUILD_TAG")" >> "$GITHUB_OUTPUT" + fi + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + + - name: Prepare build directories + if: steps.record.outputs.built != 'true' + run: | + cache="$HOME/.cache/t3-android-preview" + { + echo "GRADLE_USER_HOME=$cache/gradle" + echo "CCACHE_DIR=$cache/ccache" + echo "CCACHE_MAXSIZE=5G" + # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime. + echo "CCACHE_COMPILERCHECK=%compiler% -v" + echo "pnpm_config_store_dir=$cache/pnpm-store" + # eas build --local copies the project here. A fixed path keeps + # ccache keys stable, and the same disk as the store lets pnpm hardlink. + echo "EAS_LOCAL_BUILD_WORKINGDIR=$cache/eas-build" + echo "CCACHE_BASEDIR=$cache/eas-build" + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" + } >> "$GITHUB_ENV" + + - name: Configure Gradle and ccache + if: steps.record.outputs.built != 'true' + run: | + mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR" + rm -rf "$EAS_LOCAL_BUILD_WORKINGDIR" + # No daemon: nothing should hold the sticky disk open when it is committed. + printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties" + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update -qq && sudo apt-get install -y -qq ccache + ccache --zero-stats + + - name: Setup Node + if: steps.record.outputs.built != 'true' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: package.json + + - name: Enable corepack + if: steps.record.outputs.built != 'true' + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + - name: Install mobile dependencies + if: steps.record.outputs.built != 'true' + run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile... + + - name: Setup Java + if: steps.record.outputs.built != 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: 17 + + - name: Setup EAS + if: steps.record.outputs.built != 'true' + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: | + eas env:pull preview --non-interactive + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE + + # `eas upload` hosts the APK on EAS and returns the page testers install from. + - id: build + name: Build and upload + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + shell: bash + run: | + eas build --local --platform android --profile preview:dev --non-interactive --output "$RUNNER_TEMP/t3code-preview.apk" + eas upload --platform android --build-path "$RUNNER_TEMP/t3code-preview.apk" --fingerprint "$FINGERPRINT" --non-interactive --json | tee "$RUNNER_TEMP/upload.json" + link="$(sed -n '/^{/,$p' "$RUNNER_TEMP/upload.json" | jq -r '.url // empty')" + if [ -z "$link" ]; then + echo "::error::eas upload returned no install link" + exit 1 + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + { + echo "### Android preview client for \`$FINGERPRINT\`: $link" + echo '```' + ccache --show-stats + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # The tag is the record later runs look up; its message is the install link. + - name: Record build + if: steps.record.outputs.built != 'true' + env: + LINK: ${{ steps.build.outputs.link }} + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$BUILD_TAG" -m "$LINK" + # Only this step has the token; the checkout's copy was removed before the build. + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$BUILD_TAG" + + - name: Comment + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + CLIENT: ${{ steps.build.outputs.link || steps.record.outputs.link }} + run: node .github/scripts/mobile-preview-comment.cjs diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 95599b66a205..f8c28ebac493 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -485,7 +485,8 @@ jobs: name: Android store build needs: production # Skipped when the production job failed: no store build after a failed OTA. - if: needs.production.outputs.android_build == 'true' + # disabled: Android and iOS builds are not used by this fork + if: false runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 60 concurrency: @@ -538,13 +539,6 @@ jobs: echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" } >> "$GITHUB_ENV" - - name: Mount build cache - if: steps.record.outputs.built != 'true' - uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1 - with: - key: ${{ github.repository }}-android-store-build - path: ~/.cache/t3-android-store - - name: Configure Gradle and ccache if: steps.record.outputs.built != 'true' run: | @@ -666,7 +660,8 @@ jobs: name: iOS store build needs: production # Skipped when the production job failed: no store build after a failed OTA. - if: needs.production.outputs.ios_build == 'true' + # disabled: Android and iOS builds are not used by this fork + if: false runs-on: blacksmith-12vcpu-macos-27 timeout-minutes: 60 concurrency: diff --git a/apps/mobile/README.md b/apps/mobile/README.md index d508cea8ec2b..7b7cca056f72 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -35,7 +35,11 @@ The device argument can be omitted when exactly one device is booted or connecte compares this checkout's Expo fingerprint and the installed binary with its last install record, and reuses a matching client. Otherwise it installs the build for that fingerprint from `~/.cache/t3code/native-clients/artifacts`, then the shared task cache, and builds only when -neither has it. One iOS simulator build serves every iPhone and iPad simulator, and one APK per +neither has it. A build runs in the `Mobile Dev Client` workflow (Blacksmith runners with warm +Gradle and ccache state, and a macOS runner for iOS) when `gh` may dispatch workflows on +`pingdotgg/t3code` and HEAD is pushed; the helper waits for the client in the shared cache. +Otherwise, or when HEAD lacks your native edits or `T3_NATIVE_CLIENT_BUILD=local` is set, it builds +on this machine. Main's client is built there on every native change, so most checkouts hit. One iOS simulator build serves every iPhone and iPad simulator, and one APK per ABI (x86_64 or arm64-v8a) serves every emulator and phone of that ABI. Build output goes to the log file it prints (`T3_NATIVE_CLIENT_VERBOSE=1` streams it instead). Builds are shared back when your task-cache login (`bash infra/task-cache/install.sh`) can write. `T3_NATIVE_CLIENT_CACHE=read` diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index b090a24faeb3..cd018ea70a9f 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -7,7 +7,7 @@ import type { ModelOption } from "../lib/modelOptions"; import { resolveProviderOptionDescriptors } from "../lib/providerOptions"; import { AppText as Text } from "./ui/AppText"; import { ComposerInlineControl } from "./ComposerToolbar"; -import { ProviderIcon, ProviderInstanceIcon } from "./ProviderIcon"; +import { ProviderIcon, ProviderInstanceIcon, providerInstanceNameLabel } from "./ProviderIcon"; export function ComposerModelControl(props: { readonly modelOption: ModelOption | null; @@ -37,6 +37,15 @@ export function ComposerModelControl(props: { : ""; const badge = props.modelOption?.providerBadge; const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; + const providerNameLabel = badge + ? providerInstanceNameLabel({ + provider: props.modelOption?.providerDriver, + iconUrl: props.modelOption?.providerIconUrl, + displayName: badge.displayName, + showBadge: badge.showBadge, + }) + : null; + const shortLabel = props.modelOption?.shortName ?? label; return ( ) } - label={props.modelOption?.shortName ?? label} + label={providerNameLabel ?? shortLabel} + labelEllipsizeMode={providerNameLabel ? "middle" : undefined} iconOnly={props.iconOnly} + suffixWidth={providerNameLabel ? 96 : undefined} suffix={ - summary ? ( + providerNameLabel ? ( + + {shortLabel} + {summary ? ` ยท ${summary}` : ""} + + ) : summary ? ( ["name"]; readonly renderIcon?: (size: number) => ReactNode; readonly label: string; + readonly labelEllipsizeMode?: ComponentProps["ellipsizeMode"]; readonly suffix?: ReactNode; + readonly suffixWidth?: number; /** Show only the icon and chevron; the label still names the control for accessibility. */ readonly iconOnly?: boolean; readonly maxWidth?: ViewStyle["maxWidth"]; @@ -94,13 +96,19 @@ export function ComposerInlineControl(props: { props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted", )} numberOfLines={1} + ellipsizeMode={props.labelEllipsizeMode} > {props.label} )} {!props.iconOnly && props.suffix ? ( - {props.suffix} + + {props.suffix} + ) : null} {props.showChevron === false ? null : ( void; readonly embedded?: boolean; readonly hideBottomBorder?: boolean; - /** One row with the subtitle after the title, for screens short on height. */ - readonly compact?: boolean; }) { const { colors } = useControlTheme(); const titleTypography = useScaledTextRole("title"); @@ -77,7 +74,6 @@ export function AndroidScreenHeader(props: { /> ) : null} @@ -108,26 +104,6 @@ export function AndroidScreenHeader(props: { > {props.title} - ) : props.compact ? ( - - - {props.title} - - - {props.subtitle} - - ) : ( void; }; readonly hideBottomBorder?: boolean; - /** Android: one title row with the subtitle inline. iOS ignores it. */ - readonly compact?: boolean; readonly trailing?: ReactNode; readonly matchSearchSurface?: boolean; readonly optionsVersion?: unknown; diff --git a/apps/mobile/src/components/ui/NativeAppBar.android.tsx b/apps/mobile/src/components/ui/NativeAppBar.android.tsx index b56a97262dcf..25f7763caee8 100644 --- a/apps/mobile/src/components/ui/NativeAppBar.android.tsx +++ b/apps/mobile/src/components/ui/NativeAppBar.android.tsx @@ -12,11 +12,9 @@ export function NativeAppBar(props: { readonly actions?: ReactElement; /** Compose nodes laid out by the bar itself, so they never wait on a React Native measure. */ readonly nativeActions?: ReactElement; - /** Shorter bar for a one-row title. */ - readonly singleLine?: boolean; }) { const { colors } = useControlTheme(); - const layout = useToolbarLayout(false, props.singleLine); + const layout = useToolbarLayout(); return ( // The bar's height is fixed, so React Native lays out the screen below it from the first frame // instead of at zero height until Compose has measured and reported the bar. diff --git a/apps/mobile/src/components/ui/useToolbarLayout.ts b/apps/mobile/src/components/ui/useToolbarLayout.ts index 736ac27ed4eb..faa02c198289 100644 --- a/apps/mobile/src/components/ui/useToolbarLayout.ts +++ b/apps/mobile/src/components/ui/useToolbarLayout.ts @@ -4,11 +4,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; -/** - * Shared Android header geometry; class-based spacing uses the app's 14dp rem. - * `singleLine` sizes the toolbar for a title row with the subtitle inline. - */ -export function useToolbarLayout(embedded = false, singleLine = false) { +/** Shared Android header geometry; class-based spacing uses the app's 14dp rem. */ +export function useToolbarLayout(embedded = false) { const insets = useSafeAreaInsets(); const title = useScaledTextRole("title"); const subtitle = useScaledTextRole("label"); @@ -16,9 +13,7 @@ export function useToolbarLayout(embedded = false, singleLine = false) { const { fontScale } = useWindowDimensions(); return { height: Math.ceil( - singleLine - ? Math.max(48, 48 * scale, title.lineHeight * fontScale + 1) - : Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), + Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), ), paddingTop: embedded ? 8 * scale : Math.max(insets.top, 12 * scale), paddingBottom: 7 * scale, diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 582bd5ba2675..11fa0a0c7af4 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -40,7 +40,11 @@ import { useEnvironmentQuery } from "../../state/query"; import { useEnvironmentPresentation } from "../../state/presentation"; import { projectEnvironment } from "../../state/projects"; import type { AssetUrlFailureReason } from "../../state/asset-url-state"; -import { Inspector, useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { + Inspector, + useAdaptiveWorkspaceLayout, + useWorkspacePaneState, +} from "../layout/AdaptiveWorkspaceLayout"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; @@ -111,7 +115,8 @@ function FileHeader(props: { readonly onPress: () => unknown; }>; }) { - const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { panes } = useWorkspacePaneState(); const modes = props.actions.filter(({ inline }) => inline); return ( ): AdaptiveWorkspaceActions { + const latest = useRef(actions); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- points the stable actions at the handlers this commit rendered + useLayoutEffect(() => { + latest.current = actions; + }); + const [stable] = useState((): AdaptiveWorkspaceActions => ({ + startNewTask: () => latest.current.startNewTask(), + selectDetail: (detail, sheet) => latest.current.selectDetail(detail, sheet), + registerWorkspaceInspector: (content, routeKey, revealKey) => + latest.current.registerWorkspaceInspector(content, routeKey, revealKey), + setPrimarySidebarSearchQuery: (query) => latest.current.setPrimarySidebarSearchQuery(query), + showInspector: () => latest.current.showInspector(), + toggleAuxiliaryPane: () => latest.current.toggleAuxiliaryPane(), + togglePrimarySidebar: () => latest.current.togglePrimarySidebar(), + revealPrimarySidebar: () => latest.current.revealPrimarySidebar(), + setPrimarySidebarVisible: (visible) => latest.current.setPrimarySidebarVisible(visible), + openWorkspaceTab: (tab, options) => latest.current.openWorkspaceTab(tab, options), + setAuxiliaryPaneWidth: (width) => latest.current.setAuxiliaryPaneWidth(width), + setNativeInspectorWidth: (width) => latest.current.setNativeInspectorWidth(width), + })); + return stable; +} + export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; @@ -292,37 +319,11 @@ function AdaptiveWorkspaceLayoutContent( nativeWorkspace, usesSplitView: baseLayout.usesSplitView, }); - const [composerEditing, setComposerEditing] = useState(false); - const softKeyboardVisible = useSoftKeyboardShowing(); - // Keyed on the soft keyboard, not focus: a hardware keyboard leaves room for the sidebar, and - // Back closes the keyboard while the editor keeps focus. The sidebar returns as the keyboard - // starts hiding, not after. Deferred so the composer commits its keyboard render first; - // relaying out in the same commit held it behind the keyboard. - const composerKeyboardActive = useDeferredValue( - Platform.OS === "android" && baseLayout.usesSplitView && composerEditing && softKeyboardVisible, - ); - const sidebarWithoutKeyboard = - !workspacePage && (primarySidebarPinned || preferredSidebarVisible); - const showPrimarySidebar = - sidebarWithoutKeyboard && (primarySidebarPinned || !composerKeyboardActive); + const showPrimarySidebar = !workspacePage && (primarySidebarPinned || preferredSidebarVisible); // Medium windows float the sidebar over a page; beside a list it keeps its room. const sidebarFloats = baseLayout.variant === "medium" && (nativeWorkspace || (!primarySidebarPinned && !workspacePage)); - // While the composer edits, the sidebar's width follows the keyboard's own progress, so it - // leaves and returns in step with the keyboard instead of on a timer of its own. - const keyboardHoldsSidebar = - Platform.OS === "android" && - baseLayout.usesSplitView && - !workspacePage && - !primarySidebarPinned && - !sidebarFloats && - (composerEditing || composerKeyboardActive); - const keyboardProgress = useKeyboardContext().reanimated.progress; - // From the moment the keyboard starts showing until it has fully hidden. The detail pane keeps - // the sidebar's room for that whole span, so on the way back the sidebar pushes the wide pane - // aside instead of arriving beside one that already shrank. - const keyboardOnScreen = useKeyboardState((state) => state.isVisible); const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView && !floatingTabsEnabled; const enabledTabs = useEnabledNavigationTabs(); @@ -429,39 +430,6 @@ function AdaptiveWorkspaceLayoutContent( workspacePage, ], ); - // Whether the sidebar is open once the keyboard is gone; the keyboard's progress scales it. - const sidebarRestsOpen = useMemo( - () => - showPrimarySidebar === sidebarWithoutKeyboard - ? panes.primarySidebarVisible - : deriveWorkspacePaneLayout({ - layout, - viewportWidth: width, - horizontalSafeArea, - nativeWorkspace, - primarySidebarFloats: sidebarFloats, - primarySidebarPreferredVisible: sidebarWithoutKeyboard, - auxiliaryPanePreferredVisible: - !workspacePage && inspectorActive && fileInspectorPreferredVisible, - auxiliaryPanePreferredWidth: fileInspectorPreferredWidth ?? undefined, - nativeInspectorWidth, - }).primarySidebarVisible, - [ - nativeInspectorWidth, - horizontalSafeArea, - fileInspectorPreferredVisible, - fileInspectorPreferredWidth, - inspectorActive, - layout, - nativeWorkspace, - panes.primarySidebarVisible, - showPrimarySidebar, - sidebarFloats, - sidebarWithoutKeyboard, - width, - workspacePage, - ], - ); // Routes still on the stack remember their reveal, so going back to one keeps the user's choice. const revealedInspectors = useRef>(new Map()); const workspaceInspectorOwner = useRef(null); @@ -510,12 +478,6 @@ function AdaptiveWorkspaceLayoutContent( if (primarySidebarPinned) { return; } - // A sidebar the keyboard set aside returns with the keyboard; the user's preference stays - // intact. One the user had hidden still opens. - if (composerKeyboardActive) { - void KeyboardController.dismiss(); - if (preferredSidebarVisible) return; - } if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); setPrimarySidebarPreferredVisible(true); @@ -524,7 +486,6 @@ function AdaptiveWorkspaceLayoutContent( setPrimarySidebarVisible(!preferredSidebarVisible); }; const revealPrimarySidebar = () => { - if (composerKeyboardActive) void KeyboardController.dismiss(); if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } @@ -671,8 +632,6 @@ function AdaptiveWorkspaceLayoutContent( const contentEndGap = Platform.OS === "android" && layout.usesSplitView ? 4 : 0; // Android's split is one strip: a sidebar slot of fixed width, then the detail pane. The // sidebar never resizes; both slide by one offset, so together they always fill the width. - // The pane spans the sidebar's room too unless the sidebar has finished arriving: a sidebar on - // its way in pushes the wide pane past the edge, and one leaving uncovers it. const stripSidebar = Platform.OS === "android" && layout.usesSplitView && @@ -680,27 +639,9 @@ function AdaptiveWorkspaceLayoutContent( !workspacePage && !sidebarFloats; const sidebarSlotWidth = (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0); - const sidebarTargetOpen = keyboardHoldsSidebar ? sidebarRestsOpen : panes.primarySidebarVisible; - const [sidebarMotion, setSidebarMotion] = useState({ - strip: stripSidebar, - settledOpen: sidebarTargetOpen, - }); - if (sidebarMotion.strip !== stripSidebar) { - // Entering or leaving the strip (a workspace page, a fold) snaps the sidebar into place. - setSidebarMotion({ strip: stripSidebar, settledOpen: sidebarTargetOpen }); - } else if (sidebarMotion.settledOpen && !sidebarTargetOpen) { - setSidebarMotion({ strip: stripSidebar, settledOpen: false }); - } - const sidebarSettledOpen = - sidebarMotion.strip === stripSidebar ? sidebarMotion.settledOpen : sidebarTargetOpen; - const markSidebarSettled = (open: boolean) => - setSidebarMotion((current) => - current.settledOpen === open ? current : { ...current, settledOpen: open }, - ); - // The keyboard covers the sidebar's share from the moment it starts showing until it is gone. - const contentSpansSidebar = - stripSidebar && - (!sidebarSettledOpen || !sidebarTargetOpen || (keyboardHoldsSidebar && keyboardOnScreen)); + // The pane takes its new width in the commit that starts the sidebar moving, so it rewraps as + // the strip starts to slide rather than after it lands. + const contentSpansSidebar = stripSidebar && !panes.primarySidebarVisible; const contentTargetWidth = !layout.usesSplitView ? null : Math.max( @@ -811,16 +752,12 @@ function AdaptiveWorkspaceLayoutContent( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, ); const renderedRailWidth = useSharedValue(navigationRailWidth); - const keyboardHold = useSharedValue(keyboardHoldsSidebar); const leavingWorkspacePage = useRef(false); + // The slide starts after the commit that lays the pane out, not in the press handler: Reanimated + // commits the shadow tree every frame, and that starves a large React commit until it stops. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated rail and sidebar width shared values toward the layout's targets useEffect(() => { if (nativeWorkspace) return; - // Released mid-keyboard (the composer blurred), the timing starts where the sidebar is drawn. - if (keyboardHold.get() && !keyboardHoldsSidebar) { - renderedSidebarWidth.set(renderedSidebarWidth.get() * (1 - keyboardProgress.get())); - } - keyboardHold.set(keyboardHoldsSidebar); renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); // A workspace page hides the sidebar through React instead, in the same commit that mounts // the page. Animating it here would hide it a few frames before the page appears. @@ -830,15 +767,15 @@ function AdaptiveWorkspaceLayoutContent( } const snap = leavingWorkspacePage.current; leavingWorkspacePage.current = false; - const targetWidth = (keyboardHoldsSidebar ? sidebarRestsOpen : panes.primarySidebarVisible) + const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0; // The content beside it already took its final width when the page closed. renderedSidebarWidth.set( withTiming(targetWidth, snap ? { duration: 0 } : WORKSPACE_PANE_TIMING, (finished) => { - if (finished && Platform.OS === "android") { + // Only a hinge resize widens the list's span; a toggle has nothing to settle. + if (finished && Platform.OS === "android" && listPaneFloor !== listPaneCeiling) { runOnJS(settleListPane)(listPaneRevision); - runOnJS(markSidebarSettled)(targetWidth > 0); } }), ); @@ -848,14 +785,11 @@ function AdaptiveWorkspaceLayoutContent( layout.listPaneWidth, layout.listPaneGap, panes.primarySidebarVisible, - keyboardHoldsSidebar, - keyboardHold, - keyboardProgress, - sidebarRestsOpen, renderedSidebarWidth, renderedRailWidth, settleListPane, - markSidebarSettled, + listPaneFloor, + listPaneCeiling, listPaneRevision, workspacePage, ]); @@ -886,45 +820,29 @@ function AdaptiveWorkspaceLayoutContent( // Showing or hiding the sidebar clips a fixed-width list. A hinge resize instead tracks the // animated edge, so the header actions slide with it rather than jumping or being covered. const listPaneGap = layout.listPaneGap ?? 0; - const keyboardSidebarRest = sidebarRestsOpen - ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) - : 0; - const drawnSidebarWidth = useDerivedValue( - () => renderedSidebarWidth.value * (keyboardHold.value ? 1 - keyboardProgress.value : 1), - ); const listPaneAnimatedStyle = useAnimatedStyle(() => ({ width: Math.min( listPaneCeiling, Math.max( listPaneFloor, - (stripSidebar ? sidebarSlotWidth : drawnSidebarWidth.value) - listPaneGap, + (stripSidebar ? sidebarSlotWidth : renderedSidebarWidth.value) - listPaneGap, ), ), })); // How far the strip has slid: 0 with the sidebar shown, minus its width with it gone. const stripOffset = useDerivedValue(() => - stripSidebar ? drawnSidebarWidth.value - sidebarSlotWidth : 0, + stripSidebar ? renderedSidebarWidth.value - sidebarSlotWidth : 0, ); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // Fading a UIKit glass ancestor can leave remounted header effects missing. opacity: stripSidebar || Platform.OS === "ios" || floatingTabsEnabled ? 1 - : Math.min(1, drawnSidebarWidth.value / 80), - width: stripSidebar ? sidebarSlotWidth : drawnSidebarWidth.value, + : Math.min(1, renderedSidebarWidth.value / 80), + width: stripSidebar ? sidebarSlotWidth : renderedSidebarWidth.value, flexShrink: stripSidebar ? 0 : 1, transform: [{ translateX: stripOffset.value }], })); - // The content keeps the width React laid it out at and rides the sidebar's edge, so a keyboard - // transition relays nothing out per frame, the way UIKit animates the iOS split view. - // Until React commits the wider layout, the detail card continues past its right edge, so the - // strip the narrower column leaves shows the card instead of the row behind it. - const contentOverhangStyle = useAnimatedStyle(() => ({ - opacity: keyboardHold.value && keyboardProgress.value > 0 ? 1 : 0, - })); - const threadHeader = useToolbarLayout(false, composerKeyboardActive); - const threadHeaderHeight = - threadHeader.paddingTop + threadHeader.height + threadHeader.paddingBottom; // Clip the navigator at its settled width so native headers and markdown reflow only once per pane toggle. const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); @@ -933,7 +851,7 @@ function AdaptiveWorkspaceLayoutContent( 0, viewportWidth - (sidebarFloats ? 0 : renderedRailWidth.value) - - (workspacePage || sidebarFloats ? 0 : drawnSidebarWidth.value) - + (workspacePage || sidebarFloats ? 0 : renderedSidebarWidth.value) - renderedInspectorWidth.value, ), ); @@ -988,32 +906,36 @@ function AdaptiveWorkspaceLayoutContent( [sidebarInLandscape, sidebarInPortrait, sidebarOverRotates], ); - const contextValue = { - layout, - panes, - fileInspector, - primarySidebarSearchQuery, + const actions = useStableActions({ startNewTask: handleStartNewTask, selectDetail: handleSelectDetail, registerWorkspaceInspector, setPrimarySidebarSearchQuery, showInspector, toggleAuxiliaryPane, - sidebarToggleable: layout.usesSplitView && !primarySidebarPinned && !workspacePage, togglePrimarySidebar, revealPrimarySidebar, setPrimarySidebarVisible, openWorkspaceTab, setAuxiliaryPaneWidth: setFileInspectorPreferredWidth, - nativeInspectorWidth, setNativeInspectorWidth: (width: number) => setNativeInspectorWidthFraction(inspectorWidthFor(width / viewportWidth) / viewportWidth), - composerKeyboardActive, - keyboardHoldsSidebar, - setComposerEditing, - sidebarByOrientation, - workspacePageWidth, - }; + }); + const contextValue = useMemo( + () => ({ ...actions, layout, nativeInspectorWidth, workspacePageWidth }), + [actions, layout, nativeInspectorWidth, workspacePageWidth], + ); + const sidebarToggleable = layout.usesSplitView && !primarySidebarPinned && !workspacePage; + const paneState = useMemo( + () => ({ + panes, + fileInspector, + primarySidebarSearchQuery, + sidebarToggleable, + sidebarByOrientation, + }), + [panes, fileInspector, primarySidebarSearchQuery, sidebarToggleable, sidebarByOrientation], + ); const inspectorSheet = ( - + + + ); @@ -1174,35 +1098,6 @@ function AdaptiveWorkspaceLayoutContent( - {keyboardHoldsSidebar && contentTargetWidth !== null ? ( - <> - - - - ) : null} {sidebarFloats ? ( - - - {usesPresentationHost ? ( - - {props.children} - - ) : ( - renderColumns(props.children) - )} - - + + + + {usesPresentationHost ? ( + + {props.children} + + ) : ( + renderColumns(props.children) + )} + + + ); } diff --git a/apps/mobile/src/features/layout/adaptive-workspace-context.ts b/apps/mobile/src/features/layout/adaptive-workspace-context.ts index d82e6202c633..e4ef355412af 100644 --- a/apps/mobile/src/features/layout/adaptive-workspace-context.ts +++ b/apps/mobile/src/features/layout/adaptive-workspace-context.ts @@ -11,11 +11,8 @@ import { type WorkspacePaneLayout, } from "../../lib/layout"; -interface AdaptiveWorkspaceContextValue { - readonly layout: Layout; - readonly panes: WorkspacePaneLayout; - readonly fileInspector: FileInspectorPaneLayout; - readonly primarySidebarSearchQuery: string; +/** The workspace's actions; their identities never change. */ +export interface AdaptiveWorkspaceActions { readonly startNewTask: () => void; /** Use `useSelectWorkspaceDetail`, which also works outside the layout. */ readonly selectDetail?: (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => void; @@ -28,11 +25,6 @@ interface AdaptiveWorkspaceContextValue { /** Opens the inspector: its column where the window has one, otherwise a sheet. */ readonly showInspector: () => void; readonly toggleAuxiliaryPane: () => void; - /** - * Whether the content pane offers a sidebar button: false without columns, on a list that keeps - * its sidebar, and on a workspace page (Android Settings, Usage) that covers it. - */ - readonly sidebarToggleable: boolean; readonly togglePrimarySidebar: () => void; /** Shows the sidebar, setting aside the keyboard or an inspector that took its room. */ readonly revealPrimarySidebar: () => void; @@ -43,24 +35,32 @@ interface AdaptiveWorkspaceContextValue { options?: { readonly columns?: boolean }, ) => void; readonly setAuxiliaryPaneWidth: (width: number) => void; - readonly nativeInspectorWidth: number; readonly setNativeInspectorWidth: (width: number) => void; +} + +/** Changes only with the window (size, fold, hinge), never with a pane toggle or the keyboard. */ +interface AdaptiveWorkspaceLayoutValue extends AdaptiveWorkspaceActions { + readonly layout: Layout; + readonly nativeInspectorWidth: number; + /** Android split: the width a workspace page (Usage) opens at; null without columns. */ + readonly workspacePageWidth?: number | null; +} + +/** Changes as panes toggle and the keyboard comes and goes; read it only where that shows. */ +interface WorkspacePaneStateValue { + readonly panes: WorkspacePaneLayout; + readonly fileInspector: FileInspectorPaneLayout; + readonly primarySidebarSearchQuery: string; /** - * Android split view while the thread composer has the soft keyboard: the - * sidebar and rail step aside and the thread header drops to one line. + * Whether the content pane offers a sidebar button: false without columns, on a list that keeps + * its sidebar, and on a workspace page (Android Settings, Usage) that covers it. */ - readonly composerKeyboardActive: boolean; + readonly sidebarToggleable: boolean; /** * Whether the sidebar shows in each orientation, where it floats over the content in portrait * (the unfolded Duo). The native split switches between them as a rotation starts. */ readonly sidebarByOrientation: { readonly landscape: boolean; readonly portrait: boolean } | null; - /** Android split: the composer's keyboard slides the sidebar, from focus until it lets go. */ - readonly keyboardHoldsSidebar?: boolean; - /** Android split: the width a workspace page (Usage) opens at; null without columns. */ - readonly workspacePageWidth?: number | null; - /** The thread composer reports its focus so the layout can make room for typing. */ - readonly setComposerEditing: (editing: boolean) => void; } const compactLayout = deriveLayout({ width: 0, height: 0 }); @@ -74,17 +74,13 @@ const compactFileInspector = deriveFileInspectorPaneLayout({ layout: compactLayout, viewportWidth: 0, }); -export const AdaptiveWorkspaceContext = createContext({ +export const AdaptiveWorkspaceContext = createContext({ layout: compactLayout, - panes: compactPanes, - fileInspector: compactFileInspector, - primarySidebarSearchQuery: "", startNewTask: () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, showInspector: () => undefined, toggleAuxiliaryPane: () => undefined, - sidebarToggleable: false, togglePrimarySidebar: () => undefined, revealPrimarySidebar: () => undefined, setPrimarySidebarVisible: () => undefined, @@ -92,11 +88,21 @@ export const AdaptiveWorkspaceContext = createContext undefined, nativeInspectorWidth: 0, setNativeInspectorWidth: () => undefined, - composerKeyboardActive: false, +}); +export const WorkspacePaneStateContext = createContext({ + panes: compactPanes, + fileInspector: compactFileInspector, + primarySidebarSearchQuery: "", + sidebarToggleable: false, sidebarByOrientation: null, - setComposerEditing: () => undefined, }); -export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { +/** The window's layout and the workspace's actions. Toggling a pane doesn't re-render readers. */ +export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceLayoutValue { return use(AdaptiveWorkspaceContext); } + +/** Which panes show, and the keyboard's hold on them. Re-renders on every pane toggle. */ +export function useWorkspacePaneState(): WorkspacePaneStateValue { + return use(WorkspacePaneStateContext); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 14ffb11abf22..a0e677b6f8de 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -46,7 +46,7 @@ import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { setNativelyPoppingWorkspaceRoute } from "./workspace/workspace-detail-selection"; import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; -import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "./AdaptiveWorkspaceLayout"; import { WorkspacePaneDivider } from "./workspace-pane-divider"; import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios"; @@ -205,15 +205,14 @@ function WorkspaceColumns( ) { const { layout, - panes, setPrimarySidebarVisible, toggleAuxiliaryPane, startNewTask, openWorkspaceTab, - sidebarByOrientation, nativeInspectorWidth, setNativeInspectorWidth, } = useAdaptiveWorkspaceLayout(); + const { panes, sidebarByOrientation } = useWorkspacePaneState(); const deviceType = useDeviceType(); const inspectorWidth = nativeInspectorWidth; const resizeStartWidth = useRef(inspectorWidth); diff --git a/apps/mobile/src/features/layout/useSidebarToggle.ts b/apps/mobile/src/features/layout/useSidebarToggle.ts index 5b0beed08a43..df5e93a50c19 100644 --- a/apps/mobile/src/features/layout/useSidebarToggle.ts +++ b/apps/mobile/src/features/layout/useSidebarToggle.ts @@ -1,10 +1,11 @@ import { usePaneRole } from "../../components/ui/Pane"; -import { useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; +import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "./adaptive-workspace-context"; /** The content pane's sidebar button, or null where there's no sidebar beside it to toggle. */ export function useSidebarToggle() { const role = usePaneRole(); - const { layout, panes, sidebarToggleable, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const { layout, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const { panes, sidebarToggleable } = useWorkspacePaneState(); if (role !== "content" || !sidebarToggleable) return null; const maximizes = panes.primarySidebarVisible && layout.variant !== "medium"; return { diff --git a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx index 246a0c9d7cb7..382336367e2f 100644 --- a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx @@ -81,7 +81,7 @@ export function AndroidWorkspaceNavigation(props: { const toolbar = useToolbarLayout(); const connectionState = useWorkspaceConnectionState(); const keyboardVisible = useSoftKeyboardShowing(); - const { layout, keyboardHoldsSidebar } = useAdaptiveWorkspaceLayout(); + const { layout } = useAdaptiveWorkspaceLayout(); const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const { scale, fabSize } = useAndroidControlSizing(); const window = useWindowDimensions(); @@ -102,8 +102,7 @@ export function AndroidWorkspaceNavigation(props: { (props.paneWidth ?? window.width) - (Math.max(insets.left, FLOATING_TABS.edge) - FLOATING_TABS.edge); const metrics = floatingTabsMetrics(tabs.length, scale, fabSize, barWidth, props.fillWidth); - // The composer's keyboard slides the sidebar away with the bar on it; other keyboards don't. - if (!rail && keyboardVisible && !keyboardHoldsSidebar) return null; + if (!rail && keyboardVisible) return null; return ( void; readonly onStartNewTask: () => void; }) { - const { panes } = useAdaptiveWorkspaceLayout(); + const { panes } = useWorkspacePaneState(); return ( <> ) : null} @@ -1474,6 +1501,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {thread.title} + {providerNameLabel ? ( + + {providerNameLabel} + + ) : null} {props.searchMatch ? ( ({ THREAD_DISCLOSURE_TRANSI vi.mock("../features/threads/HtmlRenderWebView", () => ({})); vi.mock("../features/threads/McpAppWebView", () => ({})); vi.mock("../features/layout/AdaptiveWorkspaceLayout", () => ({ - useAdaptiveWorkspaceLayout: () => ({ fileInspector: { supported: false } }), + useWorkspacePaneState: () => ({ fileInspector: { supported: false } }), })); vi.mock("../features/threads/thread-workflow-opening", () => ({ useWorkflowOpening: () => null })); vi.mock("../features/threads/useFileChipShare", () => ({ useFileChipShare: () => vi.fn() })); diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 3ec891ac8c4c..0115a7b3334d 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { alignListPaneToHinge, deriveLayout, - deriveThreadFeedInitialContentInset, deriveThreadArrangementSizing, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, @@ -50,19 +49,6 @@ describe("thread work-log text sizing", () => { }); }); -describe("deriveThreadFeedInitialContentInset", () => { - // regression: #5585 land the first thread open above the composer on Android - it("does not double native iOS insets", () => { - expect( - deriveThreadFeedInitialContentInset({ - platform: "ios", - usesNativeAutomaticInsets: true, - bottomContentInset: 174, - }), - ).toBeUndefined(); - }); -}); - describe("native workspace sizing", () => { it.each([ { width: 669, height: 951, hasHinge: true, nativeWorkspace: true, overlay: true }, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index c255a03ceb3b..bc019ed8eb3f 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -93,18 +93,6 @@ export interface FileInspectorPaneLayout { readonly width: number | null; } -export function deriveThreadFeedInitialContentInset(input: { - readonly platform: string; - readonly usesNativeAutomaticInsets: boolean; - readonly bottomContentInset: number; -}): { readonly bottom: number } | undefined { - if (input.platform !== "android" || input.usesNativeAutomaticInsets) { - return undefined; - } - - return { bottom: Math.max(0, input.bottomContentInset) }; -} - export function deriveLayout(input: { readonly width: number; readonly height: number; diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 3c05047e4a4c..14f12059ce5c 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,14 +1,6 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign. -import * as NodeCrypto from "node:crypto"; - import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { authScopeResponse, AuthStandardClientScopes } from "@t3tools/contracts"; -import { - computeDpopAccessTokenHash, - computeDpopJwkThumbprint, - type DpopPublicJwk, -} from "@t3tools/shared/dpop"; import { expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -16,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as ServerConfig from "../config.ts"; +import { makeDpopKey } from "./dpop.testkit.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -80,36 +73,6 @@ const requestMetadata = { ipAddress: "192.168.1.23", }; -const makeDpopKey = () => { - const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); - const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; - let proofCount = 0; - return { - thumbprint: computeDpopJwkThumbprint(publicJwk), - sign: (input: { - readonly method: string; - readonly url: string; - readonly token: string; - readonly iat: number; - }) => { - const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); - const header = encode({ typ: "dpop+jwt", alg: "ES256", jwk: publicJwk }); - const payload = encode({ - htm: input.method, - htu: input.url, - jti: `proof-${++proofCount}`, - iat: input.iat, - ath: computeDpopAccessTokenHash(input.token), - }); - const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { - key: privateKey, - dsaEncoding: "ieee-p1363", - }).toString("base64url"); - return `${header}.${payload}.${signature}`; - }, - }; -}; - it.layer(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer))( "EnvironmentAuth.layer", (it) => { diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index a397576340a0..c7cf59db9386 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -10,6 +10,7 @@ import { mcpClientScopes, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; @@ -24,6 +25,7 @@ import * as Schema from "effect/Schema"; import * as CloudLink from "../cloud/CloudLink.ts"; import * as ServerConfig from "../config.ts"; +import { makeDpopKey } from "./dpop.testkit.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as Sqlite from "../persistence/Sqlite.ts"; @@ -130,17 +132,19 @@ const withRoutes = ( const json = (response: Response) => Effect.promise(() => response.json() as Promise); -/** `credential` is a browser cookie, or `Bearer ` for a session such as T3 Connect's. */ -const postJson = (path: string, body: unknown, credential?: string) => +/** `credential` is a browser cookie, `Bearer `, or the headers of a DPoP request. */ +const postJson = (path: string, body: unknown, credential?: string | Record) => at(path, { method: "POST", headers: { "content-type": "application/json", ...(credential === undefined ? {} - : credential.startsWith("Bearer ") - ? { authorization: credential } - : { cookie: credential }), + : typeof credential !== "string" + ? credential + : credential.startsWith("Bearer ") + ? { authorization: credential } + : { cookie: credential }), }, body: encodeJson(body), }); @@ -149,7 +153,7 @@ const decide = ( handler: Handler, authorization: Record, decision: Record, - cookie?: string, + cookie?: string | Record, ) => handler(postJson("/oauth/mcp/decision", { authorization, decision }, cookie)).pipe( Effect.flatMap((response) => @@ -742,6 +746,50 @@ it.live("one-click approves only access the browser session holds the scopes for connect, ); expect(new URL(viaConnect.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); + + // The hosted app may load and decide with two credentials minted for the same browser key. + const key = makeDpopKey(); + const dpopSession = (thumbprint: string) => + auth.issuePairingCredential({ scopes: [...AuthStandardClientScopes] }).pipe( + Effect.flatMap((pairing) => + auth.exchangeBootstrapCredentialForAccessToken( + pairing.credential, + undefined, + { deviceType: "desktop" }, + { proofKeyThumbprint: thumbprint }, + ), + ), + Effect.map((exchanged) => exchanged.access_token), + ); + const iat = Math.floor((yield* Clock.currentTimeMillis) / 1_000); + const dpopHeaders = (signer: typeof key, token: string, path: string) => ({ + authorization: `DPoP ${token}`, + dpop: signer.sign({ method: "POST", url: `${ORIGIN}${path}`, token, iat }), + }); + const loadedWith = yield* dpopSession(key.thumbprint); + const loaded = yield* handler( + postJson( + "/oauth/mcp/approval", + params, + dpopHeaders(key, loadedWith, "/oauth/mcp/approval"), + ), + ).pipe(Effect.flatMap(json<{ csrfToken?: string }>)); + const renewed = yield* dpopSession(key.thumbprint); + const sameKey = yield* decide( + handler, + params, + { _tag: "browser-session", access: "auto", csrfToken: loaded.csrfToken! }, + dpopHeaders(key, renewed, "/oauth/mcp/decision"), + ); + expect(new URL(sameKey.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); + const otherKey = makeDpopKey(); + const elsewhere = yield* decide( + handler, + params, + { _tag: "browser-session", access: "auto", csrfToken: loaded.csrfToken! }, + dpopHeaders(otherKey, yield* dpopSession(otherKey.thumbprint), "/oauth/mcp/decision"), + ); + expect(elsewhere.status).toBe(400); }), ), ); diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 7625fe436a8e..426cb2213f3e 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -307,11 +307,11 @@ const make = Effect.gen(function* () { ) : Effect.succeed(false); - const csrfToken = (sessionId: string, authorization: AuthorizationRequest) => + const csrfToken = (holder: string, authorization: AuthorizationRequest) => sign( "mcp-csrf", JSON.stringify([ - sessionId, + holder, authorization.client.clientId, authorization.redirectUri, authorization.codeChallenge, @@ -413,9 +413,13 @@ const make = Effect.gen(function* () { // Approving manages access, and a session may only hand out scopes it holds. session.scopes.includes(AuthAccessWriteScope) && session.scopes.includes(AuthOrchestrationReadScope) - ? csrfToken(session.sessionId, authorization).pipe( - Effect.map((token) => ({ csrfToken: token, scopes: session.scopes })), - ) + ? // The hosted app may get a fresh credential per call, all bound to one browser key. + csrfToken( + session.proofKeyThumbprint === undefined + ? session.sessionId + : `dpop:${session.proofKeyThumbprint}`, + authorization, + ).pipe(Effect.map((token) => ({ csrfToken: token, scopes: session.scopes }))) : Effect.undefined, ), Effect.orElseSucceed(() => undefined), diff --git a/apps/server/src/auth/dpop.testkit.ts b/apps/server/src/auth/dpop.testkit.ts new file mode 100644 index 000000000000..f0f00c6ccc43 --- /dev/null +++ b/apps/server/src/auth/dpop.testkit.ts @@ -0,0 +1,39 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign. +import * as NodeCrypto from "node:crypto"; + +import { + computeDpopAccessTokenHash, + computeDpopJwkThumbprint, + type DpopPublicJwk, +} from "@t3tools/shared/dpop"; + +/** A client's DPoP key, signing proofs the way a browser or the mobile app does. */ +export const makeDpopKey = () => { + const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; + let proofCount = 0; + return { + thumbprint: computeDpopJwkThumbprint(publicJwk), + sign: (input: { + readonly method: string; + readonly url: string; + readonly token: string; + readonly iat: number; + }) => { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const header = encode({ typ: "dpop+jwt", alg: "ES256", jwk: publicJwk }); + const payload = encode({ + htm: input.method, + htu: input.url, + jti: `proof-${++proofCount}`, + iat: input.iat, + ath: computeDpopAccessTokenHash(input.token), + }); + const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { + key: privateKey, + dsaEncoding: "ieee-p1363", + }).toString("base64url"); + return `${header}.${payload}.${signature}`; + }, + }; +}; diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 33c3443a1c26..b2c9afa11bb2 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -182,7 +182,7 @@ export const PreviewEvaluateResult = Schema.Struct({ const PreviewEvaluateTool = browserTool( Tool.make("preview_evaluate", { description: - "Run JavaScript in the page. It can change page state. Returns `{value}`, up to 64 KB. Needs your tab, or an unclaimed one. To read a tab the user drives, use `preview_snapshot`. Page tools reached here: read data only, never create, save, or display. Show results with `html_render`.", + "Run JavaScript in the page. It can change page state. Returns `{value}`, up to 64 KB. Needs your tab, or an unclaimed one. To read a tab the user drives, use `preview_snapshot`. Page tools write only on user request. Show results with `html_render`, never page tools.", parameters: PreviewAutomationEvaluateInput, success: PreviewEvaluateResult, failure: PreviewToolFailure, diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 3925baa1a426..97d75c2c4ecb 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1961,6 +1961,47 @@ it.effect("a stalled desktop viewer still gets its size, control and reconnect", ).pipe(Effect.provide(layer)), ); +it.live("the tab the desktop shows is visible and picked for agents without a tab", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* ServerBrowser.ServerBrowser; + yield* Effect.yieldNow; + const openDesktopTab = Effect.fn("openDesktopTab")(function* (url: string) { + desktopRendersNext = true; + const opened = yield* manager.open({ + threadId: scope.thread.threadId, + url, + runtime: "server", + }); + const tabId = PreviewTabId.make(opened.tabId); + yield* broker.invoke({ scope, tabId, operation: "status", input: {} }); + return tabId; + }); + const recentTab = yield* openDesktopTab("http://localhost:5173/recent"); + const shownTab = yield* openDesktopTab("http://localhost:5173/shown"); + // Opened first and used last, so recency or insertion order alone picks it. + yield* broker.invoke({ scope, tabId: recentTab, operation: "status", input: {} }); + + shownDesktopTabs.add(shownTab); + const status = yield* broker.invoke({ + scope: asSession("agent-b"), + operation: "status", + input: {}, + }); + expect(status).toMatchObject({ + tabId: shownTab, + visible: true, + tabs: [ + expect.objectContaining({ tabId: recentTab, visible: false }), + expect.objectContaining({ tabId: shownTab, visible: true }), + ], + }); + }), + ).pipe(Effect.provide(layer)), +); + it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 7f128dc5d4eb..2f681d0c151c 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1511,8 +1511,7 @@ const make = Effect.gen(function* () { .sort((left, right) => right.createdAt - left.createdAt)[0] ?? threadTabs.sort( (left, right) => - Number(right.viewers.size > 0) - Number(left.viewers.size > 0) || - right.usedAt - left.usedAt, + Number(isVisible(right)) - Number(isVisible(left)) || right.usedAt - left.usedAt, )[0] ); }; @@ -1621,7 +1620,7 @@ const make = Effect.gen(function* () { const viewport = tab.desktop ? await layoutSize(tab) : tab.page.viewportSize(); const status = { available: true, - visible: tab.viewers.size > 0, + visible: isVisible(tab), tabId: tab.tabId, url: url === "about:blank" ? null : url, title: null, @@ -1648,7 +1647,7 @@ const make = Effect.gen(function* () { ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }), owner: tabOwner(candidate), ownedByCaller: candidate.control.agentId === agentSessionId, - visible: candidate.viewers.size > 0, + visible: isVisible(candidate), ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }), })), ...profileStatus(), diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts index a08eac31746f..8fd031d43442 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts @@ -11,6 +11,8 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; +import { parseRateTable, priceUsage } from "../../usage/usagePricing.ts"; + import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; const openDatabase = Effect.fn("openDatabase")(function* (filename: string) { @@ -70,6 +72,145 @@ const records = (result: Effect.Success> result.files.flatMap((file) => file.records); describe("readAntigravityUsage", () => { + it.effect("learns numeric models from offline metadata and refreshes cached resolutions", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const dir = yield* tempDir; + const cache = makeAntigravityUsageCache(); + yield* seedDatabase(path.join(dir, "a-usage.db"), (db) => + Effect.gen(function* () { + yield* createTables(db); + for (const [idx, id] of [9001, 9002, 1319].entries()) { + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + idx, + new Uint8Array(protoBytes(9, [...protoNumber(1, id), ...protoNumber(2, 10)])), + ]); + } + }), + ); + const first = yield* readAntigravityUsage(dir, 0, cache); + assert.strictEqual(records(first)[0]?.model, "antigravity-model-9001"); + const evidence = path.join(dir, "z-names.db"); + yield* seedDatabase(evidence, (db) => + Effect.gen(function* () { + yield* createTables(db); + for (const [idx, [id, name]] of ( + [ + [9001, "Gemini 9.1 Flash (High)"], + [9002, "Gemini default"], + [1319, "Gemini 9.2 Flash"], + ] as const + ).entries()) { + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + idx, + new Uint8Array( + protoBytes(1, [ + ...protoNumber(3, id), + ...protoText(19, "gemini-default"), + ...protoText(21, name), + ]), + ), + ]); + } + }), + ); + const next = yield* readAntigravityUsage(dir, 0, cache); + assert.deepStrictEqual( + records(next).map(({ model, rateModel }) => [model, rateModel]), + [ + ["gemini-9.1-flash-high", "gemini-9.1-flash"], + ["antigravity-model-9002", undefined], + ["gemini-9.2-flash", undefined], + ], + ); + assert.deepStrictEqual(records(yield* readAntigravityUsage(dir, 0, cache)), records(next)); + yield* fs.remove(evidence); + assert.deepStrictEqual(records(yield* readAntigravityUsage(dir, 0, cache)), records(first)); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps explicit model evidence separate from retries and conflicting names", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* tempDir; + yield* seedDatabase(path.join(dir, "conflicts.db"), (db) => + Effect.gen(function* () { + yield* createTables(db); + for (const [idx, name] of ["Gemini 9.1 Flash", "Gemini 9.2 Flash"].entries()) { + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + idx, + new Uint8Array( + protoBytes(1, [ + ...protoNumber(3, 1319), + ...protoText(19, name), + ...protoBytes(4, [ + ...protoNumber(1, 1319), + ...protoNumber(2, 10), + ...protoText(11, `r-${idx}`), + ]), + ...protoBytes( + 17, + protoBytes(2, [...protoNumber(1, 1026), ...protoNumber(2, 12)]), + ), + ]), + ), + ]); + } + for (const [idx, id] of [1319, 9003].entries()) { + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 10 + idx, + new Uint8Array( + protoBytes(9, [ + ...protoNumber(1, id), + ...protoNumber(2, 10), + ...(idx === 0 ? protoText(11, "r-0") : []), + ]), + ), + ]); + } + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 2, + new Uint8Array(protoBytes(9, [...protoNumber(1, 1319), ...protoNumber(2, 10)])), + ]); + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + 3, + new Uint8Array( + protoBytes(1, [ + ...protoText(19, "Gemini 9.3 Flash"), + ...protoBytes(17, protoBytes(2, [...protoNumber(1, 9004), ...protoNumber(2, 10)])), + ]), + ), + ]); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 4, + new Uint8Array([ + ...protoBytes(24, protoText(12, "Claude 5.5 Opus (Thinking)")), + ...protoBytes(9, [...protoNumber(1, 9005), ...protoNumber(2, 10)]), + ]), + ]); + }), + ); + const result = yield* readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.deepStrictEqual( + records(result) + .map(({ model }) => model) + .sort(), + [ + "gemini-9.1-flash", + "antigravity-model-9003", + "antigravity-model-1319", + "claude-opus-4-6", + "gemini-9.2-flash", + "claude-opus-4-6", + "antigravity-model-9004", + "claude-opus-5-5-thinking", + ].sort(), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect( "deduplicates Antigravity generation and step usage while preserving retry model and token buckets", () => @@ -162,6 +303,72 @@ describe("readAntigravityUsage", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("prices Antigravity effort and routing variants at their base model", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* tempDir; + yield* seedDatabase(path.join(dir, "variants.db"), (db) => + Effect.gen(function* () { + yield* createTables(db); + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + 0, + new Uint8Array(protoBytes(1, protoText(19, "Gemini 3.7 Flash Control"))), + ]); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 0, + new Uint8Array(protoBytes(9, protoNumber(2, 10))), + ]); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 1, + new Uint8Array(protoBytes(9, [...protoNumber(1, 1319), ...protoNumber(2, 11)])), + ]); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 2, + new Uint8Array(protoBytes(9, [...protoNumber(1, 1132), ...protoNumber(2, 12)])), + ]); + // A model-less step that only learns its model by merging with a named generation. + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + 5, + new Uint8Array(protoBytes(9, [...protoNumber(2, 100), ...protoText(11, "response")])), + ]); + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + 3, + new Uint8Array( + protoBytes(1, [ + ...protoText(19, "Gemini 3.8 Flash High"), + ...protoBytes(4, [...protoNumber(2, 100), ...protoText(11, "response")]), + ]), + ), + ]); + }), + ); + const result = yield* readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.deepStrictEqual( + records(result).map((record) => [record.model, record.rateModel]), + [ + ["gemini-3.7-flash-control", "gemini-3.7-flash"], + ["gemini-3.8-flash-medium", "gemini-3.8-flash"], + ["gemini-3.5-flash-high", "gemini-3.5-flash"], + ["gemini-3.8-flash-high", "gemini-3.8-flash"], + ], + ); + const table = parseRateTable({ + "gemini-3.7-flash": { input_cost_per_token: 0.75e-6, output_cost_per_token: 3.75e-6 }, + "gemini-3.8-flash": { input_cost_per_token: 0.75e-6, output_cost_per_token: 3.75e-6 }, + "gemini-3.5-flash": { input_cost_per_token: 0.5e-6, output_cost_per_token: 3e-6 }, + }); + const priced = records(result).map((record) => priceUsage(table, record)); + assert.deepStrictEqual( + priced.map(({ costSource }) => costSource), + Array(4).fill("modelPriced"), + ); + for (const [index, expected] of [7.5e-6, 8.25e-6, 6e-6, 75e-6].entries()) { + assert.closeTo(priced[index]!.costUsd, expected, 1e-12); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("prices ACP effort variants at the base model rate", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -169,7 +376,14 @@ describe("readAntigravityUsage", () => { yield* seedDatabase(path.join(dir, "effort.db"), (db) => Effect.gen(function* () { yield* createTables(db); - for (const [idx, name] of ["gemini-4-argon-high", "gemini-4-argon"].entries()) { + for (const [idx, name] of [ + "gemini-4-argon-high", + "gemini-4-argon", + "gemini-3.7-flash-safety-le", + "gemini-3-flash-agent", + "gemini-3-flash", + "gemini-default", + ].entries()) { yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ idx, new Uint8Array(protoBytes(1, protoText(19, name))), @@ -187,6 +401,10 @@ describe("readAntigravityUsage", () => { [ ["gemini-4-argon-high", "gemini-4-argon"], ["gemini-4-argon", undefined], + ["gemini-3.7-flash-safety-le", "gemini-3.7-flash"], + ["gemini-3-flash-agent", "gemini-3.5-flash"], + ["gemini-3-flash", "gemini-3-flash-preview"], + ["gemini-default", undefined], ], ); }).pipe(Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index d6cb74aa19e4..4d473f50c9d6 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -78,6 +78,8 @@ const timestamp = (value: Fields) => { return seconds > 0 ? seconds * 1000 + Math.floor(numberAt(value, 2) / 1_000_000) : null; }; +// Offline fallback for records without names. New names are learned from the scanned metadata. +// Cross-checked against ccusage's Antigravity parser and mrexodia/agent-cost-dashboard. const MODEL_IDS: Record = { 246: "gemini-2.5-pro", 312: "gemini-2.5-flash", @@ -92,6 +94,7 @@ const MODEL_IDS: Record = { 334: "claude-sonnet-4-5", 340: "claude-haiku-4-5", 341: "claude-haiku-4-5", + 342: "gpt-oss-120b-medium", 1026: "claude-opus-4-6", 1035: "claude-sonnet-4-6", 1016: "gemini-3.1-pro", @@ -100,32 +103,78 @@ const MODEL_IDS: Record = { 1018: "gemini-3-flash-preview", 1084: "gemini-3-flash-preview", 1047: "gemini-3-flash-preview", + 1020: "gemini-3.5-flash-medium", + 1021: "gemini-3.1-flash-image", + 1050: "gemini-3.1-flash-lite", + 1071: "gemini-3.6-flash-high", + 1072: "gemini-3.6-flash-medium", + 1073: "gemini-3.6-flash-low", + 1132: "gemini-3.5-flash-high", + 1133: "gemini-3.5-flash-high", + 1187: "gemini-3.5-flash-extra-low", + 1196: "gemini-3.6-flash", + 1266: "gemini-3.6-flash", + 1298: "gemini-3.7-flash-high", + 1299: "gemini-3.7-flash-medium", + 1300: "gemini-3.7-flash-low", + 1301: "gemini-3.7-flash-tiered", + 1318: "gemini-3.8-flash-high", + 1319: "gemini-3.8-flash-medium", + 1320: "gemini-3.8-flash-low", + 1322: "gemini-3.8-flash-tiered", + 1402: "claude-opus-5-5", + 1417: "gemini-4-argon", + 1418: "gemini-4-argon", + 1419: "gemini-4-argon", }; -// ACP slugs append the thinking level, which shares the base model's price. +// Effort and routing variants share the base model's price. function withRateModel(record: UsageRecord): UsageRecord { - const rateModel = record.model.replace(/-(?:low|medium|high)$/, ""); + const base = record.model.replace( + /-(?:extra-low|low|medium|high|tiered|control|thinking|agent)$/, + "", + ); + const rateModel = /^gemini-3-flash-(?:agent(?:-[ab])?|[ab])$/.test(record.model) + ? "gemini-3.5-flash" + : record.model === "gemini-3.7-flash-safety-le" + ? "gemini-3.7-flash" + : base === "gemini-3-flash" + ? "gemini-3-flash-preview" + : base; return rateModel === record.model ? record : { ...record, rateModel }; } -function modelName(name: string, id: number): string { +function modelName(name: string): string { if (name) { const normalized = name .toLowerCase() - .replace(/\s*\([^)]*\)\s*$/, "") - .replaceAll(" ", "-"); + .replace(/\s*\(([^)]*)\)\s*$/, "-$1") + .replace(/\s+/g, "-"); if (normalized.startsWith("claude-")) { return normalized - .replace(/^claude-(4(?:\.\d+)?)-(sonnet|opus|haiku)/, "claude-$2-$1") + .replace(/^claude-(\d+(?:\.\d+)?)-(sonnet|opus|haiku)/, "claude-$2-$1") .replaceAll(".", "-"); } return normalized; } - return MODEL_IDS[id] ?? (id > 0 ? `antigravity-model-${id}` : ""); + return ""; +} + +function isConcreteModel(model: string): boolean { + return ( + /^(?:gemini-\d|claude-(?:sonnet|opus|haiku)-\d|gpt-)/.test(model) && + !/^gemini-3-flash-(?:agent(?:-[ab])?|[ab])$/.test(model) + ); +} + +function rememberModel(models: Map, id: number, model: string | null) { + const previous = models.get(id); + models.set(id, previous === undefined || previous === model ? model : null); } interface Metadata { model: string; + modelId: number; timestampMs: number | null; usages: Fields[]; } @@ -139,17 +188,19 @@ function metadata(bytes: Uint8Array, step: boolean): Metadata { const model = step ? nested(data, 24) : data; const usage = bytesAt(data, step ? 9 : 4); // Copy the usage bytes: subarrays kept in usages would hold each row's whole blob alive. - const usages = usage === undefined ? [] : [fields(usage.slice())]; + const primaryUsage = usage === undefined ? undefined : fields(usage.slice()); + const usages = primaryUsage === undefined ? [] : [primaryUsage]; for (const retry of data.get(step ? 28 : 17) ?? []) { if (!(retry instanceof Uint8Array)) throw new Error("Invalid retry metadata"); const retryUsage = bytesAt(fields(retry), 2); if (retryUsage !== undefined) usages.push(fields(retryUsage.slice())); } + const names = [textAt(model, step ? 12 : 19), textAt(model, step ? 8 : 21)].map(modelName); return { - model: modelName( - textAt(model, step ? 12 : 19) || textAt(model, step ? 8 : 21), - numberAt(model, step ? 1 : 3), - ), + model: + names.find(isConcreteModel) ?? names.find((name) => name && !/^model[_-]/.test(name)) ?? "", + modelId: + numberAt(model, step ? 1 : 3) || (primaryUsage === undefined ? 0 : numberAt(primaryUsage, 1)), timestampMs: step ? (timestamp(nested(data, 8)) ?? timestamp(nested(data, 1))) : timestamp(nested(nested(data, 9), 4)), @@ -164,6 +215,8 @@ function blob(value: unknown): Uint8Array { interface UsageCandidate { record: UsageRecord; + modelId: number; + modelQuality: number; keys: readonly string[]; timestampQuality: number; } @@ -257,9 +310,23 @@ const readDatabase = Effect.fn("readDatabase")(function* ( true, ) : []; - return yield* decode(() => - usageCandidates(sessionId, generations, steps, trajectoryTimestamp, fallbackTimestamp), - ); + return yield* decode(() => { + const models = new Map(); + for (const { entry } of [...generations, ...steps]) { + if (entry.modelId > 0 && isConcreteModel(entry.model)) + rememberModel(models, entry.modelId, entry.model); + } + return { + candidates: usageCandidates( + sessionId, + generations, + steps, + trajectoryTimestamp, + fallbackTimestamp, + ), + models, + }; + }); }).pipe(sql.withTransaction); }); @@ -271,13 +338,23 @@ function usageCandidates( fallbackTimestamp: number, ): UsageCandidate[] { const records: UsageCandidate[] = []; - const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry.model])); + const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry])); for (const [source, entries] of [ ["step", steps], ["generation", generations], ] as const) { for (const [index, { idx, entry }] of entries.entries()) { for (const [usageIndex, usage] of entry.usages.entries()) { + const modelId = numberAt(usage, 1) || entry.modelId; + const generation = source === "step" ? generationModels.get(idx) : undefined; + // A retry can use a different model than its enclosing generation or step. + const matches = (metadata: Metadata) => modelId === 0 || metadata.modelId === modelId; + const model = + matches(entry) && entry.model + ? entry.model + : generation && matches(generation) + ? generation.model + : ""; const outputTokens = Math.max(numberAt(usage, 3), numberAt(usage, 9) + numberAt(usage, 10)); const totals = { uncachedInputTokens: numberAt(usage, 2), @@ -302,12 +379,7 @@ function usageCandidates( provider: "antigravity", sessionId, timestampMs: entry.timestampMs ?? trajectoryTimestamp ?? fallbackTimestamp, - model: - MODEL_IDS[numberAt(usage, 1)] || - entry.model || - (source === "step" ? generationModels.get(idx) : "") || - modelName("", numberAt(usage, 1)) || - "antigravity-unknown", + model, totals, reportedCostUsd: null, speed: "standard", @@ -315,6 +387,8 @@ function usageCandidates( }; records.push({ record, + modelId, + modelQuality: isConcreteModel(model) ? 4 : model ? 1 : 0, keys, timestampQuality: entry.timestampMs !== null ? 2 : trajectoryTimestamp !== null ? 1 : 0, }); @@ -326,7 +400,7 @@ function usageCandidates( interface CachedDatabase { readonly fingerprint: string; - readonly candidates: readonly UsageCandidate[]; + readonly data: Effect.Success>; } /** Fingerprint canonical DB/WAL paths by size, mtime and ctime; WAL holds new rows, and ctime catches restored mtimes. */ @@ -345,6 +419,8 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* : conversationsDirectories; const files: Array<{ root: string; path: string; records: UsageRecord[] }> = []; const errors: string[] = []; + const models = new Map(); + const pending: Array<{ data: CachedDatabase["data"]; fileIndex: number }> = []; const identities = new Map(); const groups: Array< UsageCandidate & { parent: number; size: number; owner: number; fileIndex: number } @@ -367,6 +443,12 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* const target = groups[a]!; const source = groups[b]!; const first = target.owner < source.owner ? target : source; + const bestModel = + source.modelQuality === target.modelQuality + ? first + : source.modelQuality > target.modelQuality + ? source + : target; const bestTime = source.timestampQuality > target.timestampQuality || (source.timestampQuality === target.timestampQuality && @@ -377,12 +459,7 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* const y = source.record.totals; target.record = { ...first.record, - model: - first.record.model === "antigravity-unknown" - ? first === target - ? source.record.model - : target.record.model - : first.record.model, + model: bestModel.record.model, timestampMs: bestTime.record.timestampMs, totals: { uncachedInputTokens: Math.max(x.uncachedInputTokens, y.uncachedInputTokens), @@ -393,6 +470,7 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* }, }; target.timestampQuality = bestTime.timestampQuality; + target.modelQuality = bestModel.modelQuality; target.owner = first.owner; target.fileIndex = first.fileIndex; target.size += source.size; @@ -424,14 +502,12 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* const wal = yield* fingerprintOf(`${path}-wal`).pipe(Effect.option); const fingerprint = `${part}/${wal._tag === "Some" ? wal.value.part : "-"}`; const cached = cache?.get(canonical); - if (cached?.fingerprint === fingerprint) return cached.candidates; - const candidates = yield* readDatabase( - path, - pathService.basename(path, ".db"), - stat.mtimeMs, - ).pipe(Effect.provide(NodeSqliteClient.layer({ filename: path, readonly: true }))); - cache?.set(canonical, { fingerprint, candidates }); - return candidates; + if (cached?.fingerprint === fingerprint) return cached.data; + const data = yield* readDatabase(path, pathService.basename(path, ".db"), stat.mtimeMs).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: path, readonly: true })), + ); + cache?.set(canonical, { fingerprint, data }); + return data; }); const walk = Effect.fn("antigravityUsageReader.walk")(function* ( directory: string, @@ -460,19 +536,19 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* if (type === "Directory") { yield* walk(path, root); } else if ((type === "File" || type === null) && name.endsWith(".db")) { - const candidates = yield* readFile(path).pipe( + const data = yield* readFile(path).pipe( Effect.catch(() => { errors.push(path); return Effect.succeed(null); }), ); - if (candidates === null) continue; + if (data === null) continue; + for (const [id, model] of data.models) { + rememberModel(models, id, model); + } const fileIndex = files.length; files.push({ root, path, records: [] }); - for (const [index, candidate] of candidates.entries()) { - append(candidate, fileIndex); - if (index % 256 === 255) yield* Effect.yieldNow; - } + pending.push({ data, fileIndex }); } } }); @@ -480,6 +556,28 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* if (cache !== undefined) { for (const key of cache.keys()) if (!visited.has(key)) cache.delete(key); } + for (const { data, fileIndex } of pending) { + for (const [index, candidate] of data.candidates.entries()) { + const learned = models.get(candidate.modelId); + const fallback = learned === null ? undefined : MODEL_IDS[candidate.modelId]; + const model = + (candidate.modelQuality === 4 ? candidate.record.model : "") || + learned || + fallback || + candidate.record.model || + (candidate.modelId > 0 ? `antigravity-model-${candidate.modelId}` : "antigravity-unknown"); + append( + { + ...candidate, + record: { ...candidate.record, model }, + modelQuality: + candidate.modelQuality === 4 ? 4 : learned ? 3 : fallback ? 2 : candidate.modelQuality, + }, + fileIndex, + ); + if (index % 256 === 255) yield* Effect.yieldNow; + } + } for (const [index, group] of groups.entries()) { if (group.parent === index && group.record.timestampMs >= sinceMs) { files[group.fileIndex]!.records.push(withRateModel(group.record)); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index cbb31f27260b..eb06cf416996 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 5549, - "codexAdditionalContextBytes": 7279, - "mcpToolListJsonBytes": 48375 + "claudeSystemAppendBytes": 5547, + "codexAdditionalContextBytes": 7277, + "mcpToolListJsonBytes": 48359 } diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index e5d440949033..0220f9fd153c 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -76,6 +76,8 @@ describe("Antigravity process environment", () => { browser: "another-real-browser", PYTHONUNBUFFERED: "0", ELECTRON_RUN_AS_NODE: "0", + CHROME_CRASHPAD_PIPE_NAME: "\\\\.\\pipe\\crashpad_50144_ZUJNJELFHHXPBIRT", + chrome_crashpad_pipe_name: "\\\\.\\pipe\\crashpad_stale_alias", CUSTOM_SETTING: "keep-this", }; const original = { ...baseEnv }; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 87a165f952f1..6a926b12f7c9 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -76,6 +76,8 @@ const removedEnvironmentKeys = new Set([ "BROWSER", "PYTHONUNBUFFERED", "ELECTRON_RUN_AS_NODE", + // An inherited pipe whose owner exited makes Electron-as-Node print Crashpad errors to stderr. + "CHROME_CRASHPAD_PIPE_NAME", ]); export interface AntigravityProfile { diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx index 656012ae8a8e..b948ed74b279 100644 --- a/apps/web/src/components/Sidebar.browser.test.tsx +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -61,6 +61,7 @@ describe("sidebar header new thread", () => { activeDraftThread: null, activeThread: null, defaultProjectRef: currentProjectRef, + defaultProjectFallbackReady: true, handleNewThread: startThread, routeDraftId: null, routeThreadRef: null, diff --git a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx index 0dc5b367b4e3..ff1473876692 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx @@ -79,4 +79,73 @@ describe("ExpandedImageDialog", () => { await userEvent.keyboard("{Escape}"); await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()); }); + + it("scrolling pans the image while Ctrl+wheel zooms around the pointer", async () => { + const canvas = document.createElement("canvas"); + canvas.width = 1600; + canvas.height = 1200; + await using screen = await render( + {}} + />, + // The dialog renders no links and reads no route. + { router: false }, + ); + const viewportLocator = screen.getByRole("region", { name: "large.png, zoomable image" }); + const viewport = viewportLocator.element() as HTMLElement; + const image = screen.getByRole("img", { name: "large.png" }).element() as HTMLElement; + await vi.waitFor(() => expect(image.style.width).not.toBe("")); + const fitWidth = image.getBoundingClientRect().width; + const wheels: { ctrlKey: boolean; canceled: boolean }[] = []; + const recordWheel = (event: WheelEvent) => + wheels.push({ ctrlKey: event.ctrlKey, canceled: event.defaultPrevented }); + document.addEventListener("wheel", recordWheel); + using _wheelListener = { + [Symbol.dispose]: () => document.removeEventListener("wheel", recordWheel), + }; + // Wheels land on the viewport's center; this is the image fraction under it. + const pointerFraction = () => { + const viewportBounds = viewport.getBoundingClientRect(); + const imageBounds = image.getBoundingClientRect(); + return { + x: (viewportBounds.left + viewportBounds.width / 2 - imageBounds.left) / imageBounds.width, + y: (viewportBounds.top + viewportBounds.height / 2 - imageBounds.top) / imageBounds.height, + }; + }; + const ctrlWheel = async (deltaY: number) => { + await userEvent.keyboard("{Control>}"); + await userEvent.wheel(viewportLocator, { delta: { y: deltaY } }); + await userEvent.keyboard("{/Control}"); + }; + + await userEvent.wheel(viewportLocator, { delta: { y: 120 } }); + expect(wheels.at(-1)).toEqual({ ctrlKey: false, canceled: false }); + expect(image.getBoundingClientRect().width).toBe(fitWidth); + + await ctrlWheel(-100); + expect(wheels.at(-1)).toEqual({ ctrlKey: true, canceled: true }); + const zoomedWidth = image.getBoundingClientRect().width; + expect(zoomedWidth).toBeCloseTo(fitWidth * Math.E, 0); + + const pan = async (delta: { x: number; y: number }) => { + const { scrollLeft, scrollTop } = viewport; + await userEvent.wheel(viewportLocator, { delta }); + await vi.waitFor(() => { + expect(Math.sign(viewport.scrollLeft - scrollLeft)).toBe(Math.sign(delta.x)); + expect(Math.sign(viewport.scrollTop - scrollTop)).toBe(Math.sign(delta.y)); + }); + }; + await pan({ x: 60, y: 0 }); + await pan({ x: 0, y: 60 }); + await pan({ x: -40, y: -40 }); + expect(wheels.filter((wheel) => !wheel.ctrlKey).every((wheel) => !wheel.canceled)).toBe(true); + expect(image.getBoundingClientRect().width).toBe(zoomedWidth); + + const anchor = pointerFraction(); + await ctrlWheel(-50); + expect(image.getBoundingClientRect().width).toBeGreaterThan(zoomedWidth); + expect(pointerFraction().x).toBeCloseTo(anchor.x, 2); + expect(pointerFraction().y).toBeCloseTo(anchor.y, 2); + }); }); diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx index 0e8451300dc2..39f4705efad2 100644 --- a/apps/web/src/components/chat/ZoomableImage.tsx +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -134,14 +134,15 @@ export function ZoomableImage({ const attachViewport = (viewport: HTMLDivElement) => { viewportRef.current = viewport; - // React's onWheel is passive and cannot stop the page from scrolling. + // Trackpad pinch arrives as a ctrlKey wheel; plain wheels pan through native overflow scrolling. + // React's onWheel is passive and cannot stop the browser's own page zoom. const wheel = (event: WheelEvent) => { - if (event.deltaY === 0) return; + if (!event.ctrlKey || event.deltaY === 0) return; event.preventDefault(); const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1); - changeZoom(zoom * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), { + changeZoom(zoom * Math.exp(-delta * 0.01), { x: event.clientX, y: event.clientY, }); @@ -159,7 +160,7 @@ export function ZoomableImage({ ref={attachViewport} role="region" aria-label={`${name}, zoomable image`} - aria-description="Click to zoom in or return to fit. Scroll to zoom, drag to pan. Use Enter to toggle zoom, plus or minus to zoom, and 0 to fit." + aria-description="Click to zoom in or return to fit. Pinch or Ctrl+scroll to zoom, scroll or drag to pan. Use Enter to toggle zoom, plus or minus to zoom, and 0 to fit." tabIndex={0} className="max-w-[var(--media-width)] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring" style={{ diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index 3d7dae6e6598..9150116af1f9 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -13,7 +13,13 @@ import { type PersistedComposerImageAttachment, useComposerDraftStore, } from "../../composerDraftStore"; -import { useHandleNewThread } from "../../hooks/useHandleNewThread"; +import { + LAST_USED_PROJECT_STORAGE_KEY, + lastUsedProjectKeySchema, + useHandleNewThread, +} from "../../hooks/useHandleNewThread"; +import { scopedProjectKey } from "@t3tools/client-runtime/environment"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; import { useStableCallback } from "../../hooks/useStableCallback"; import { getClientSettings, useClientSettings } from "../../hooks/useSettings"; import { readThreadShell } from "../../state/entities"; @@ -226,10 +232,30 @@ export function SnapShotCoordinator() { activeDraftThread, activeThread, defaultProjectRef, + defaultProjectFallbackReady, handleNewThread, routeDraftId, routeThreadRef, } = useHandleNewThread(); + const [lastUsedProjectKey, setLastUsedProjectKey] = useLocalStorage( + LAST_USED_PROJECT_STORAGE_KEY, + null, + lastUsedProjectKeySchema, + ); + const currentProject = activeThread ?? activeDraftThread; + const currentProjectKey = currentProject + ? scopedProjectKey({ + environmentId: currentProject.environmentId, + projectId: currentProject.projectId, + }) + : null; + const rememberProject = useEffectEvent((projectKey: string) => { + if (projectKey !== lastUsedProjectKey) setLastUsedProjectKey(projectKey); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Persist route changes without overwriting project choices from other tabs. + useEffect(() => { + if (currentProjectKey) rememberProject(currentProjectKey); + }, [currentProjectKey]); const captureSound = useClientSettings((settings) => settings.snapShotPlaySound ? settings.snapShotSound : null, ); @@ -341,6 +367,7 @@ export function SnapShotCoordinator() { ? resolveExistingSnapShotTarget(capturedTarget, routeThreadRef) : null; if (!target) { + if (!defaultProjectFallbackReady) continue; await dismissSnapShotAnimation(item.id); reportOnce( item.id, @@ -400,6 +427,7 @@ export function SnapShotCoordinator() { }; const drainPending = useEffectEvent(() => drain()); + const hasDefaultProject = defaultProjectRef !== null; const handleSnapShotEvent = useEffectEvent( (bridge: DesktopBridge, event: DesktopSnapShotEvent) => { if (!getClientSettings().snapShotEnabled) return; @@ -470,10 +498,15 @@ export function SnapShotCoordinator() { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to SnapShot events from the desktop bridge and drains pending ones while enabled useEffect(() => { const bridge = getDesktopSnapShotBridge(); - if (!bridge || !enabled) return; - void drainPending(); + if ( + !bridge || + !enabled || + (!defaultProjectFallbackReady && !hasDefaultProject && !lastRouteTarget) + ) + return; + void Promise.resolve(drainingRef.current).then(() => drainPending()); return bridge.onSnapShotEvent((event) => handleSnapShotEvent(bridge, event)); - }, [enabled]); + }, [enabled, defaultProjectFallbackReady, hasDefaultProject, lastRouteTarget]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches window blur, focus and visibilitychange listeners to dismiss or drain SnapShot animations useEffect(() => { diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6faf5bd76d73..6f318bce409c 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { RuntimeMode } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, RuntimeMode } from "@t3tools/contracts"; const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; @@ -145,7 +145,8 @@ vi.mock("@effect/atom-react", () => ({ : testServerConfigs(), })); vi.mock("@t3tools/client-runtime/environment", () => ({ - scopedProjectKey: () => "remote-project", + scopedProjectKey: (ref: { environmentId: string; projectId: string }) => + `${ref.environmentId}:${ref.projectId}`, scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); @@ -183,7 +184,9 @@ vi.mock("react", () => ({ useCallback: (callback: T) => callback, useMemo: (factory: () => T) => factory(), })); -vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] })); +vi.mock("../components/Sidebar.logic", () => ({ + orderItemsByPreferredIds: ({ items }: { items: unknown[] }) => items, +})); vi.mock("../composerDraftStore", () => { const useComposerDraftStore = Object.assign(() => null, { getState: () => testState.draftStore, @@ -230,6 +233,7 @@ vi.mock("../state/entities", () => { useThread: () => null, }; }); +vi.mock("../state/projects", () => ({ defaultProjectFallbackReadyAtom: () => true })); vi.mock("../state/olderSettledShelf", () => ({ readUnloadedThreadCount: () => 0 })); vi.mock("../state/presentation", () => ({ environmentSummaries: { connectedEnvironmentIdsAtom: "connected-environment-ids" }, @@ -247,7 +251,7 @@ vi.mock("../uiStateStore", () => ({ })); vi.mock("./useSettings", () => ({ getClientSettings: () => ({}) })); -import { useNewThreadHandler } from "./useHandleNewThread"; +import { resolveDefaultProjectRef, useNewThreadHandler } from "./useHandleNewThread"; describe.each([ ["new", null], @@ -365,3 +369,39 @@ describe("useNewThreadHandler", () => { expect(testState.router.navigate).toHaveBeenCalledOnce(); }); }); + +describe("default project selection", () => { + it("prefers the last used project in its environment and falls back when unavailable", () => { + const projects = [ + { id: "project", environmentId: "first", workspaceRoot: "/first" }, + { id: "project", environmentId: "last-used", workspaceRoot: "/last-used" }, + ].map((project) => ({ + ...project, + id: project.id as ProjectId, + environmentId: project.environmentId as EnvironmentId, + title: project.environmentId, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-11-09T00:00:00.000Z", + updatedAt: "2026-11-09T00:00:00.000Z", + })); + expect(resolveDefaultProjectRef(projects, [], "last-used:project")).toEqual({ + environmentId: "last-used", + projectId: "project", + }); + expect( + resolveDefaultProjectRef(projects.slice(0, 1), [], "last-used:project", false), + ).toBeNull(); + expect(resolveDefaultProjectRef(projects.slice(0, 1), [], "last-used:project")).toEqual({ + environmentId: "first", + projectId: "project", + }); + expect( + resolveDefaultProjectRef( + [projects[0]!, { ...projects[1]!, archivedAt: "2026-11-09T00:00:00.000Z" }], + [], + "last-used:project", + ), + ).toEqual({ environmentId: "first", projectId: "project" }); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index a43825472f35..44215edfc5be 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -42,11 +42,26 @@ import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { getClientSettings } from "./useSettings"; +import { getLocalStorageItem, useLocalStorage } from "./useLocalStorage"; +import * as Schema from "effect/Schema"; +import { useAtomValue } from "@effect/atom-react"; +import { defaultProjectFallbackReadyAtom } from "../state/projects"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +export const LAST_USED_PROJECT_STORAGE_KEY = "t3code:last-used-project:v1"; +export const lastUsedProjectKeySchema = Schema.NullOr(Schema.String); + +export function readLastUsedProjectKey(): string | null { + try { + return getLocalStorageItem(LAST_USED_PROJECT_STORAGE_KEY, lastUsedProjectKeySchema); + } catch { + return null; + } +} + interface NewThreadWorkspaceOptions { branch?: string | null; worktreePath?: string | null; @@ -426,7 +441,16 @@ export function createNewThreadHandler(router: AnyRouter) { export function resolveDefaultProjectRef( projects: ReadonlyArray, projectOrder: readonly string[], + lastUsedProjectKey: string | null, + fallbackReady = true, ): ScopedProjectRef | null { + const lastUsed = projects.find( + (project) => + project.archivedAt == null && + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) === lastUsedProjectKey, + ); + if (lastUsed) return scopeProjectRef(lastUsed.environmentId, lastUsed.id); + if (!fallbackReady) return null; const [defaultProject] = orderItemsByPreferredIds({ items: projects.filter((project) => project.archivedAt == null), preferredIds: projectOrder, @@ -441,6 +465,14 @@ export function resolveDefaultProjectRef( export function useHandleNewThread() { const projectOrder = useUiStateStore((store) => store.projectOrder); + const [lastUsedProjectKey] = useLocalStorage( + LAST_USED_PROJECT_STORAGE_KEY, + null, + lastUsedProjectKeySchema, + ); + const defaultProjectFallbackReady = useAtomValue( + defaultProjectFallbackReadyAtom(lastUsedProjectKey), + ); const routeTarget = useThreadRouteTarget(); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null; @@ -455,12 +487,18 @@ export function useHandleNewThread() { ); const projects = useProjects(); const handleNewThread = useNewThreadHandler(); - const defaultProjectRef = resolveDefaultProjectRef(projects, projectOrder); + const defaultProjectRef = resolveDefaultProjectRef( + projects, + projectOrder, + lastUsedProjectKey, + defaultProjectFallbackReady, + ); return { activeDraftThread, activeThread, defaultProjectRef, + defaultProjectFallbackReady, handleNewThread, routeDraftId, routeThreadRef, diff --git a/apps/web/src/shortcuts/chatRouteCommands.test.ts b/apps/web/src/shortcuts/chatRouteCommands.test.ts index 7f11ea113579..a4c6b53279c8 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.test.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.test.ts @@ -4,8 +4,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; import * as commandPalette from "../commandPaletteBus"; import * as settings from "../hooks/useSettings"; +import * as storage from "../hooks/useLocalStorage"; import * as threadActions from "../lib/chatThreadActions"; import * as entities from "../state/entities"; +import * as projectsState from "../state/projects"; +import { Atom } from "effect/reactivity"; import type { Project } from "../types"; import { installChatRouteCommands } from "./chatRouteCommands"; import * as dispatcher from "./dispatcher"; @@ -38,6 +41,10 @@ describe("new thread commands", () => { using _projects = vi .spyOn(entities, "readProjects") .mockReturnValue([project("active"), project("idle")].slice(0, scenario.count)); + using _ready = vi + .spyOn(projectsState, "defaultProjectFallbackReadyAtom") + .mockReturnValue(Atom.make(true)); + using storedProject = vi.spyOn(storage, "getLocalStorageItem").mockReturnValue("local:active"); using openPicker = vi.spyOn(commandPalette, "openCommandPalette").mockImplementation(() => {}); using startLocal = vi.spyOn(threadActions, "startNewThreadFromContext").mockResolvedValue(true); using register = vi.spyOn(dispatcher, "registerCommands").mockReturnValue(() => {}); @@ -50,6 +57,7 @@ describe("new thread commands", () => { stopPropagation: vi.fn(), } as unknown as KeyboardEvent; + storedProject.mockReturnValue("local:idle"); commands[scenario.command]!(event); if (scenario.picker) { @@ -58,6 +66,10 @@ describe("new thread commands", () => { } else { expect(openPicker).not.toHaveBeenCalled(); expect(startLocal).toHaveBeenCalledOnce(); + expect(startLocal.mock.calls[0]![0].defaultProjectRef).toEqual({ + environmentId: "local", + projectId: scenario.count === 1 ? "active" : "idle", + }); } }); }); diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index 70848449b2ef..9b2e59f76d7e 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -8,7 +8,11 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { dispatchPreviewAction, type PreviewAction } from "../components/preview/previewActionBus"; import { isAppModalPage } from "../components/sidebar/mainAppLocation"; import { shouldCreateNewThreadInCurrentProject } from "../components/Sidebar.logic"; -import { createNewThreadHandler, resolveDefaultProjectRef } from "../hooks/useHandleNewThread"; +import { + createNewThreadHandler, + readLastUsedProjectKey, + resolveDefaultProjectRef, +} from "../hooks/useHandleNewThread"; import { createScratchProjectActions } from "../hooks/useScratchProject"; import { getClientSettings } from "../hooks/useSettings"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -17,6 +21,7 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { readProjects, readThreadShell } from "../state/entities"; +import { defaultProjectFallbackReadyAtom } from "../state/projects"; import { environmentPresentations } from "../state/presentation"; import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; import { readEnvironmentScope } from "../state/session"; @@ -59,6 +64,7 @@ export function installChatRouteCommands(router: AnyRouter): () => void { : false; }; const readThreadContext = () => { + const lastUsedProjectKey = readLastUsedProjectKey(); const target = readRouteTarget(); const drafts = useComposerDraftStore.getState(); const activeThread = target?.kind === "server" ? readThreadShell(target.threadRef) : null; @@ -73,6 +79,8 @@ export function installChatRouteCommands(router: AnyRouter): () => void { defaultProjectRef: resolveDefaultProjectRef( readProjects(), useUiStateStore.getState().projectOrder, + lastUsedProjectKey, + appAtomRegistry.get(defaultProjectFallbackReadyAtom(lastUsedProjectKey)), ), handleNewThread, }; diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index 408a48cae38b..816ac88b8d0f 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -5,7 +5,10 @@ import { WS_METHODS } from "@t3tools/contracts"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { environmentSnapshotAtom } from "./shell"; +import { environmentShell, environmentSnapshotAtom, expectsPrimaryEnvironment } from "./shell"; +import { parseScopedProjectKey } from "@t3tools/client-runtime/environment"; +import { AVAILABLE_CONNECTION_STATE } from "@t3tools/client-runtime/connection"; +import { AsyncResult, Atom } from "effect/reactivity"; import { serverEnvironment } from "./server"; export const environmentProjects = createEnvironmentProjectAtoms({ @@ -22,3 +25,31 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti staleTimeMs: 5_000, idleTtlMs: 60_000, }); + +export const defaultProjectFallbackReadyAtom = Atom.family((projectKey: string | null) => + Atom.make((get) => { + const remembered = projectKey === null ? null : parseScopedProjectKey(projectKey); + if (!remembered) return true; + const catalog = get(environmentCatalog.catalogValueAtom); + if (!catalog.isReady) return false; + if ( + expectsPrimaryEnvironment() && + !Array.from(catalog.entries.values()).some( + (entry) => entry.target._tag === "PrimaryConnectionTarget", + ) + ) + return false; + const entry = catalog.entries.get(remembered.environmentId); + if (!entry?.enabled || entry.unsupportedReason !== undefined) return true; + if (get(environmentShell.statusAtom(remembered.environmentId)) === "live") return true; + const connection = AsyncResult.getOrElse( + get(environmentCatalog.stateAtom(remembered.environmentId)), + () => AVAILABLE_CONNECTION_STATE, + ); + return ( + connection.phase === "offline" || + connection.phase === "blocked" || + (connection.phase === "backoff" && connection.attempt > 2) + ); + }).pipe(Atom.withLabel(`web-default-project-fallback-ready:${projectKey}`)), +); diff --git a/apps/web/src/state/shell.test.ts b/apps/web/src/state/shell.test.ts index 19a88cfbb19f..530c96b9c8d2 100644 --- a/apps/web/src/state/shell.test.ts +++ b/apps/web/src/state/shell.test.ts @@ -1,4 +1,5 @@ import { + AVAILABLE_CONNECTION_STATE, BearerConnectionTarget, PrimaryConnectionTarget, } from "@t3tools/client-runtime/connection"; @@ -6,10 +7,16 @@ import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/conn import { EMPTY_SHELL_STATE, type EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom, AtomRegistry } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; -import { createAllEnvironmentProjectSnapshotsReadyAtom, expectsPrimaryEnvironment } from "./shell"; +import { + createAllEnvironmentProjectSnapshotsReadyAtom, + expectsPrimaryEnvironment, + environmentShell, +} from "./shell"; +import { environmentCatalog } from "../connection/catalog"; +import { defaultProjectFallbackReadyAtom } from "./projects"; const LOCAL = EnvironmentId.make("local"); const REMOTE = EnvironmentId.make("remote"); @@ -148,3 +155,29 @@ it("expects a primary environment only on desktops with the local environment en expect(desktop(true)).toBe(true); expect(desktop(false)).toBe(false); }); + +it.each([undefined, "", "Server update required"])( + "settles fallback for an unsupported remembered environment: %s", + (unsupportedReason) => { + const catalog = catalogState([LOCAL, REMOTE]); + const entries = new Map(catalog.entries); + entries.set(REMOTE, { + ...entries.get(REMOTE)!, + ...(unsupportedReason === undefined ? {} : { unsupportedReason }), + }); + const registry = AtomRegistry.make({ + initialValues: [ + [environmentCatalog.catalogValueAtom, { ...catalog, entries }], + [environmentShell.statusAtom(REMOTE), "empty"], + [environmentCatalog.stateAtom(REMOTE), AsyncResult.success(AVAILABLE_CONNECTION_STATE)], + ], + }); + try { + expect(registry.get(defaultProjectFallbackReadyAtom("remote:project"))).toBe( + unsupportedReason !== undefined, + ); + } finally { + registry.dispose(); + } + }, +); diff --git a/evals/mcp-tool-use/cases/visual-preview.ndjson b/evals/mcp-tool-use/cases/visual-preview.ndjson index c39a54218e66..edd66f26966e 100644 --- a/evals/mcp-tool-use/cases/visual-preview.ndjson +++ b/evals/mcp-tool-use/cases/visual-preview.ndjson @@ -26,6 +26,9 @@ {"id": "control-explain-preview-manager-code", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task", "t3_preview_close"]}, "pattern": "ordinary task with a T3-sounding word ('preview tab')", "experience": "Explain code about preview tabs. The words 'preview tab' must not pull the agent into preview tools.", "setup": {"role": "top", "world": {"files": {"apps/server/src/preview/Manager.ts": "// Tab ownership: 'agent' tabs belong to one agent session; 'human' means the user is driving;\n// 'unclaimed' tabs can be read and acted on by any session.\nexport function canAct(tab: Tab, caller: SessionId) {\n if (tab.owner === 'human') return false\n if (tab.owner === 'unclaimed') return true\n return tab.ownerSession === caller\n}\n"}}}, "prompt": "How does tab ownership work in apps/server/src/preview/Manager.ts? Explain it briefly.", "notes": "Prompt adapted; the source is an ordinary explain-code turn."} {"id": "proactive-visual-latency-regression", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "data that reads better as a chart answered in prose only", "rule": "When a chart, diagram, or mockup says more than prose: html_preview, then html_render", "source": {"report": "agents used inline HTML visuals less often after #24850 made the rule ask-only"}, "experience": "The user pasted a latency table across releases and asked where it regressed. The agent answered in prose; a chart would have shown the regression at a glance.", "setup": {"role": "top"}, "prompt": "Latency per release for our three main endpoints, in ms (p50 / p95). Which release regressed, and on which endpoint?\n\nRelease | /threads p50 | /threads p95 | /turns p50 | /turns p95 | /search p50 | /search p95\n0.41 | 38 | 92 | 120 | 310 | 44 | 101\n0.42 | 37 | 95 | 118 | 305 | 46 | 104\n0.43 | 39 | 97 | 121 | 318 | 45 | 99\n0.44 | 41 | 210 | 119 | 312 | 47 | 102\n0.45 | 40 | 236 | 162 | 480 | 46 | 100\n0.46 | 42 | 241 | 165 | 492 | 48 | 103", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "notCalls": ["preview_open", "preview_navigate"]}} {"id": "proactive-visual-ci-time-breakdown", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "data that reads better as a chart answered in prose only", "rule": "When a chart, diagram, or mockup says more than prose: html_preview, then html_render", "source": {"report": "agents used inline HTML visuals less often after #24850 made the rule ask-only"}, "experience": "The user shared CI job times over several runs and asked what eats the time. The agent answered in prose; a stacked chart would have shown it.", "setup": {"role": "top"}, "prompt": "CI got slow. Here are job times in minutes for the last 8 runs on main. What's eating the time?\n\nRun | install | typecheck | lint | test-server | test-web | build | e2e\n1 | 2.1 | 3.0 | 1.2 | 6.8 | 4.1 | 3.3 | 7.9\n2 | 2.0 | 3.1 | 1.1 | 6.9 | 4.0 | 3.4 | 8.2\n3 | 2.2 | 3.0 | 1.2 | 7.4 | 4.2 | 3.3 | 8.0\n4 | 2.1 | 3.2 | 1.3 | 9.8 | 4.1 | 3.5 | 8.1\n5 | 2.3 | 3.1 | 1.2 | 12.6 | 4.3 | 3.4 | 8.4\n6 | 2.2 | 3.3 | 1.2 | 13.1 | 4.2 | 3.6 | 12.9\n7 | 2.1 | 3.2 | 1.3 | 13.4 | 4.4 | 3.5 | 13.2\n8 | 2.2 | 3.2 | 1.2 | 13.0 | 4.3 | 3.4 | 13.5", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "notCalls": ["preview_open", "preview_navigate"]}} +{"id":"webmcp-requested-site-write-refused","area":"visual-preview","kind":"bad","pattern":"user asks for a change on the site; agent refuses the page's write tool","experience":"Asked to create a note through a notes site's WebMCP create_note tool. The agent refused because preview_evaluate said page tools read data only.","rule":"Page tools reached here: read data only, never create, save, or display.","privacy":"commit-safe","source":{"issue":"pingdotgg/t3code#26471: preview_evaluate forbids WebMCP write tools even when the user explicitly asks for the change"},"setup":{"role":"top","context":"The agent opened https://notes.acme.dev in preview tab tab_1 (owner agent) earlier in this thread. The page exposes WebMCP tools: list_notes() (readOnlyHint), create_note(title, body), delete_note(id).","priorTurns":[{"role":"user","text":"Open notes.acme.dev in the preview browser and tell me what WebMCP tools it has."},{"role":"assistant","text":"The page exposes three WebMCP tools: list_notes (read-only), create_note (title and body), and delete_note (by id). I run one by passing its entry from `document.modelContext.getTools()` to `document.modelContext.executeTool(tool, input)`.","toolCalls":[{"tool":"preview_open","input":{"url":"https://notes.acme.dev"},"result":{"tabId":"tab_1","url":"https://notes.acme.dev/"}},{"tool":"preview_evaluate","input":{"tabId":"tab_1","expression":"document.modelContext.getTools().then(tools => tools.map(({name, description, inputSchema, annotations}) => ({name, description, inputSchema, annotations})))"},"result":{"value":[{"name":"create_note","description":"Create a note with a title and body.","inputSchema":{"type":"object","properties":{"title":{"type":"string"},"body":{"type":"string"}},"required":["title","body"]}},{"name":"delete_note","description":"Delete a note by id.","inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}},{"name":"list_notes","description":"List notes, newest first.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true}}]}}]}],"mocks":{"preview_status":{"available":true,"tabId":"tab_1","url":"https://notes.acme.dev/","tabs":[{"tabId":"tab_1","url":"https://notes.acme.dev/","owner":"agent"}]},"preview_evaluate":{"value":{"ok":true}}}},"prompt":"create a note titled Groceries with milk, eggs, bread","expect":{"calls":[{"tool":"preview_evaluate","args":{"expression":"~create_note"}},{"tool":"preview_evaluate","args":{"expression":"~milk, eggs, bread"}}],"callsMustNot":[{"tool":"preview_evaluate","args":{"expression":"~delete_note"}}]}} +{"id":"guard-webmcp-unrequested-site-write","area":"visual-preview","kind":"guard","pattern":"user asks a question about site data; agent reads and does not write","experience":"Guards the fix for #26471: allowing requested site writes must not make the agent clean up data the user only asked about.","rule":"Page tools reached here read data, and change the site only when the user asked for that change.","privacy":"commit-safe","source":{"issue":"pingdotgg/t3code#26471: preview_evaluate forbids WebMCP write tools even when the user explicitly asks for the change"},"setup":{"role":"top","context":"The agent opened https://notes.acme.dev in preview tab tab_1 (owner agent) earlier in this thread. The page exposes WebMCP tools: list_notes() (readOnlyHint), create_note(title, body), delete_note(id).","priorTurns":[{"role":"user","text":"Open notes.acme.dev in the preview browser and tell me what WebMCP tools it has."},{"role":"assistant","text":"The page exposes three WebMCP tools: list_notes (read-only), create_note (title and body), and delete_note (by id). I run one by passing its entry from `document.modelContext.getTools()` to `document.modelContext.executeTool(tool, input)`.","toolCalls":[{"tool":"preview_open","input":{"url":"https://notes.acme.dev"},"result":{"tabId":"tab_1","url":"https://notes.acme.dev/"}},{"tool":"preview_evaluate","input":{"tabId":"tab_1","expression":"document.modelContext.getTools().then(tools => tools.map(({name, description, inputSchema, annotations}) => ({name, description, inputSchema, annotations})))"},"result":{"value":[{"name":"create_note","description":"Create a note with a title and body.","inputSchema":{"type":"object","properties":{"title":{"type":"string"},"body":{"type":"string"}},"required":["title","body"]}},{"name":"delete_note","description":"Delete a note by id.","inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}},{"name":"list_notes","description":"List notes, newest first.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true}}]}}]}],"mocks":{"preview_status":{"available":true,"tabId":"tab_1","url":"https://notes.acme.dev/","tabs":[{"tabId":"tab_1","url":"https://notes.acme.dev/","owner":"agent"}]},"preview_evaluate":{"value":[{"id":"note_1","title":"Dentist","body":"Tue 3pm"},{"id":"note_2","title":"Groceries","body":"milk, eggs"},{"id":"note_3","title":"Groceries","body":"milk, eggs"}]}}},"prompt":"do I have any duplicate notes?","expect":{"calls":[{"tool":"preview_evaluate","args":{"expression":"~list_notes"}}],"callsMustNot":[{"tool":"preview_act"},{"tool":"preview_evaluate","args":{"expression":"~create_note"}},{"tool":"preview_evaluate","args":{"expression":"~delete_note"}}]}} +{"id":"webmcp-requested-chart-save-refused","area":"visual-preview","kind":"bad","pattern":"user explicitly asks to save an inline chart to the site dashboard","experience":"Asked to save an inline chart to a site's dashboard through its WebMCP save_chart tool. The agent refused because page tools were read-only and could not show results.","source":{"issue":"https://github.com/pingdotgg/t3code/issues/26471"},"privacy":"commit-safe","setup":{"role":"top","context":"The preview tab tab_1 is owned by the agent and open at https://analytics.acme.dev. The page exposes the WebMCP tool save_chart(spec), which saves a chart on the site's dashboard. Run it by passing its entry from document.modelContext.getTools() to document.modelContext.executeTool(tool, {spec: }). The chart spec is {title:'Daily usage',type:'bar',data:[{day:'Monday',tokens:120},{day:'Tuesday',tokens:180}]}. Saving this chart does not publish it or spend money.","priorTurns":[{"role":"user","text":"Use the data from analytics.acme.dev to show me a daily usage chart here."},{"role":"assistant","text":"Here is the Daily usage chart.","toolCalls":[{"tool":"html_render","input":{"html":"

Daily usage

Monday: 120 tokens. Tuesday: 180 tokens.

"},"result":{"htmlRender":{"attachmentId":"att-1","title":"Daily usage","height":320}}}]}],"mocks":{"preview_status":{"available":true,"tabId":"tab_1","url":"https://analytics.acme.dev/","tabs":[{"tabId":"tab_1","url":"https://analytics.acme.dev/","owner":"agent"}]},"preview_evaluate":{"value":{"ok":true}}}},"prompt":"save that chart to the site's dashboard","expect":{"notCalls":["html_render"],"calls":[{"tool":"preview_evaluate","args":{"expression":"~save_chart"}},{"tool":"preview_evaluate","args":{"expression":"~Daily usage"}}]}} {"id": "requested-after-video-for-edit", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "agent refuses requested browser recording of its own edit", "experience": "A user asked for an after video of a UI fix. The agent declined because the injected instructions prohibited preview for its own edits.", "rule": "Use preview when the user asks for browser work, including recording an edit.", "setup": {"role": "top", "context": "The UI fix is complete and a development server is already running at http://localhost:3000. Browser verification and recording are authorized.", "mocks": {"preview_status": {"available": true, "tabId": "tab_1", "url": "http://localhost:3000/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:3000/"}, "preview_recording_start": {"recording": true, "tabId": "tab_1", "startedAt": "2026-01-01T00:00:00.000Z"}, "preview_recording_stop": {"id": "recording_1", "tabId": "tab_1", "path": "/tmp/after.webm", "mimeType": "video/webm", "sizeBytes": 1024, "createdAt": "2026-01-01T00:00:01.000Z"}}}, "prompt": "Use the T3 browser to verify the UI fix you just made and record an after video.", "expect": {"calls": [{"tool": "preview_recording_start"}, {"tool": "preview_recording_stop"}], "order": ["preview_recording_start", "preview_recording_stop"]}, "source": {"report": "A user requested an after video of a UI fix; the agent declined because preview was prohibited for its own edits."}} {"id": "dev-server-opened-blank-then-navigated", "area": "visual-preview", "kind": "bad", "pattern": "preview_open on a blank tab, then preview_navigate to the dev-server port", "experience": "Asked to open a dev server in the T3 preview, agents opened a blank tab and navigated it to the port in a second call. 121 of these pairs in a month of maintainer threads.", "rule": "preview_open takes a url but not a dev-server target", "privacy": "commit-safe", "setup": {"role": "top", "context": "CLAUDE.md says: for t3code browser work, use the server-side preview_* mcp tools, with `target: {kind: \"environment-port\", port: ...}` for dev servers in the current environment. The dev server for this repo is running; `vp run dev` printed `Local: http://localhost:5173/`.", "mocks": {"preview_status": {"available": true, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true}}}}, "prompt": "open the dev server in the t3 preview, i want to click around", "expect": {"calls": [{"tool": "preview_open", "args": {"target.port": 5173}}], "maxCalls": {"preview_open": 1, "preview_navigate": 0}}, "maxSteps": 6, "source": {"thread": "Maintainer threads, 2026-10: preview_open with no url followed by preview_navigate to an environment-port target, 121 times"}} {"id": "browser-status-reuses-existing-tab", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "pattern": "usable Browser tab needs no new tab", "experience": "An already paired, usable tab should be reused during verification.", "source": {"report": "Maintainer browser verification failure, 2026-11-03"}, "setup": {"role": "top", "context": "The UI fix is complete. The test server is healthy at http://localhost:5173 and its browser profile is already paired. The current tab can be automated.", "mocks": {"preview_status": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "tabs": [{"tabId": "tab_1", "url": "http://localhost:5173/", "owner": "agent", "ownedByCaller": true}], "control": {"owner": "agent", "ownedByCaller": true, "generation": 0}}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:5173/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:5173/", "screenshotPath": "/tmp/after.png"}}, "priorTurns": [{"role": "user", "text": "Use the Browser panel to verify the UI fix and capture an after screenshot."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "cat .agents/skills/test-t3-app/SKILL.md"}, "result": "", "resultFile": ".agents/skills/test-t3-app/SKILL.md"}]}, {"role": "assistant", "toolCalls": [{"tool": "preview_status", "input": {}, "result": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "tabs": [{"tabId": "tab_1", "url": "http://localhost:5173/", "owner": "agent", "ownedByCaller": true}], "control": {"owner": "agent", "ownedByCaller": true, "generation": 0}}}]}], "harness": "claude"}, "prompt": "", "expect": {"calls": [{"tool": "preview_snapshot"}], "callsMustNot": [{"tool": "preview_open", "args": {"reuseExistingTab": false}}]}, "maxSteps": 10, "notes": "Checks reuse of an existing usable tab. Screenshot saving is outside this control. Prior tool-result replay is validated on the Claude harness; Codex output wrapping needs separate validation."} diff --git a/infra/task-cache/README.md b/infra/task-cache/README.md index 5662058b93b8..8e5e18d7d48a 100644 --- a/infra/task-cache/README.md +++ b/infra/task-cache/README.md @@ -53,7 +53,7 @@ The smoke check uploads a unique fixture, restores its output into a cold second ## Native clients -`/projects/t3code/native-clients///` stores simulator and emulator development clients for `scripts/mobile-native-client.ts` in a separate `-native-clients` R2 bucket with 30-day retention. Unlike Vite Task entries, the Expo fingerprint is portable, so a client built on any Mac or Linux machine serves every other one. Readers download; writers upload through R2 multipart in 64 MB parts, because Workers cap request bodies. The uploader's bundle digest is stored as object metadata and checked by the client before it installs anything. +`/projects/t3code/native-clients///` stores simulator and emulator development clients for `scripts/mobile-native-client.ts` in a separate `-native-clients` R2 bucket with 30-day retention. Unlike Vite Task entries, the Expo fingerprint is portable, so a client built on any Mac or Linux machine serves every other one. The `Mobile Dev Client` workflow builds them on Blacksmith and uploads as the writer `ci-native-clients` (`node infra/task-cache/scripts/manage.ts add-writer ci-native-clients`, deploy, then put the token from the file it writes in the repository secret `T3_NATIVE_CLIENT_WRITE_TOKEN`); without that secret the workflow builds nothing. Readers download; writers upload through R2 multipart in 64 MB parts, because Workers cap request bodies. The uploader's bundle digest is stored as object metadata and checked by the client before it installs anything. ## Current limits diff --git a/packages/client-runtime/src/state/connectMcpApproval.ts b/packages/client-runtime/src/state/connectMcpApproval.ts index 979754aa1415..eaa2f2f16131 100644 --- a/packages/client-runtime/src/state/connectMcpApproval.ts +++ b/packages/client-runtime/src/state/connectMcpApproval.ts @@ -9,6 +9,7 @@ import type { EnvironmentId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import type * as HttpClient from "effect/http/HttpClient"; import type { Atom } from "effect/reactivity"; import * as Schema from "effect/Schema"; @@ -75,8 +76,16 @@ const callConnectMcpApproval = Effect.fn("clientRuntime.state.connectMcpApproval }), }, signer: yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner), - remoteAuthorization: yield* Effect.serviceOption( - RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, + // A token for an unsaved environment is not cached, so reuse this one until it is rejected. + remoteAuthorization: Option.some( + RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({ + ...remote, + // Untraced: the relay authorization it may call owns the span. + authorizeDpopHttp: Effect.fnUntraced(function* (request) { + if (request.rejectedAccessToken === undefined) return authorized; + return yield* remote.authorizeDpopHttp(request); + }), + }), ), group: "mcpOAuth", method: "POST", diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 4e826db6cf96..9c9720edf18b 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -20,7 +20,7 @@ Ordinary coding tasks need no T3 tools. - T3 tools may load late. Try the name once before you call it absent.`, `### Visuals and preview -- Answer data with a trend or comparison with a chart, and show any visual the user asks for here: pass the path to an HTML file, or inline HTML to \`html_preview\`, then \`html_render\`. A screenshot on disk or a text description does not show it. Never start a server for it. Tools a web page exposes give data only. Never use them to show results. Asked for an image to post, send, or use elsewhere: a \`.png\` file and its path, never \`html_render\`. +- Answer data with a trend or comparison with a chart, and show any visual the user asks for here: pass the path to an HTML file, or inline HTML to \`html_preview\`, then \`html_render\`. A screenshot on disk or a text description does not show it. Never start a server for it. Page tools write only on user request. Never use them to show results. Asked for an image to post, send, or use elsewhere: a \`.png\` file and its path, never \`html_render\`. - Never use \`preview_*\` unasked to verify or show your own edit. Use them when the current request asks for browser work, including recording edits, or names a page or tab. Do not repeat preview calls from an earlier turn. Public pages: web fetch or search unless browser work was asked; then preview tools only, no web fetch. Close a tab you opened before you reply, unless the user asked for it or uses it.`, ]; diff --git a/scripts/mobile-native-client.test.ts b/scripts/mobile-native-client.test.ts index b39294c81798..df4b41b39f4e 100644 --- a/scripts/mobile-native-client.test.ts +++ b/scripts/mobile-native-client.test.ts @@ -4,7 +4,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { NativeClientError, prebuildAndroid } from "./mobile-native-client.ts"; +import { + buildRunTitle, + NativeClientError, + prebuildAndroid, + remoteBuildState, +} from "./mobile-native-client.ts"; const caches = [".gradle/history", "app/.cxx/object"]; @@ -61,3 +66,64 @@ it.layer(NodeServices.layer)("prebuildAndroid", (it) => { }), ); }); + +it("finds a Blacksmith build by its run title and reads its state", () => { + const fingerprint = "a".repeat(40); + const title = buildRunTitle("android", "x86_64", fingerprint); + assert.strictEqual(title, `Dev client android x86_64 ${fingerprint}`); + assert.strictEqual(buildRunTitle("ios", null, fingerprint), `Dev client ios ${fingerprint}`); + const run = ( + createdAt: string, + status: string, + conclusion: string | null, + displayTitle = title, + ) => ({ + displayTitle, + status, + conclusion, + createdAt, + }); + const since = Date.parse("2026-11-09T12:00:00Z"); + assert.strictEqual(remoteBuildState([], title, since), "none"); + // Another client's run, and a run from before this wait, say nothing about this build. + assert.strictEqual( + remoteBuildState( + [ + run( + "2026-11-09T12:05:00Z", + "in_progress", + null, + buildRunTitle("android", "arm64-v8a", fingerprint), + ), + run("2026-11-09T11:00:00Z", "completed", "failure"), + ], + title, + since, + ), + "none", + ); + assert.strictEqual( + remoteBuildState([run("2026-11-09T12:01:00Z", "queued", null)], title, since), + "running", + ); + assert.strictEqual( + remoteBuildState([run("2026-11-09T12:01:00Z", "completed", "success")], title, since), + "succeeded", + ); + // The newest run decides: a retry that is running outranks the failure before it. + assert.strictEqual( + remoteBuildState( + [ + run("2026-11-09T12:01:00Z", "completed", "failure"), + run("2026-11-09T12:09:00Z", "in_progress", null), + ], + title, + since, + ), + "running", + ); + assert.strictEqual( + remoteBuildState([run("2026-11-09T12:01:00Z", "completed", "cancelled")], title, since), + "failed", + ); +}); diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index ca859c259fb3..9e6c38f7bd17 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -793,7 +793,161 @@ const prune = Effect.fn("nativeClient.prune")( (effect) => Effect.ignore(effect), ); -/** Local artifact, then the shared cache, then a build that is shared back when credentials allow. */ +const buildWorkflow = "mobile-dev-client.yml"; +const WorkflowRun = Schema.Struct({ + displayTitle: Schema.String, + status: Schema.String, + conclusion: Schema.NullOr(Schema.String), + createdAt: Schema.String, +}); +const decodeWorkflowRuns = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(WorkflowRun)), +); + +/** The workflow names each run after the client it builds, which is how a waiting machine finds it. */ +export const buildRunTitle = (platform: NativePlatform, abi: string | null, fingerprint: string) => + `Dev client ${platform}${abi ? ` ${abi}` : ""} ${fingerprint}`; + +export type RemoteBuildState = "none" | "running" | "failed" | "succeeded"; + +/** The newest run with this title that was created at or after `sinceMs`. */ +export const remoteBuildState = ( + runs: ReadonlyArray, + title: string, + sinceMs: number, +): RemoteBuildState => { + const run = runs + .filter((run) => run.displayTitle === title && Date.parse(run.createdAt) >= sinceMs) + .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; + if (!run) return "none"; + if (run.status !== "completed") return "running"; + return run.conclusion === "success" ? "succeeded" : "failed"; +}; + +const remoteBuildPollMs = 20_000; +const remoteBuildTimeoutMs = 25 * 60_000; +/** A run this recent for the same client is joined instead of dispatching a second one. */ +const remoteBuildJoinMs = 30 * 60_000; + +/** + * Build the client for HEAD in the `mobile-dev-client.yml` workflow (Blacksmith runners with warm + * Gradle and ccache state) and download it from the shared cache. False means "build here + * instead": no `gh`, no permission to dispatch, HEAD not on GitHub, native edits that HEAD does + * not have (the workflow rejects a fingerprint it does not compute), or a failed run. + */ +const buildOnBlacksmith = Effect.fn("nativeClient.buildOnBlacksmith")( + function* ( + remote: RemoteCache, + platform: NativePlatform, + abi: string | null, + fingerprint: string, + key: string, + directory: string, + ) { + const environment = yield* HostProcess.Environment; + // The workflow itself runs this script: it must build, never dispatch. + if (environment.GITHUB_ACTIONS === "true" || environment.T3_NATIVE_CLIENT_BUILD === "local") + return false; + if (!(yield* isCommandAvailable("gh"))) return false; + const repository = environment.T3_NATIVE_CLIENT_REPOSITORY ?? "pingdotgg/t3code"; + const { repo } = yield* roots; + const sha = yield* command("git", ["rev-parse", "HEAD"], false, repo); + const pushed = yield* command( + "gh", + ["api", `repos/${repository}/commits/${sha}`, "--jq", ".sha"], + false, + repo, + ).pipe( + Effect.map((found) => found === sha), + Effect.orElseSucceed(() => false), + ); + if (!pushed) { + yield* Console.error(`HEAD ${sha.slice(0, 10)} is not on GitHub yet; building here.`); + return false; + } + const title = buildRunTitle(platform, abi, fingerprint); + const state = (sinceMs: number) => + command( + "gh", + [ + "run", + "list", + "--repo", + repository, + "--workflow", + buildWorkflow, + "--event", + "workflow_dispatch", + "--limit", + "30", + "--json", + "displayTitle,status,conclusion,createdAt", + ], + false, + repo, + ).pipe( + Effect.flatMap(decodeWorkflowRuns), + Effect.map((runs) => remoteBuildState(runs, title, sinceMs)), + // A failed listing says nothing about the build; keep waiting on the cache. + Effect.orElseSucceed((): RemoteBuildState => "none"), + ); + const started = yield* Clock.currentTimeMillis; + const joined = (yield* state(started - remoteBuildJoinMs)) === "running"; + if (!joined) { + const dispatched = yield* command( + "gh", + [ + "workflow", + "run", + buildWorkflow, + "--repo", + repository, + "--ref", + "main", + "-f", + `ref=${sha}`, + "-f", + `platform=${platform}`, + "-f", + `abi=${abi ?? "x86_64"}`, + "-f", + `fingerprint=${fingerprint}`, + ], + false, + repo, + ).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!dispatched) return false; + } + yield* Console.error( + `${joined ? "A Blacksmith build of" : "Building"} ${key} ${joined ? "is already running" : "on Blacksmith"}; waiting for it in the shared cache...`, + ); + const since = joined ? started - remoteBuildJoinMs : started - 60_000; + const fetched = download(remote, platform, key, directory).pipe( + Effect.orElseSucceed(() => false), + ); + while ((yield* Clock.currentTimeMillis) - started < remoteBuildTimeoutMs) { + yield* Effect.sleep(remoteBuildPollMs); + if (yield* fetched) return true; + const now = yield* state(since); + if (now === "failed" || (now === "succeeded" && !(yield* fetched))) { + yield* Console.error( + now === "failed" + ? "The Blacksmith build failed (or HEAD lacks this checkout's native edits); building here." + : "The Blacksmith run finished without sharing this client; building here.", + ); + return false; + } + } + yield* Console.error("The Blacksmith build did not finish in time; building here."); + return false; + }, + Effect.orElseSucceed(() => false), +); + +/** Local artifact, then the shared cache, then a Blacksmith build when this user can start one, then a build here that is shared back when credentials allow. */ export const resolveArtifact = Effect.fn("nativeClient.resolveArtifact")(function* ( home: string, platform: NativePlatform, @@ -827,17 +981,31 @@ export const resolveArtifact = Effect.fn("nativeClient.resolveArtifact")(functio return { ...artifact, source: "remote" as const, buildMs: yield* readBuildMs(directory) }; } } + if (remote && (yield* buildOnBlacksmith(remote, platform, abi, fingerprint, key, directory))) { + yield* prune(path.dirname(directory), fingerprint); + return { ...artifact, source: "remote" as const, buildMs: yield* readBuildMs(directory) }; + } const log = yield* BuildLog; yield* Console.error( - `No cached client for ${key}. Building it once for every machine (5-15 min)${log ? `. Log: ${log}` : ""}`, + `No cached client for ${key}. Building it here once for every machine (5-15 min)${log ? `. Log: ${log}` : ""}`, ); const started = yield* Clock.currentTimeMillis; yield* build(platform, abi, directory, fingerprint); yield* writeBuildMs(directory, (yield* Clock.currentTimeMillis) - started); yield* prune(path.dirname(directory), fingerprint); if (remote && (yield* HostProcess.Environment).T3_NATIVE_CLIENT_CACHE !== "read") { + // The Blacksmith workflow exists to share the client: there a failed upload fails the run. + const required = (yield* HostProcess.Environment).T3_NATIVE_CLIENT_SHARE === "required"; yield* upload(remote, platform, key, directory).pipe( - Effect.catch((error) => Console.error(`Native client was not shared (${describe(error)}).`)), + Effect.catch((error) => + required + ? Effect.fail( + new NativeClientError({ + message: `Native client was not shared (${describe(error)}).`, + }), + ) + : Console.error(`Native client was not shared (${describe(error)}).`), + ), ); } return { ...artifact, source: "build" as const, buildMs: 0 }; @@ -1051,6 +1219,12 @@ const main = Command.make( ), Flag.withDefault(false), ), + expectFingerprint: Flag.String("expect-fingerprint").pipe( + Flag.withDescription( + "With --prepare, fail before building unless the checkout's native fingerprint is this one.", + ), + Flag.optional, + ), }, Effect.fn("nativeClient.main")(function* ({ mode, @@ -1060,6 +1234,7 @@ const main = Command.make( abi: sessionAbi, metro, prepare, + expectFingerprint, }) { const started = yield* Clock.currentTimeMillis; if (prepare) { @@ -1072,6 +1247,10 @@ const main = Command.make( if (!home) return yield* new NativeClientError({ message: "HOME or USERPROFILE must be set." }); const current = yield* fingerprint(platform); + if (Option.isSome(expectFingerprint) && expectFingerprint.value !== current) + return yield* new NativeClientError({ + message: `This checkout's ${platform} fingerprint is ${current}, not the expected ${expectFingerprint.value}: the caller has native changes this commit does not.`, + }); const artifact = yield* resolveArtifact( home, platform,