diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 06fe9867b15ae..6fde6fff30984 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -21,6 +21,19 @@ Test with meaningful project and thread data. Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) only when inspecting or seeding SQLite. Stop the test server before direct fixture writes. +## Drive agent turns for free + +Do not spend a paid model to see a turn render. `vp run dev` registers the +`Replay (dev)` provider, which replays recorded turns with simulated streaming +through the real adapter and orchestration. Its models are scenarios: +`showcase` covers thinking, a plan, tool calls, an approval, and markdown. +Press `Ctrl+Alt+Shift+R` for a picker (GNOME takes that key for screen +recording), call `window.t3dev.replay("showcase")` +with `preview_evaluate`, or run +`node apps/server/src/bin.ts thread start --base-dir .t3 --provider devReplay --model showcase "go"`. +When the test needs real provider behavior, use that provider's cheapest +model, such as Haiku for Claude or Luna for Codex. + ## Use the Browser panel Call `preview_status`, then `preview_open` if the Browser panel is diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 06983eaa71fd1..0b7d0182e6df7 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -26,6 +26,11 @@ export default defineConfig((ctx) => ({ path: "apps/web/src/**/*.{tsx,css}", instructions: "Hold changed code to the rules in docs/internals/web-ui.md.", }, + { + path: "**/*.test.*", + instructions: + "Hold changed tests to the rules in docs/internals/testing.md. Request tests only as those rules allow.", + }, ], }, knowledge_base: { @@ -33,6 +38,7 @@ export default defineConfig((ctx) => ({ filePatterns: [ { files: "docs/internals/effect-services.md", applyTo: "{apps,packages,infra}/**/*.ts" }, { files: "docs/internals/web-ui.md", applyTo: "apps/web/src/**/*.{tsx,css}" }, + { files: "docs/internals/testing.md", applyTo: "{apps,packages,infra}/**/*.{ts,tsx}" }, ], }, }, diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e0ef427bd1d36..e9b7ae70a36ad 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -5,6 +5,14 @@ inputs: description: Read-only credential for the private maintainer task cache required: false default: "" + install: + description: Set to "false" to set up Vite+ only, with no pnpm store or install + required: false + default: "true" + install-args: + description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. + required: false + default: "" runs: using: composite steps: @@ -19,10 +27,11 @@ runs: echo "node=$(jq -r '.engines.node' package.json)" >> "$GITHUB_OUTPUT" echo "pnpm=$(jq -r '.packageManager' package.json)" >> "$GITHUB_OUTPUT" + # Pinned because release-desktop.yml signs builds with this action in the job. - name: Cache Vite+ id: vp_cache if: steps.vp.outputs.version != '' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.vite-plus key: vite-plus-home-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }}-node${{ steps.vp.outputs.node }}-${{ steps.vp.outputs.pnpm }} @@ -38,38 +47,93 @@ runs: echo "$HOME/.vite-plus/bin" >> "$GITHUB_PATH" "$HOME/.vite-plus/bin/vp" env use "${{ steps.vp.outputs.node }}" + # vp gives each Node.js download attempt 10 minutes by default, so one + # stalled stream holds the job that long. It retries 3 times and resumes + # partial bytes, so a shorter limit costs a slow download only a retry. - name: Setup Vite+ if: steps.vp_cache.outputs.cache-hit != 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + env: + VP_DOWNLOAD_TIMEOUT: "120" with: node-version-file: package.json cache: false run-install: false - - name: Resolve pnpm store path + # A shared key lets whichever install saves first, often a small filtered + # one, become every job's store. Each set of install args gets its own. + - name: Resolve pnpm store id: pnpm_store + if: inputs.install == 'true' shell: bash + env: + INSTALL_ARGS: ${{ inputs.install-args }} run: | - store_path="$(vp pm cache dir)" - echo "path=$store_path" >> "$GITHUB_OUTPUT" + echo "path=$(vp pm cache dir)" >> "$GITHUB_OUTPUT" + metadata_dir="${pnpm_config_cache_dir:-$RUNNER_TEMP/pnpm-metadata}" + echo "pnpm_config_cache_dir=$metadata_dir" >> "$GITHUB_ENV" + echo "verified=$metadata_dir/lockfile-verified.jsonl" >> "$GITHUB_OUTPUT" + if [[ -z "$INSTALL_ARGS" ]]; then + echo "namespace=full" >> "$GITHUB_OUTPUT" + else + echo "namespace=filtered-$(printf '%s' "$INSTALL_ARGS" | cksum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + fi - # setup-vp's shared cache key ignores install filters. Keep full installs in a - # separate namespace, including fallback restores. + # Filtered installs prefer the current full store, which holds every package, + # over an older store of their own. # The pnpm store stays a cache tarball on the runner's own disk. A store on a # Blacksmith sticky disk mounts in under a second, but pnpm then copies every # package across filesystems instead of hard-linking it: install took 28-49s # against 9s plus an 11s restore. - - name: Cache full pnpm store - uses: actions/cache@v6 + - name: Restore pnpm store + id: pnpm_cache + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.pnpm_store.outputs.path }} - key: pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + key: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} restore-keys: | + pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}- pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}- + # pnpm accepts an earlier pass for the same lockfile and policy at any age, so + # a cached record skips fetching registry metadata for every package. + - name: Restore lockfile verification + id: pnpm_verified + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: pnpm-verified-v1-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + - name: Install dependencies + if: inputs.install == 'true' shell: bash - run: vp install + env: + INSTALL_ARGS: ${{ inputs.install-args }} + # Unquoted so each filter is its own argument. + run: vp install $INSTALL_ARGS + + - name: Save lockfile verification + if: inputs.install == 'true' && steps.pnpm_verified.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} + + # A filtered install that started from the current full store adds nothing + # worth a second copy of it. + - name: Save pnpm store + if: >- + inputs.install == 'true' && + steps.pnpm_cache.outputs.cache-hit != 'true' && + (steps.pnpm_store.outputs.namespace == 'full' || + steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.path }} + key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} - name: Enable shared task cache if: >- diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 13ca38fed4ad6..78a0156d1eaa1 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -11,8 +11,8 @@ provided alongside this playbook. Everything machine-specific lives there, not h ## 1. Ask what went wrong -Your first message to the user: ask them to describe what went wrong, in their own -words. Ask them to paste screenshots directly into this session if they have any. +Unless the user already described it, your first message asks what went wrong, in +their own words. Ask them to paste screenshots directly into this session if they have any. Ask follow-up questions when the description is vague. Good repro steps are the most valuable thing you can extract from this conversation. diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml new file mode 100644 index 0000000000000..9396d8e3e7a63 --- /dev/null +++ b/.github/workflows/agent-context-budget.yml @@ -0,0 +1,55 @@ +name: Agent Context Budget + +# Fails when a PR raises a value in agentContextBudget.json above main's. AgentContextBudget.test.ts +# keeps that file equal to the measured context, so the context cannot grow by editing the file. +# To accept growth on purpose, a maintainer adds the agent-context-increase label. +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +concurrency: + group: agent-context-budget-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + budget: + name: Agent context budget + runs-on: ubuntu-24.04 + timeout-minutes: 5 + env: + BUDGET: apps/server/src/provider/agentContextBudget.json + ALLOW_INCREASE: ${{ contains(github.event.pull_request.labels.*.name, 'agent-context-increase') }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: apps/server/src/provider/agentContextBudget.json + sparse-checkout-cone-mode: false + + - name: Compare with main + run: | + git fetch --quiet --depth=1 origin main + if ! git cat-file -e "FETCH_HEAD:$BUDGET" 2>/dev/null; then + echo "main has no agent context budget yet." + exit 0 + fi + git show "FETCH_HEAD:$BUDGET" > "$RUNNER_TEMP/main.json" + grown=$(jq -r --slurpfile main "$RUNNER_TEMP/main.json" ' + to_entries[] + | select($main[0][.key] != null and .value > $main[0][.key]) + | "\(.key): \($main[0][.key]) -> \(.value) (+\(.value - $main[0][.key]))" + ' "$BUDGET") + if [ -z "$grown" ]; then + echo "Agent context is not larger than main." + exit 0 + fi + printf 'Agent context grew compared to main:\n%s\n' "$grown" + if [ "$ALLOW_INCREASE" = "true" ]; then + echo "Accepted by the agent-context-increase label." + exit 0 + fi + echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text, or ask a maintainer to add the agent-context-increase label." + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e389b524541d..e4c45728c2342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,16 +225,6 @@ jobs: !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present), - # but ACP process-tree live tests compile a small pthread fixture with `cc` - # and soft-skip when it is missing. Install build-essential so that path - # always runs in CI instead of silently no-oping. - - name: Install C toolchain for process-tree fixtures - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends build-essential - command -v cc - - uses: ./.github/actions/setup-apt-mirrors # The desktop tests compile the browser secret helper. The install runs in @@ -344,6 +334,12 @@ jobs: - name: Test run: vp run --filter @t3tools/web test + # Browser tests run source modules; this boots the production chunk graph. + - name: Boot the built web client + run: | + vp run --filter @t3tools/web build + node apps/web/scripts/verify-built-boot.ts + # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of # separate workers, so no two server test files ever share a machine and the @@ -358,7 +354,9 @@ jobs: id-token: write name: Test Server ${{ matrix.shard }} runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 10 + # Shards take 2-5 minutes. When the shared task cache throttles reads, each + # failed fetch stalls vp for 15-90s, which pushed shards past 10 minutes. + timeout-minutes: 20 strategy: fail-fast: false matrix: diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index bf8591f81da9b..5db7a86eb98c9 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -5,12 +5,11 @@ on: branches: - main # Redeploys main after a variable or secret change. Alchemy redeploys the - # Worker when a value it reads changes, so force is rarely needed, and it - # replaces the Postgres runtime role (alchemy-run/alchemy#1832). + # Worker when a value it reads changes, so force is rarely needed. workflow_dispatch: inputs: force: - description: Redeploy every resource, including ones with no detected changes. Replaces the Postgres runtime role. + description: Redeploy every resource, including ones with no detected changes. type: boolean default: false @@ -81,6 +80,7 @@ jobs: CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }} + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} - name: Publish relay deploy commit status uses: actions/github-script@v8 diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index b6581e0e8ea49..4dd434a956e25 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -275,6 +275,7 @@ jobs: --environment production \ --platform ${{ inputs.platform }} \ --message "$OTA_MESSAGE" \ + --emit-metadata \ --non-interactive # Reconcile each version separately because the major is in the fingerprint. @@ -313,8 +314,11 @@ jobs: --environment production \ --platform "$platform" \ --message "$message" \ + --emit-metadata \ --non-interactive; then echo ":white_check_mark: $platform/$profile: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + # The next target's export replaces dist; keep this one for the map upload. + rm -rf "dist-$platform-$profile" && mv dist "dist-$platform-$profile" else failed=1 echo ":x: $platform/$profile: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" @@ -354,3 +358,18 @@ jobs: fi done exit "$failed" + + # Lets the relay symbolicate crash stacks from the updates published + # above. A step of its own so the R2 credentials reach only this script, + # not `eas update` and the dependencies it runs. Never fails the job. + - name: Upload OTA source maps + if: ${{ !cancelled() && steps.expo-token.outputs.present == 'true' }} + working-directory: apps/mobile + env: + MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} + run: | + for dir in dist dist-*; do + if [ -f "$dir/eas-update-metadata.json" ]; then + node scripts/upload-sourcemaps.mts ota "$dir" + fi + done diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml index 53a71110cef1f..fdce22c68a59e 100644 --- a/.github/workflows/relay-preview.yml +++ b/.github/workflows/relay-preview.yml @@ -79,3 +79,5 @@ jobs: PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + # Unset by default, so previews keep the T3 Chat link off. + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 516a0a7785a95..ba40c4fb89cba 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -64,6 +64,12 @@ on: required: false default: false type: boolean + # Whether the job opens the packaged app against a seeded database and + # opens a thread before uploading it. Linux AppImage or macOS DMG. + launch_check: + required: false + default: false + type: boolean # A runner of the target's own platform and architecture, for a `runner` # that cannot execute the target. The build job skips everything that # runs the packaged binaries, and a second job runs it there. @@ -141,13 +147,6 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -157,10 +156,35 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install desktop dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + # The CLI archive and the staged app install without a lockfile, so they + # ask the registry about every package and fetch platform packages the + # workspace store lacks. They share this registry metadata and store. A + # new week starts cold, which bounds how stale cached ranges can get. + - name: Resolve staged install cache + id: stage_pnpm + shell: bash + run: | + echo "dir=$RUNNER_TEMP/desktop-stage-pnpm" >> "$GITHUB_OUTPUT" + echo "prefix=desktop-stage-pnpm-v2-${{ inputs.platform }}-${{ inputs.arch }}-$(date -u +%G-%V)-" >> "$GITHUB_OUTPUT" + + - name: Restore staged install cache + id: stage_pnpm_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ steps.stage_pnpm.outputs.prefix }} + + - name: Mark staged install cache + shell: bash + run: mkdir -p "${{ steps.stage_pnpm.outputs.dir }}" && touch "$RUNNER_TEMP/desktop-stage-pnpm.marker" - name: Cache resource monitor id: resource_monitor_cache @@ -253,6 +277,63 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + - name: Resolve target Node.js for the CLI executable + id: target_node + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + run: echo "version=$(node -p 'require("./package.json").engines.node')" >> "$GITHUB_OUTPUT" + + # The download only changes with engines.node, so a cached copy keeps + # releases off nodejs.org, which stalls on the runners some days. + - name: Restore target Node.js for the CLI executable + id: target_node_cache + if: inputs.cli_archive && inputs.platform == 'win' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: node-download-v${{ steps.target_node.outputs.version }}-win-${{ inputs.arch }} + + # tsdown downloads the target Node with one fetch and no retry, and + # nodejs.org drops that download often enough to fail releases. Seed + # tsdown's cache with a retried, checksum-verified copy instead. A transfer + # under 100 KB/s for 30s counts as failed, so a stall retries. + - name: Download target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} + run: | + set -euo pipefail + name="node-v$VERSION-win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + mkdir -p "$dir" + fetch=(curl -fsSL --retry 5 --retry-all-errors --connect-timeout 10 --speed-limit 102400 --speed-time 30) + "${fetch[@]}" -o "$dir/$name.zip" "https://nodejs.org/dist/v$VERSION/$name.zip" + "${fetch[@]}" -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$VERSION/SHASUMS256.txt" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + + # Saved right away so a later failure in this job still keeps the download. + - name: Save target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: ${{ steps.target_node_cache.outputs.cache-primary-key }} + + - name: Install target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} + run: | + set -euo pipefail + name="node-v$VERSION-win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$VERSION/win-${{ inputs.arch }}" + mkdir -p "$dest" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe" + # Setup Vite+ selects the exact engines.node version from package.json. # Native builds embed that executable; cross-target builds download the # same version for their target. @@ -313,6 +394,9 @@ jobs: if: inputs.cli_archive shell: bash env: + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} @@ -432,7 +516,10 @@ jobs: - name: Build desktop artifact shell: bash env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + # pnpm only goes to the registry for packages or versions the cache lacks. + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} @@ -517,6 +604,55 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # Warm installs only touch the store index, so new content means a fetch. + - name: Check staged install cache for new content + id: stage_pnpm_changed + shell: bash + run: | + dir="${{ steps.stage_pnpm.outputs.dir }}" + new=$(find "$dir/cache/v12" "$dir/store/v11/files" -type f -newer "$RUNNER_TEMP/desktop-stage-pnpm.marker" 2>/dev/null | wc -l | tr -d ' ') + echo "Staged installs added $new cached files." + echo "changed=$([[ "$new" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Save staged install cache + if: steps.stage_pnpm_changed.outputs.changed == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }} + + # A build can package fine and still not open (a missing module in the + # backend or the bundle). This launches the app from the shipped + # AppImage or DMG. + - name: Launch check + if: inputs.launch_check + shell: bash + run: | + set -euo pipefail + check=(node scripts/smoke-desktop-app.ts --artifacts-dir "$RUNNER_TEMP/desktop-launch-check") + if [[ "${{ inputs.platform }}" == "mac" ]]; then + mount="$RUNNER_TEMP/desktop-launch-dmg" + hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$(ls "$GITHUB_WORKSPACE"/release/*.dmg)" + trap 'hdiutil detach "$mount" -force >/dev/null || true' EXIT + app=$(ls -d "$mount"/*.app) + executable=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$app/Contents/Info.plist") + "${check[@]}" --executable "$app/Contents/MacOS/$executable" + else + sudo apt-get install -y xvfb + appimage=$(ls "$GITHUB_WORKSPACE"/release/*.AppImage) + chmod +x "$appimage" + (cd "$RUNNER_TEMP" && "$appimage" --appimage-extract >/dev/null) + xvfb-run -a "${check[@]}" --executable "$RUNNER_TEMP/squashfs-root/t3code" + fi + + - name: Upload launch check output + if: failure() && inputs.launch_check + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-launch-check-${{ inputs.platform }}-${{ inputs.arch }} + path: ${{ runner.temp }}/desktop-launch-check + if-no-files-found: ignore + # The verify job runs what this host could not: the packaged natives and # the CLI. Its scripts are bundled so that job needs no install. - name: Stage verification inputs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1045a239c876..e8f300242f054 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -344,6 +344,12 @@ jobs: - name: Test run: vp run --filter @t3tools/web test + # Browser tests run source modules; this boots the production chunk graph. + - name: Boot the built web client + run: | + vp run --filter @t3tools/web build + node apps/web/scripts/verify-built-boot.ts + # Server test files run one at a time, so more small runners beat fewer big ones. test_server: name: Release server tests ${{ matrix.shard }} @@ -414,14 +420,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3code-relay... + install-args: --filter=t3code-relay... # The deployed stack's outputs, read from Alchemy's state store without # planning or applying. Select the backend explicitly to avoid loading @@ -516,13 +518,6 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -532,10 +527,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install bundle dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -613,6 +610,7 @@ jobs: rust_target: aarch64-apple-darwin resource_key: darwin-arm64 cli_archive: true + launch_check: true desktop_mac_x64: name: Desktop macOS x64 @@ -662,6 +660,7 @@ jobs: rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 cli_archive: true + launch_check: true # node-pty has no Linux prebuild and compiles from source, so the arm64 app # and archive are built on arm64 hardware rather than cross-built. @@ -773,14 +772,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Download all desktop artifacts uses: actions/download-artifact@v8 @@ -969,15 +964,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/scripts... + install-args: --filter=t3... --filter=@t3tools/scripts... - name: Build the npm launcher run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages @@ -1033,15 +1023,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/web... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/web... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -1137,11 +1122,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Alias channel shell: bash @@ -1208,14 +1191,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/marketing... + install-args: --filter=@t3tools/marketing... - id: deploy name: Build staged deployment @@ -1279,11 +1258,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Promote marketing site shell: bash @@ -1338,15 +1315,10 @@ jobs: echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/oxlint-plugin-t3code... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/oxlint-plugin-t3code... - id: update_versions name: Update version strings @@ -1403,14 +1375,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Read published release notes if: needs.preflight.outputs.is_prerelease == 'true' diff --git a/.gitignore b/.gitignore index b33f3469c5345..b6b793e020ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ apps/mobile/.android-cache/ /.generated/ artifacts/app-store/screenshots/ artifacts/mobile-e2e/ +artifacts/mobile-behavior/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index f7cef3929ab10..f005f899595f5 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -1,7 +1,7 @@ --- title: Effect Service Conventions -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code diff --git a/.macroscope/check-run-agents/test-signal.md b/.macroscope/check-run-agents/test-signal.md new file mode 100644 index 0000000000000..5c5319aafb739 --- /dev/null +++ b/.macroscope/check-run-agents/test-signal.md @@ -0,0 +1,27 @@ +--- +title: Test Signal +model: gpt-6-1-terra +reasoning: low +input: incremental +tools: + - browse_code + - modify_pr +# An explicit include overrides ignore.md, which skips test files for every other check. +include: + - "**/*.test.*" +exclude: + - ".repos/**" +requires: + - Check +maxBudgetPerRun: 1 +maxBudgetPerPR: 5 +conclusion: neutral +--- + +# Test signal review + +Review only the tests this pull request adds or grows. Tests it moves, renames, or deletes are not findings. The rules are in `docs/internals/testing.md` under `## What to test`; read that section first and hold new tests to it. + +Post each finding as a brief inline comment on the new test with one fix. Ask only for fewer or smaller tests, never for more coverage. + +When there are no findings, make the entire final response exactly `All clear` on one line with nothing else. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index ab37120ff344f..047dafbdd4be1 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,7 +1,7 @@ --- title: UI Consistency -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code diff --git a/AGENTS.md b/AGENTS.md index 5124fc4e78001..e8eb6fd49b883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,10 +102,13 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - While you iterate, run `vp test run `. For big files, add `-t ""`. - Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. -- Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. +- Test behavior that can break, such as orchestration and event-sourcing, provider protocol translation, auth and permission guards, persistence and migrations, external-format parsing, race fixes, and the logic a bug fix changed. Leave out tests for what types already guarantee, trivial logic, copy text, smoke runs, mock-call mirrors, callback wiring, and props rendered to static markup. +- Extend before you add. When an existing test or table covers the behavior, add a row or an assertion there. Write a new `it` only when no case fits. +- Test at one layer: the lowest one that fails on `main`. Do not repeat the case in a wrapper test, at each call site, or in web and mobile copies of the same logic. +- [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in the module's existing test file), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. @@ -134,6 +137,8 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. +- Real agent turns cost money. To watch a turn stream through the pipeline and UI, use the free `Replay (dev)` provider that `vp run dev` registers; [test-t3-app](.agents/skills/test-t3-app/SKILL.md) shows how to start one. Use a real provider only when the change depends on that provider's behavior, and then pick its cheapest model, such as Haiku for Claude or Luna for Codex. + For authorized mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. ## Evals @@ -198,7 +203,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. - Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. -- React effects are for synchronizing with something outside React. Derive in render and act in handlers instead of syncing state, but don't hide real lifecycle work in refs or stores to dodge one. The `t3code/react-effect-budget` lint rule holds each file to its count in `scripts/effect-budget.json`; a new effect states its reason with `// oxlint-disable-next-line t3code/react-effect-budget -- `. +- React effects are for synchronizing with something outside React. Derive in render and act in handlers instead of syncing state, but don't hide real lifecycle work in refs or stores to dodge one. The `t3code/no-unreasoned-state-sync` lint rule reports every effect; one that stays states its reason with `// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- `. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f87fa20453f36..728ab36f7fcb2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,14 +1,13 @@ { "name": "@t3tools/desktop", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "main": "dist-electron/boot.cjs", "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", - "test": "vp test run --passWithNoTests", - "smoke-test": "node scripts/smoke-test.mjs" + "test": "vp test run --passWithNoTests" }, "dependencies": { "@clerk/electron": "catalog:", diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs deleted file mode 100644 index 05195bb8d8cda..0000000000000 --- a/apps/desktop/scripts/smoke-test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import * as NodeChildProcess from "node:child_process"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; -import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; - -const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); -const desktopDir = NodePath.resolve(__dirname, ".."); -const mainJs = NodePath.resolve(desktopDir, "dist-electron/boot.cjs"); - -console.log("\nLaunching Electron smoke test..."); - -const electronCommand = resolveElectronLaunchCommand([mainJs]); -const child = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - VITE_DEV_SERVER_URL: "", - ELECTRON_ENABLE_LOGGING: "1", - }, -}); - -let output = ""; -child.stdout.on("data", (chunk) => { - output += chunk.toString(); -}); -child.stderr.on("data", (chunk) => { - output += chunk.toString(); -}); - -const timeout = setTimeout(() => { - child.kill(); -}, 8_000); - -child.on("exit", () => { - clearTimeout(timeout); - - const fatalPatterns = [ - "Cannot find module", - "MODULE_NOT_FOUND", - "Refused to execute", - "Uncaught Error", - "Uncaught TypeError", - "Uncaught ReferenceError", - ]; - const failures = fatalPatterns.filter((pattern) => output.includes(pattern)); - - if (failures.length > 0) { - console.error("\nDesktop smoke test failed:"); - for (const failure of failures) { - console.error(` - ${failure}`); - } - console.error("\nFull output:\n" + output); - process.exit(1); - } - - console.log("Desktop smoke test passed."); - process.exit(0); -}); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index f57e709bca7af..d49d2cdebb701 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -29,6 +29,7 @@ import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; +import * as DesktopStartupRecovery from "./DesktopStartupRecovery.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; @@ -325,6 +326,7 @@ const startup = Effect.gen(function* () { yield* applicationMenu.configure; yield* updates.configure; yield* DesktopRemoteUpdates.listen; + yield* DesktopStartupRecovery.listen; yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 340cb23136441..094033192e088 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat, an atomic rename, and a directory watch at the Node adapter boundary. +import { pipe } from "effect/Function"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; @@ -151,8 +152,10 @@ export async function startDesktopAppControlServer(input: { } if (!isDesktopAppActivationRequest(parsed)) { - finish( - invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + pipe( + requestIdFromUnknown(parsed), + (requestId) => invalidResponse(requestId, "The desktop app request is invalid."), + finish, ); return; } diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index a244ff4b3fa7a..34b7286c335ed 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -156,6 +156,7 @@ describe("DesktopClerk", () => { `setPath:userData:${userData}`, "requestSingleInstanceLock", "on:second-instance", + "on:open-url", "createClerkBridge", ]); }, @@ -200,7 +201,7 @@ describe("DesktopClerk", () => { }, ); - it.effect("delivers queued startup links and later links once in arrival order", () => { + it.effect("delivers each queued startup link once, before later links", () => { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const listeners = new Map void>(); @@ -208,6 +209,7 @@ describe("DesktopClerk", () => { const first = "t3code://new?prompt=first"; const second = "t3code://threads/env-1/thread-1"; const later = "t3code://new?prompt=later"; + const launchPair = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; const delivered = Promise.withResolvers(); const electronApp = { on: (name: string, listener: (...args: unknown[]) => void) => @@ -220,13 +222,14 @@ describe("DesktopClerk", () => { const clerk = yield* DesktopClerk.DesktopClerk; listeners.get("second-instance")?.({}, ["t3", first]); listeners.get("second-instance")?.({}, ["t3", second]); + listeners.get("open-url")?.({ preventDefault: () => undefined }, launchPair); assert.deepEqual(received, []); yield* clerk.configure; listeners.get("second-instance")!({}, ["t3", later]); yield* Effect.promise(() => delivered.promise); - assert.deepEqual(received, [first, second, later]); + assert.deepEqual(received, [launchPair, first, second, later]); }).pipe( Effect.scoped, Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), @@ -425,6 +428,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" }), } as unknown as ElectronApp.ElectronApp["Service"]; const received: Array = []; + const pairLink = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; yield* Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; yield* clerk.configure; @@ -437,6 +441,8 @@ it.effect("hands web and app links to the renderer and leaves other links alone" assert.strictEqual(open("http://localhost:3000/"), 1); assert.strictEqual(open("t3code://new?prompt=hi"), 1); assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1); + assert.strictEqual(open(pairLink), 1); + assert.strictEqual(open("t3code://pair"), 0); assert.strictEqual(open("mailto:hello@example.com"), 0); assert.strictEqual(open("t3code://somewhere-else"), 0); yield* Effect.yieldNow; @@ -445,6 +451,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" "http://localhost:3000/", "t3code://new?prompt=hi", "t3code://threads/env-1/thread-1", + pairLink, ]); }).pipe( Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 3e0d753d1bfba..992e1fe056937 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -13,6 +13,7 @@ import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCal import * as ElectronShell from "../electron/ElectronShell.ts"; import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; +import { readPairingAppLink } from "@t3tools/shared/remote"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -118,6 +119,15 @@ export const make = Effect.gen(function* () { yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { handleSecondInstance(argv); }); + // macOS hands a launch link to open-url, which can fire before configure runs. + const pendingOpenUrls: Array = []; + let handleOpenUrl = (url: string) => { + pendingOpenUrls.push(url); + return true; + }; + yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { + if (handleOpenUrl(url)) event.preventDefault(); + }); yield* Effect.acquireRelease( Effect.try({ @@ -194,7 +204,8 @@ export const make = Effect.gen(function* () { return true; }; const openAppLink = (value: string | undefined) => { - if (!value || promptLinkAppPath(value) === null) return false; + if (!value || (promptLinkAppPath(value) === null && readPairingAppLink(value) === null)) + return false; void runPromise(webLinks.receive(value)); return true; }; @@ -205,15 +216,12 @@ export const make = Effect.gen(function* () { void runPromise(webLinks.receive(url)); return true; }; - yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { - if ( - startProviderAuthHandoff(url) || - resumeProviderAuth(url) || - openWebLink(url) || - openAppLink(url) - ) - event.preventDefault(); - }); + handleOpenUrl = (url) => + startProviderAuthHandoff(url) || + resumeProviderAuth(url) || + openWebLink(url) || + openAppLink(url); + for (const url of pendingOpenUrls.splice(0)) handleOpenUrl(url); yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { if (!DesktopWebLinks.isWebPageFile(path)) return; event.preventDefault(); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 0b446f8ada300..8b4fc23a99335 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -100,7 +100,7 @@ const withStore = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopConnectionCatalogStore", () => { - it.effect("persists, reads, and clears an encrypted connection catalog", () => + it.effect("persists and reads an encrypted connection catalog", () => withStore( Effect.gen(function* () { const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; @@ -108,9 +108,6 @@ describe("DesktopConnectionCatalogStore", () => { assert.isTrue(yield* store.set(catalog)); assert.deepStrictEqual(yield* store.get, Option.some(catalog)); - - yield* store.clear; - assert.deepStrictEqual(yield* store.get, Option.none()); }), ), ); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index a9020c36d1c89..ceb3f293550bf 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -163,7 +163,6 @@ export class DesktopConnectionCatalogStore extends Context.Service< boolean, DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError >; - readonly clear: Effect.Effect; } >()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} @@ -503,15 +502,6 @@ export const make = Effect.gen(function* () { yield* writeCatalog(catalog); return true; }), - clear: fileSystem.remove(catalogPath, { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("Could not clear the desktop connection catalog.", { - catalogPath, - error, - }), - ), - Effect.withSpan("desktop.connectionCatalogStore.clear"), - ), }); }); diff --git a/apps/desktop/src/app/DesktopStartupRecovery.test.ts b/apps/desktop/src/app/DesktopStartupRecovery.test.ts new file mode 100644 index 0000000000000..d8a0ef1a999cc --- /dev/null +++ b/apps/desktop/src/app/DesktopStartupRecovery.test.ts @@ -0,0 +1,70 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { createInitialDesktopUpdateState } from "../updates/updateMachine.ts"; +import { resolveStartupRecoveryOffer } from "./DesktopStartupRecovery.ts"; + +const updateState = (patch: Partial): DesktopUpdateState => ({ + ...createInitialDesktopUpdateState( + "0.1.3", + { hostArch: "arm64", appArch: "arm64", runningUnderArm64Translation: false }, + "latest", + ), + enabled: true, + ...patch, +}); + +describe("resolveStartupRecoveryOffer", () => { + it("offers a newer version before going back", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ status: "available", availableVersion: "0.1.4" }), + "0.1.2", + ), + ).toEqual({ _tag: "Update", version: "0.1.4" }); + }); + + it("offers a retained download after a later check fails", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ status: "error", errorContext: "check", downloadedVersion: "0.1.4" }), + null, + ), + ).toEqual({ _tag: "Update", version: "0.1.4" }); + }); + + it("waits for the update check before offering to go back", () => { + expect(resolveStartupRecoveryOffer(updateState({ status: "checking" }), "0.1.2")).toEqual({ + _tag: "Checking", + }); + }); + + it("offers the last version that started when no update exists", () => { + expect(resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), "0.1.2")).toEqual({ + _tag: "Reinstall", + version: "0.1.2", + }); + }); + + it("does not offer an update that already failed to apply here", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ + status: "downloaded", + downloadedVersion: "0.1.4", + notAppliedVersion: "0.1.4", + }), + "0.1.2", + ), + ).toEqual({ _tag: "Reinstall", version: "0.1.2" }); + }); + + it("falls back to the releases page when this version never started", () => { + expect(resolveStartupRecoveryOffer(updateState({ status: "disabled" }), "0.1.3")).toEqual({ + _tag: "Releases", + }); + expect(resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), null)).toEqual({ + _tag: "Releases", + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts new file mode 100644 index 0000000000000..4d2dbcfb14409 --- /dev/null +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -0,0 +1,338 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import type { BackendStartupFailure } from "../backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopState from "./DesktopState.ts"; + +const RELEASES_URL = "https://github.com/pingdotgg/t3code/releases"; +const LAST_STARTED_VERSION_FILE = "desktop-last-started-version"; +// Buttons are plain links to this host; will-navigate cancels them and runs the action. +const ACTION_ORIGIN = "https://startup-recovery.invalid"; + +const { logInfo, logWarning } = DesktopObservability.makeComponentLogger( + "desktop-startup-recovery", +); + +export type StartupRecoveryOffer = + | { readonly _tag: "Checking" } + | { readonly _tag: "Update"; readonly version: string } + | { readonly _tag: "Downloading"; readonly version: string; readonly percent: number | null } + | { readonly _tag: "Reinstall"; readonly version: string } + | { readonly _tag: "Releases" }; + +const STARTUP_RECOVERY_ACTIONS = ["update", "reinstall", "releases", "copy", "quit"] as const; +type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number]; + +/** Prefer a newer build, then the last build that started on this machine, then the releases page. */ +export function resolveStartupRecoveryOffer( + update: DesktopUpdateState, + lastStartedVersion: string | null, +): StartupRecoveryOffer { + if (update.status === "downloading" && update.availableVersion !== null) { + return { + _tag: "Downloading", + version: update.availableVersion, + percent: update.downloadPercent, + }; + } + const updateVersion = + update.downloadedVersion ?? (update.status === "available" ? update.availableVersion : null); + // A version that already failed to apply here would loop through install and relaunch. + if (updateVersion !== null && updateVersion !== update.notAppliedVersion) { + return { _tag: "Update", version: updateVersion }; + } + if (update.enabled && (update.status === "idle" || update.status === "checking")) { + return { _tag: "Checking" }; + } + if (lastStartedVersion !== null && lastStartedVersion !== update.currentVersion) { + return { _tag: "Reinstall", version: lastStartedVersion }; + } + return { _tag: "Releases" }; +} + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (char) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char, + ); + +const actionLink = (action: StartupRecoveryAction, label: string, primary = false) => + `${escapeHtml(label)}`; + +function describeOffer(offer: StartupRecoveryOffer): { text: string; action: string } { + switch (offer._tag) { + case "Checking": + return { text: "Checking for a newer version…", action: "" }; + case "Update": + return { + text: `Version ${offer.version} is available. It can contain a fix.`, + action: actionLink("update", `Update to ${offer.version}`, true), + }; + case "Downloading": + return { + text: `Downloading ${offer.version}${offer.percent === null ? "" : ` (${Math.floor(offer.percent / 10) * 10}%)`}. T3 Code restarts when the download is done.`, + action: "", + }; + case "Reinstall": + return { + text: `Version ${offer.version} started correctly on this computer. Download it and install it to go back.`, + action: actionLink("reinstall", `Download ${offer.version}`, true), + }; + case "Releases": + return { + text: "Get a different version from the releases page.", + action: actionLink("releases", "Open releases", true), + }; + } +} + +function buildStartupRecoveryHtml(input: { + readonly appVersion: string; + readonly failure: BackendStartupFailure; + readonly offer: StartupRecoveryOffer; + readonly logDir: string; +}): string { + const offer = describeOffer(input.offer); + const output = input.failure.output.trim(); + return `T3 Code could not start +

T3 Code could not start

+

${escapeHtml(input.failure.reason)}

+

${escapeHtml(offer.text)}

+
${offer.action}${actionLink("copy", "Copy details")}${actionLink("quit", "Quit")}
+
${escapeHtml(output.length > 0 ? output : "The server wrote no output.")}
+

Version ${escapeHtml(input.appVersion)}. T3 Code tries again in the background. Logs: ${escapeHtml(input.logDir)}

+`; +} + +/** Shows a self-contained error window while the primary backend fails to start, so a broken release still opens. */ +export const listen: Effect.Effect< + void, + never, + | DesktopState.DesktopState + | DesktopUpdates.DesktopUpdates + | DesktopEnvironment.DesktopEnvironment + | ElectronApp.ElectronApp + | ElectronShell.ElectronShell + | ElectronTheme.ElectronTheme + | ElectronWindow.ElectronWindow + | FileSystem.FileSystem + | Scope.Scope +> = Effect.gen(function* () { + const { primaryBackendStartup, quitting } = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + const electronShell = yield* ElectronShell.ElectronShell; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const fileSystem = yield* FileSystem.FileSystem; + const runFork = Effect.runForkWith(yield* Effect.context()); + + const lastStartedVersionPath = environment.path.join( + environment.stateDir, + LAST_STARTED_VERSION_FILE, + ); + const lastStartedVersion = yield* fileSystem.readFileString(lastStartedVersionPath).pipe( + Effect.map((text) => text.trim() || null), + Effect.orElseSucceed(() => null), + ); + + const windowRef = yield* Ref.make(Option.none()); + // The failure that opened the window; later restarts do not reload its details. + const failureRef = yield* Ref.make(Option.none()); + const renderedHtmlRef = yield* Ref.make(""); + const checkedRef = yield* Ref.make(false); + const { latest, changes: updateChanges } = yield* updates.subscribe; + const updateStateRef = yield* Ref.make(latest); + + const offer = Ref.get(updateStateRef).pipe( + Effect.map((state) => resolveStartupRecoveryOffer(state, lastStartedVersion)), + ); + + const render = Effect.gen(function* () { + const window = yield* Ref.get(windowRef); + const failure = yield* Ref.get(failureRef); + if (Option.isNone(window) || Option.isNone(failure) || window.value.isDestroyed()) return; + const html = buildStartupRecoveryHtml({ + appVersion: environment.appVersion, + failure: failure.value, + offer: yield* offer, + logDir: environment.logDir, + }); + if ((yield* Ref.getAndSet(renderedHtmlRef, html)) === html) return; + void window.value.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + }); + + const runAction = Effect.fn("desktop.startupRecovery.runAction")(function* ( + action: StartupRecoveryAction, + ) { + yield* logInfo("startup recovery action", { action }); + const current = yield* offer; + switch (action) { + case "update": { + if (current._tag !== "Update") return; + const state = yield* updates.getState; + if (state.downloadedVersion === null) { + const download = yield* updates.download; + if (!download.completed) return; + } + // Waits for a running background check; plain install would refuse. + yield* updates.installPrepared(current.version); + return; + } + case "reinstall": + if (current._tag === "Reinstall") { + yield* electronShell.openExternal(`${RELEASES_URL}/tag/v${current.version}`); + } + return; + case "releases": + yield* electronShell.openExternal(RELEASES_URL); + return; + case "copy": { + const failure = yield* Ref.get(failureRef); + if (Option.isNone(failure)) return; + yield* electronShell.copyText( + `T3 Code ${environment.appVersion} (${environment.platform} ${environment.processArch}) could not start.\n${failure.value.reason}\n\n${failure.value.output}`, + ); + return; + } + case "quit": + yield* electronApp.quit; + return; + } + }); + + const show = Effect.fn("desktop.startupRecovery.show")( + function* () { + if (yield* Ref.get(quitting)) return; + // A backend that crashes after the app opened is reported by the app itself. + if (Option.isSome(yield* electronWindow.main)) return; + const existing = yield* Ref.get(windowRef); + if (Option.isSome(existing) && !existing.value.isDestroyed()) { + yield* electronWindow.reveal(existing.value); + return; + } + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const window = yield* electronWindow.create( + { + width: 640, + height: 480, + minWidth: 420, + minHeight: 320, + show: false, + title: "T3 Code could not start", + autoHideMenuBar: true, + backgroundColor: shouldUseDarkColors ? "#000000" : "#ffffff", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + javascript: false, + }, + }, + { auxiliary: true }, + ); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event, url) => { + event.preventDefault(); + const action = STARTUP_RECOVERY_ACTIONS.find((name) => url === `${ACTION_ORIGIN}/${name}`); + if (action !== undefined) runFork(runAction(action)); + }); + window.once("ready-to-show", () => { + if (!window.isDestroyed()) window.show(); + }); + window.once("closed", () => { + runFork( + Ref.set(windowRef, Option.none()).pipe(Effect.andThen(Ref.set(renderedHtmlRef, ""))), + ); + }); + yield* Ref.set(windowRef, Option.some(window)); + yield* render; + yield* logInfo("startup recovery window shown"); + }, + Effect.catch((error) => + logWarning("could not show startup recovery", { errorTag: error._tag }), + ), + ); + + const close = Effect.gen(function* () { + const window = yield* Ref.getAndSet(windowRef, Option.none()); + if (Option.isSome(window) && !window.value.isDestroyed()) window.value.close(); + }); + + const rememberStartedVersion = Effect.gen(function* () { + if (lastStartedVersion === environment.appVersion) return; + yield* fileSystem.writeFileString(lastStartedVersionPath, environment.appVersion); + }).pipe( + Effect.catch((error) => + logWarning("could not record the started version", { errorTag: error._tag }), + ), + ); + + yield* updateChanges.pipe( + Stream.runForEach((state) => Ref.set(updateStateRef, state).pipe(Effect.andThen(render))), + Effect.forkScoped, + ); + + yield* SubscriptionRef.changes(primaryBackendStartup).pipe( + Stream.runForEach( + Effect.fn("desktop.startupRecovery.onStartupChange")(function* (startup) { + if (startup._tag === "Ready") { + yield* Ref.set(failureRef, Option.none()); + yield* close; + yield* rememberStartedVersion; + return; + } + if (startup._tag !== "Failing") return; + if (Option.isSome(yield* Ref.get(failureRef))) return; + yield* Ref.set(failureRef, Option.some({ reason: startup.reason, output: startup.output })); + // The backend manager already logs each failure's reason. + yield* logWarning("primary backend failing to start"); + yield* show(); + if (!(yield* Ref.getAndSet(checkedRef, true))) { + yield* updates.check("startup-recovery").pipe(Effect.forkScoped); + } + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("startup recovery stopped", cause).pipe( + Effect.annotateLogs({ component: "desktop-startup-recovery" }), + ), + ), + Effect.forkScoped, + ); + + // macOS keeps running with no windows; the Dock icon must bring the error back. + yield* electronApp.on("activate", () => { + runFork( + Ref.get(failureRef).pipe( + Effect.flatMap((failure) => (Option.isSome(failure) ? show() : Effect.void)), + ), + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts index cd2abe9106544..3452fd13c0807 100644 --- a/apps/desktop/src/app/DesktopState.ts +++ b/apps/desktop/src/app/DesktopState.ts @@ -2,18 +2,29 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { BackendStartupFailure } from "../backend/DesktopBackendManager.ts"; + +/** Startup health of the primary local backend, read by the startup recovery window. */ +export type PrimaryBackendStartup = + | { readonly _tag: "Starting" } + | { readonly _tag: "Ready" } + | ({ readonly _tag: "Failing" } & BackendStartupFailure); export class DesktopState extends Context.Service< DesktopState, { readonly backendReady: Ref.Ref; readonly quitting: Ref.Ref; + readonly primaryBackendStartup: SubscriptionRef.SubscriptionRef; } >()("@t3tools/desktop/app/DesktopState") {} const make = Effect.all({ backendReady: Ref.make(false), quitting: Ref.make(false), + primaryBackendStartup: SubscriptionRef.make({ _tag: "Starting" }), }); export const layer = Layer.effect(DesktopState, make); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index ca69f5d025899..492903139547b 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -74,7 +74,6 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_NO_BROWSER", "T3CODE_HOST", "T3CODE_DESKTOP_WS_URL", - "T3CODE_DESKTOP_LAN_ACCESS", "T3CODE_DESKTOP_LAN_HOST", "T3CODE_DESKTOP_HTTPS_ENDPOINTS", "T3CODE_TAILSCALE_SERVE", @@ -86,6 +85,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", "OTEL_SDK_DISABLED", diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 7b15346a754e2..54fb312ec308f 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -123,6 +123,9 @@ interface MakeInstanceInput { readonly onReady?: Effect.Effect; readonly onShutdown?: Effect.Effect; readonly onStateDirOwned?: Effect.Effect; + readonly onStartupFailure?: ( + failure: DesktopBackendManager.BackendStartupFailure, + ) => Effect.Effect; readonly onPreflightFailed?: ( failure: DesktopBackendManager.PreflightFailure, ) => Effect.Effect; @@ -182,6 +185,7 @@ function makeTestInstance(input: MakeInstanceInput) { ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), ...(input.onStateDirOwned ? { onStateDirOwned: () => input.onStateDirOwned! } : {}), ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), + ...(input.onStartupFailure ? { onStartupFailure: input.onStartupFailure } : {}), }); return instance.pipe(Effect.provide(layerServices)); @@ -575,4 +579,56 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("reports a crash loop with the server output on the third restart", () => + Effect.scoped( + Effect.gen(function* () { + const message = "Error: Cannot find module 'zod/v4' in /Users/René"; + let exitCount = 0; + const firstExit = yield* Deferred.make(); + const reports = yield* Queue.unbounded<{ + readonly exitCount: number; + readonly failure: DesktopBackendManager.BackendStartupFailure; + }>(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut + // lands inside the emoji's surrogate pair. + stderr: Stream.make( + new TextEncoder().encode( + `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, + ), + new Uint8Array([0xc3]), + new Uint8Array([0xa9]), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }), + ), + ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + backendOutputLog: { + persistFailure: () => { + exitCount += 1; + return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); + }, + }, + onStartupFailure: (failure) => + Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(firstExit); + yield* TestClock.adjust(Duration.minutes(1)); + const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); + assert.equal(exitsWhenReported, 3); + assert.equal(failure.reason, "The server could not start (code=1)."); + assert.isTrue(failure.output.endsWith(message)); + assert.isTrue(failure.output.isWellFormed()); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 8b5e8045ed371..cbc0516509d0b 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -41,6 +41,9 @@ const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; +// Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help. +const STARTUP_FAILURE_RESTART_ATTEMPTS = 3; +const STARTUP_FAILURE_OUTPUT_CHARS = 4_000; const { logWarning: logBackendProcessWarning } = DesktopObservability.makeComponentLogger("desktop-backend-process"); @@ -217,6 +220,12 @@ export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( PRIMARY_LOCAL_ENVIRONMENT_ID, ); +export interface BackendStartupFailure { + readonly reason: string; + /** Tail of the last run's stdout and stderr. */ + readonly output: string; +} + export interface DesktopBackendInstance { readonly id: BackendInstanceId; readonly label: Effect.Effect; @@ -236,6 +245,8 @@ export interface BackendInstanceSpec { readonly onShutdown?: () => Effect.Effect; readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; readonly onStateDirOwned?: () => Effect.Effect; + /** Fires on each repeated crash or readiness timeout until the backend becomes ready. */ + readonly onStartupFailure?: (failure: BackendStartupFailure) => Effect.Effect; } interface ActiveBackendRun { @@ -619,6 +630,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); const mutex = yield* Semaphore.make(1); + const outputTail = yield* Ref.make(""); + // One streaming decoder per stream keeps UTF-8 characters split across chunks intact. + const makeOutputDecoders = () => ({ stdout: new TextDecoder(), stderr: new TextDecoder() }); + let outputDecoders = makeOutputDecoders(); + const reportStartupFailure = (reason: string) => + Ref.get(outputTail).pipe( + Effect.flatMap((output) => spec.onStartupFailure?.({ reason, output }) ?? Effect.void), + ); const { logWarning: logInstanceWarning, logError: logInstanceError } = DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); @@ -875,6 +894,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...run, pid: Option.some(pid), })); + yield* Ref.set(outputTail, ""); + outputDecoders = makeOutputDecoders(); yield* backendOutputLog.beginSession({ details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, }); @@ -923,9 +944,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* backendOutputLog.persistFailureSnapshot({ details: error.message, }); + yield* reportStartupFailure("The server started but did not become ready in time."); }, ), - onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + onOutput: (streamName, chunk) => + Ref.update(outputTail, (tail) => + // A cut through a surrogate pair would make encodeURIComponent throw when rendering. + (tail + outputDecoders[streamName].decode(chunk, { stream: true })) + .slice(-STARTUP_FAILURE_OUTPUT_CHARS) + .toWellFormed(), + ).pipe(Effect.andThen(backendOutputLog.writeOutputChunk(streamName, chunk))), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -951,26 +979,29 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ) { const scheduled = yield* Ref.modify(state, (latest) => { if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { - return [Option.none(), latest] as const; + return [Option.none<{ delay: Duration.Duration; attempt: number }>(), latest] as const; } - const delay = calculateRestartDelay(latest.restartAttempt); + const attempt = latest.restartAttempt + 1; return [ - Option.some(delay), - { - ...latest, - restartAttempt: latest.restartAttempt + 1, - }, + Option.some({ delay: calculateRestartDelay(latest.restartAttempt), attempt }), + { ...latest, restartAttempt: attempt }, ] as const; }); yield* Option.match(scheduled, { onNone: () => Effect.void, - onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) { + onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* ({ + delay, + attempt, + }) { yield* logInstanceError("backend exited unexpectedly; restart scheduled", { reason, delayMs: Duration.toMillis(delay), }); + if (attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) { + yield* reportStartupFailure(`The server could not start (${reason}).`); + } const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 0583e8df21705..2b2dd841ab53f 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -9,6 +9,7 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; @@ -62,6 +63,7 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer(); @@ -158,12 +161,16 @@ export const layer = Layer.effect( // Absorb window errors so readiness can reset restartAttempt. onReady: (httpBaseUrl) => desktopWindow.handleBackendReady(httpBaseUrl).pipe( + // Only once the main window exists, so the recovery window never closes into nothing. + Effect.andThen(SubscriptionRef.set(primaryBackendStartup, { _tag: "Ready" })), Effect.catch((error) => logBackendPoolWarning("failed to open main window after backend readiness", { error: error.message, }), ), ), + onStartupFailure: (failure) => + SubscriptionRef.set(primaryBackendStartup, { _tag: "Failing", ...failure }), onShutdown: () => desktopWindow.handleBackendNotReady, onPreflightFailed: handlePrimaryPreflightFailure, onStateDirOwned: () => diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 4f17ca7e33010..478d490129014 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -162,4 +162,20 @@ describe("ElectronWindow", () => { assert.equal(vi.mocked(laterWindow.destroy).mock.calls.length, 1); }).pipe(Effect.provide(layerTest)), ); + + it.effect("never falls back to an auxiliary window as the main window", () => + Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const auxiliary = yield* electronWindow.create({}, { auxiliary: true }); + const ordinary = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; + Object.assign(auxiliary, { isDestroyed: () => false }); + + getAllWindowsMock.mockReturnValue([auxiliary]); + assert.isTrue((yield* electronWindow.currentMainOrFirst)._tag === "None"); + + getAllWindowsMock.mockReturnValue([auxiliary, ordinary]); + const current = yield* electronWindow.currentMainOrFirst; + assert.isTrue(current._tag === "Some" && current.value === ordinary); + }).pipe(Effect.provide(layerTest)), + ); }); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 35303ed935aef..985f9c00775a0 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -109,8 +109,10 @@ export class ElectronWindowOperationError extends Schema.TaggedError Effect.Effect; readonly main: Effect.Effect>; readonly currentMainOrFirst: Effect.Effect>; @@ -141,6 +143,7 @@ export const make = Effect.gen(function* () { const captureRevealWindows = new Set(); yield* Effect.addFinalizer(() => Effect.sync(() => windowsForegroundFocus?.close())); const mainWindowRef = yield* Ref.make>(Option.none()); + const auxiliaryWindows = new WeakSet(); const listWindows = Effect.try({ try: () => Electron.BrowserWindow.getAllWindows(), @@ -181,7 +184,9 @@ export const make = Effect.gen(function* () { return main; } - const first = Option.fromNullishOr((yield* listWindows)[0] ?? null); + const first = Option.fromNullishOr( + (yield* listWindows).find((window) => !auxiliaryWindows.has(window)) ?? null, + ); if (Option.isNone(first) || (yield* isWindowDestroyed(first.value))) { return Option.none(); } @@ -207,7 +212,7 @@ export const make = Effect.gen(function* () { }); return ElectronWindow.of({ - create: Effect.fn("ElectronWindow.create")(function (options) { + create: Effect.fn("ElectronWindow.create")(function (options, behavior) { const webPreferences = options.webPreferences; const diagnosticOptions = { title: options.title ?? null, @@ -232,7 +237,11 @@ export const make = Effect.gen(function* () { } satisfies typeof ElectronWindowCreateOptions.Type; return Effect.try({ - try: () => new Electron.BrowserWindow(options), + try: () => { + const window = new Electron.BrowserWindow(options); + if (behavior?.auxiliary === true) auxiliaryWindows.add(window); + return window; + }, catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), }); }), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2d67aa56e6ccd..35333ac3d33a4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -4,11 +4,7 @@ import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./metho import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; -import { - clearConnectionCatalog, - getConnectionCatalog, - setConnectionCatalog, -} from "./methods/connectionCatalog.ts"; +import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, @@ -26,7 +22,6 @@ import { ensureSshEnvironment, fetchSshEnvironmentDescriptor, fetchSshSessionState, - issueSshWebSocketTicket, resolveSshHost, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; @@ -120,7 +115,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(dismissSnapShotAnimation); yield* ipc.handle(acknowledgeSnapShot); yield* ipc.handle(setConnectionCatalog); - yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(resolveSshHost); @@ -129,7 +123,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(fetchSshEnvironmentDescriptor); yield* ipc.handle(bootstrapSshBearerSession); yield* ipc.handle(fetchSshSessionState); - yield* ipc.handle(issueSshWebSocketTicket); yield* ipc.handle(resolveSshPasswordPrompt); yield* ipc.handle(getServerExposureState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9b11a2283a273..bba4f6449b8c1 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -55,7 +55,6 @@ export const DISMISS_SNAP_SHOT_ANIMATION_CHANNEL = "desktop:dismiss-snap-shot-an export const ACKNOWLEDGE_SNAP_SHOT_CHANNEL = "desktop:acknowledge-snap-shot"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; -export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; @@ -63,7 +62,6 @@ export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-enviro export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; -export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; @@ -110,7 +108,6 @@ export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-fra export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; -export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts index 4e51496a6379a..0f913d0f300a3 100644 --- a/apps/desktop/src/ipc/methods/connectionCatalog.ts +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -25,13 +25,3 @@ export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ return yield* store.set(catalog); }), }); - -export const clearConnectionCatalog = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, - payload: Schema.Void, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () { - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - yield* store.clear; - }), -}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index c6c2cd8fbdf9a..20b7bb4877d59 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -47,9 +47,6 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeStateChanges((tabId, state) => electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), ); - yield* manager.subscribeRecordingFrames((frame) => - electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), - ); yield* manager.subscribeRecordingInputs((event) => electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, event), ); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 099b97ce7bf31..15e04f71282b1 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -1,7 +1,6 @@ import { bootstrapRemoteBearerSession, fetchRemoteSessionState, - issueRemoteWebSocketTicket, RemoteEnvironmentAuthUndeclaredStatusError, type RemoteEnvironmentAuthError, } from "@t3tools/client-runtime/authorization"; @@ -24,7 +23,6 @@ import { EnvironmentScopeRequiredError, AuthAccessTokenResult, AuthSessionState, - AuthWebSocketTicketResult, } from "@t3tools/contracts"; import { SshHttpBridgeError } from "@t3tools/ssh/errors"; import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; @@ -40,8 +38,7 @@ import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts. type DesktopSshEnvironmentRequestOperation = | "fetch-environment-descriptor" | "bootstrap-bearer-session" - | "fetch-session-state" - | "issue-websocket-ticket"; + | "fetch-session-state"; type DesktopSshEnvironmentRequestCause = RemoteEnvironmentAuthError | SshHttpBridgeError; @@ -203,23 +200,6 @@ export const fetchSshSessionState = DesktopIpc.makeIpcMethod({ }), }); -export const issueSshWebSocketTicket = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - payload: DesktopSshBearerRequestInputSchema, - result: AuthWebSocketTicketResult, - handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({ - httpBaseUrl, - bearerToken, - }) { - return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) => - issueRemoteWebSocketTicket({ - httpBaseUrl: resolvedHttpBaseUrl, - bearerToken, - }), - )(httpBaseUrl); - }), -}); - export const resolveSshPasswordPrompt = DesktopIpc.makeIpcMethod({ channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, payload: DesktopSshPasswordPromptResolutionInputSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4d57b30e2139a..a3e1e711cb2d3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,7 +3,6 @@ import type { DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, - DesktopPreviewRecordingFrame, DesktopPreviewTabState, DesktopSnapShotEvent, } from "@t3tools/contracts"; @@ -139,7 +138,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), setConnectionCatalog: (catalog) => ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), - clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias), ensureSshEnvironment: async (target, options) => @@ -160,8 +158,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { }), fetchSshSessionState: (httpBaseUrl, bearerToken) => ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), - issueSshWebSocketTicket: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), onSshPasswordPrompt: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { if (typeof request !== "object" || request === null) return; @@ -398,15 +394,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { mimeType, data, }), - onFrame: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { - if (typeof frame !== "object" || frame === null) return; - listener(frame as DesktopPreviewRecordingFrame); - }; - ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - return () => - ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - }, }, onStateChange: (listener) => { const wrappedListener = ( diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index d9b797dd0451f..22af4a829e049 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -168,12 +168,7 @@ describe("BrowserSession", () => { return granted; }; - for (const permission of [ - "clipboard-read", - "clipboard-sanitized-write", - "geolocation", - "fullscreen", - ]) { + for (const permission of ["clipboard-sanitized-write", "geolocation", "fullscreen"]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( checkHandler(null, permission) as boolean, @@ -226,6 +221,55 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("asks once per origin before a page reads the clipboard", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0]; + const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + assert.isFunction(checkHandler); + + const readClipboard = (requestingUrl: string) => + Effect.callback((resume) => { + requestHandler( + null, + "clipboard-read", + (granted: boolean) => resume(Effect.succeed(granted)), + { requestingUrl, isMainFrame: true }, + ); + }); + const checkClipboard = (origin: string): boolean => + checkHandler(null, "clipboard-read", origin, {}) as boolean; + + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1); + + assert.isFalse(checkClipboard("https://allowed.test")); + assert.isTrue(yield* readClipboard("https://allowed.test/page")); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + assert.strictEqual( + showMessageBox.mock.calls[0]?.[0].message, + "https://allowed.test wants to see text and images copied to the clipboard", + ); + assert.deepStrictEqual(showMessageBox.mock.calls[0]?.[0].buttons, ["Allow", "Block"]); + // Enter must not accept a prompt the page raised while the user was typing. + assert.strictEqual(showMessageBox.mock.calls[0]?.[0].defaultId, 1); + + assert.isTrue(yield* readClipboard("https://allowed.test/other")); + assert.isTrue(checkClipboard("https://allowed.test")); + + assert.isFalse(yield* readClipboard("https://blocked.test/")); + assert.isFalse(yield* readClipboard("https://blocked.test/again")); + assert.isFalse(checkClipboard("https://blocked.test")); + assert.isTrue(checkClipboard("https://allowed.test")); + assert.strictEqual(showMessageBox.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + it.effect("clears a partition whose session has not been opened yet", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 0f900d521b739..2ea7efdb88ee0 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -20,7 +20,6 @@ export type BrowserSessionPartitionNamespace = "profile"; // Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ - "clipboard-read", "clipboard-sanitized-write", "geolocation", // DesktopWindow confines HTML fullscreen to the guest's webview. @@ -55,6 +54,19 @@ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => const MAX_PROMPT_URL_LENGTH = 300; +const truncateForPrompt = (text: string): string => + text.length > MAX_PROMPT_URL_LENGTH ? `${text.slice(0, MAX_PROMPT_URL_LENGTH)}…` : text; + +const permissionOrigin = (rawUrl: string | undefined): string | null => { + if (!rawUrl) return null; + try { + const { origin } = new URL(rawUrl); + return origin === "null" ? null : origin; + } catch { + return null; + } +}; + export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { @@ -175,47 +187,70 @@ export const make = Effect.gen(function* BrowserSessionMake() { const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. - let externalPromptOpen = false; - /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ - const confirmOpenExternal = ( + let permissionPromptOpen = false; + /** Settles with `undefined` when the user gave no answer: another prompt was open or the dialog failed. */ + const promptPermission = ( requester: WebContents | null, - rawUrl: string | undefined, - callback: (granted: boolean) => void, + prompt: { + readonly buttons: readonly [allow: string, deny: string]; + readonly message: string; + readonly detail?: string; + }, + callback: (answer: boolean | undefined) => void, ): void => { - const url = externalProtocolPromptUrl(rawUrl); - if (url === null || externalPromptOpen) { - callback(false); + if (permissionPromptOpen) { + callback(undefined); return; } - externalPromptOpen = true; - const settle = (granted: boolean) => + permissionPromptOpen = true; + const settle = (answer: boolean | undefined) => Effect.sync(() => { - externalPromptOpen = false; - callback(granted); + permissionPromptOpen = false; + callback(answer); }); - const shownUrl = - url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url; runFork( electronDialog .showMessageBox( { type: "question", - buttons: ["Open", "Cancel"], - defaultId: 0, + buttons: [...prompt.buttons], + // Default to deny so an Enter typed into the page cannot accept a prompt it raised. + defaultId: 1, cancelId: 1, - message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, - detail: `A page in the browser wants to open another application:\n\n${shownUrl}`, + message: prompt.message, + ...(prompt.detail === undefined ? {} : { detail: prompt.detail }), }, previewHostWindow(requester), ) .pipe( - Effect.map(({ response }) => response === 0), - Effect.orElseSucceed(() => false), + Effect.map(({ response }): boolean | undefined => response === 0), + Effect.orElseSucceed(() => undefined), Effect.flatMap(settle), - Effect.onInterrupt(() => settle(false)), + Effect.onInterrupt(() => settle(undefined)), ), ); }; + /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ + const confirmOpenExternal = ( + requester: WebContents | null, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const url = externalProtocolPromptUrl(rawUrl); + if (url === null) { + callback(false); + return; + } + promptPermission( + requester, + { + buttons: ["Open", "Cancel"], + message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, + detail: `A page in the browser wants to open another application:\n\n${truncateForPrompt(url)}`, + }, + (answer) => callback(answer === true), + ); + }; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( @@ -248,6 +283,35 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); + // Like Chrome, clipboard reads ask once per origin; both answers last for the session. + const clipboardReadDecisions = new Map(); + const confirmClipboardRead = ( + requester: WebContents, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const origin = permissionOrigin(rawUrl); + if (origin === null) { + callback(false); + return; + } + const remembered = clipboardReadDecisions.get(origin); + if (remembered !== undefined) { + callback(remembered); + return; + } + promptPermission( + requester, + { + buttons: ["Allow", "Block"], + message: `${truncateForPrompt(origin)} wants to see text and images copied to the clipboard`, + }, + (answer) => { + if (answer !== undefined) clipboardReadDecisions.set(origin, answer); + callback(answer === true); + }, + ); + }; // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010. browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { @@ -259,11 +323,21 @@ export const make = Effect.gen(function* BrowserSessionMake() { ); return; } + if (permission === "clipboard-read") { + confirmClipboardRead( + webContents, + "requestingUrl" in details ? details.requestingUrl : undefined, + callback, + ); + return; + } callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }, ); - browserSession.setPermissionCheckHandler((_webContents, permission) => - ALLOWED_PREVIEW_PERMISSIONS.has(permission), + browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => + permission === "clipboard-read" + ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true + : ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); const next = new Map(sessions); next.set(partition, browserSession); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d0221e9be0e6e..036c0d4afd932 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -359,7 +359,6 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { }; type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } @@ -536,9 +535,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const recordingInputListenersRef = yield* Ref.make>( new Set(), ); - const recordingFrameListenersRef = yield* Ref.make>( - new Set(), - ); const pickSessionsRef = yield* Ref.make>(new Map()); const controlSessionsRef = yield* SynchronizedRef.make< ReadonlyMap @@ -801,12 +797,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: - | "state-change" - | "recording-frame" - | "recording-input" - | "pointer-event" - | "open-link", + eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link", tabId: string, delivery: () => Effect.Effect, ) => @@ -1077,35 +1068,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get( - tabId, - ); - if (captureSession?.consumers.has("recording")) { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: - typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: - typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => - deliverEvent("recording-frame", frame.tabId, () => listener(frame)), - { discard: true }, - ); - } - } } if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { yield* Queue.offer(consoleReleases, undefined); @@ -1505,7 +1467,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); if (forwarded && host) { event.preventDefault(); - if (!input.isAutoRepeat) host.send(MENU_ACTION_CHANNEL, forwarded.command); + if (!input.isAutoRepeat) { + if ( + forwarded.command === "workspace.focusNext" || + forwarded.command === "workspace.focusPrevious" + ) { + // DOM focus in the renderer cannot by itself release the guest's native keyboard focus. + host.focus(); + } + host.send(MENU_ACTION_CHANNEL, forwarded.command); + } return; } if (isPreviewRefreshShortcut(input)) { @@ -2517,78 +2488,66 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function height: size.height, receivedAt, }; - const deliveries: Array> = []; - if (pictureInPicture) { - const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( - tabId, - )?.window; - if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) { - deliveries.push( - Effect.gen(function* () { - const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get( - tabId, - ); - const aspectRatio = frame.width / frame.height; - if ( - previousAspectRatio === undefined || - Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON - ) { - yield* attempt( - { - operation: "pictureInPicture.setAspectRatio", - tabId, - webContentsId: wc.id, - }, - () => { - const contentSize = fitPictureInPictureContentSize( - pictureInPictureWindow.getContentSize(), - aspectRatio, - ); - pictureInPictureWindow.setAspectRatio(0); - pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); - pictureInPictureWindow.setAspectRatio(aspectRatio); - }, - ); - yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => - replaceMap(aspectRatios, (copy) => { - copy.set(tabId, aspectRatio); - }), - ); - } - yield* attempt( - { - operation: "pictureInPicture.deliverFrame", - tabId, - webContentsId: wc.id, - }, - () => { - pictureInPictureWindow.webContents.send( - PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, - frame, - ); - }, + const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( + tabId, + )?.window; + if (!pictureInPictureWindow || pictureInPictureWindow.isDestroyed()) return; + yield* Effect.gen(function* () { + const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get(tabId); + const aspectRatio = frame.width / frame.height; + if ( + previousAspectRatio === undefined || + Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON + ) { + yield* attempt( + { + operation: "pictureInPicture.setAspectRatio", + tabId, + webContentsId: wc.id, + }, + () => { + const contentSize = fitPictureInPictureContentSize( + pictureInPictureWindow.getContentSize(), + aspectRatio, ); - yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { - if (sessions.get(tabId) !== frameSession) return sessions; - return replaceMap(sessions, (copy) => { - copy.set(tabId, { - ...frameSession, - lastPictureInPictureFingerprint: fingerprint, - }); - }); - }); - }).pipe( - Effect.catch((error) => - Effect.logWarning("Picture-in-picture frame delivery failed.", { - tabId, - error, - }), - ), - ), + pictureInPictureWindow.setAspectRatio(0); + pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); + pictureInPictureWindow.setAspectRatio(aspectRatio); + }, + ); + yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => + replaceMap(aspectRatios, (copy) => { + copy.set(tabId, aspectRatio); + }), ); } - } - yield* Effect.all(deliveries, { concurrency: 2, discard: true }); + yield* attempt( + { + operation: "pictureInPicture.deliverFrame", + tabId, + webContentsId: wc.id, + }, + () => { + pictureInPictureWindow.webContents.send(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, frame); + }, + ); + yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { + if (sessions.get(tabId) !== frameSession) return sessions; + return replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...frameSession, + lastPictureInPictureFingerprint: fingerprint, + }); + }); + }); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Picture-in-picture frame delivery failed.", { + tabId, + error, + }), + ), + ); }); const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* ( @@ -3306,7 +3265,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), - Ref.set(recordingFrameListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, @@ -3354,8 +3312,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), - subscribeRecordingFrames: (listener: RecordingFrameListener) => - subscribe(recordingFrameListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")), zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")), @@ -3597,9 +3553,6 @@ export class PreviewManager extends Context.Service< readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; - readonly subscribeRecordingFrames: ( - listener: RecordingFrameListener, - ) => Effect.Effect; } >()("@t3tools/desktop/preview/Manager/PreviewManager") {} @@ -3733,7 +3686,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, - subscribeRecordingFrames: operations.subscribeRecordingFrames, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 51c3d31daae85..ea351b6ceb2f4 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,8 +34,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, confirmThreadUnpin: false, contextWindowMeterEnabled: false, - composerCollapseOnScroll: true, - dismissedProviderUpdateNotificationKeys: [], diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", @@ -64,7 +62,8 @@ const clientSettings: ClientSettings = { }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, + boringSidebarEnabled: false, + boringSidebarMultilineRows: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index c80fe9b87cd6a..25c37ecefc051 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -95,6 +95,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; @@ -450,6 +451,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 30edacb073c36..e31d347084209 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -1,5 +1,6 @@ // @effect-diagnostics globalTimers:off -- Capture timeouts and Electron overlay animation timers run at native callback boundaries outside Effect fibers. +import { pipe } from "effect/Function"; import { DEFAULT_CLIENT_SETTINGS, DesktopPendingSnapShot, @@ -605,7 +606,7 @@ function createSnapShotFlashWindow(bounds: Electron.Rectangle): Electron.BaseWin return window; } -export class SnapShotFlash { +class SnapShotFlash { private flashWindow: Electron.BaseWindow | undefined; private animationTimer: ReturnType | undefined; private closeTimer: ReturnType | undefined; @@ -1288,10 +1289,10 @@ export const make = Effect.gen(function* () { if (isModifierPairShortcut(shortcut)) { registered = yield* Effect.tryPromise(() => startPairShortcutProcess(snapShotShortcutModifierPair(shortcut), onCurrentShortcut, () => { - void runPromise( - setShortcutFailure( - snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), - ), + void pipe( + snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), + setShortcutFailure, + runPromise, ).catch(() => undefined); }), ).pipe( diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 26ae8fbf9ebe9..7c63a8b9aadef 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -8,8 +8,6 @@ import { accessibleWindowText, compactAccessibilityTree, findAccessibleWindow, - findCaptureSource, - hideAndWaitForBlur, isWaylandSession, } from "./snapShot.ts"; @@ -450,68 +448,6 @@ describe("findAccessibleWindow", () => { }); }); -describe("hideAndWaitForBlur", () => { - it("waits for a delayed blur after hiding the window", async () => { - let blur: (() => void) | undefined; - let settled = false; - const hidden = hideAndWaitForBlur({ - hide: () => undefined, - once: (_event, listener) => { - blur = listener; - }, - removeListener: () => undefined, - }).then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(settled).toBe(false); - blur?.(); - await hidden; - expect(settled).toBe(true); - }); -}); - -describe("findCaptureSource", () => { - const sources = [ - { id: "window:42:0", name: "Terminal" }, - { id: "window:84:0", name: "Editor" }, - ]; - - it("matches the native window id before its title", () => { - expect( - findCaptureSource(sources, { - id: 84, - title: "Changed title", - }), - ).toEqual(sources[1]); - }); - - it("falls back to a unique title match", () => { - expect( - findCaptureSource(sources, { - id: 100, - title: "Terminal", - }), - ).toEqual(sources[0]); - }); - - it("does not guess when a title is ambiguous", () => { - expect( - findCaptureSource( - [ - { id: "window:42:0", name: "Editor" }, - { id: "window:84:0", name: "Editor" }, - ], - { - id: 100, - title: "Editor", - }, - ), - ).toBeUndefined(); - }); -}); - describe("isWaylandSession", () => { effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 28bd301c557e6..872f99e1d6a72 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -23,7 +23,6 @@ interface AccessibilityTreeNode { } const MAX_ACCESSIBILITY_TREE_NODES = 10_000; -const WINDOW_BLUR_TIMEOUT_MS = 1_000; export const WINDOWS_MODIFIER_PAIR_VIRTUAL_KEYS: Record< SnapShotModifier, @@ -438,25 +437,6 @@ export async function accessibleWindowElementTree( return { root: compacted.root, truncated: completedTruncated }; } -export function hideAndWaitForBlur(window: { - readonly hide: () => void; - readonly once: (event: "blur", listener: () => void) => unknown; - readonly removeListener: (event: "blur", listener: () => void) => unknown; -}): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - window.removeListener("blur", onBlur); - reject(new Error("Timed out waiting for T3 Code to lose focus.")); - }, WINDOW_BLUR_TIMEOUT_MS); - const onBlur = () => { - clearTimeout(timeout); - resolve(); - }; - window.once("blur", onBlur); - window.hide(); - }); -} - type WindowBounds = { readonly x: number; readonly y: number; @@ -591,30 +571,6 @@ export function toElectronAccelerator(shortcut: SnapShotKeyChord): string { return parts.join("+"); } -interface CaptureSourceLike { - readonly id: string; - readonly name: string; -} - -interface ActiveWindowLike { - readonly id: number; - readonly title: string; -} - -export function findCaptureSource( - sources: readonly T[], - activeWindow: ActiveWindowLike, -): T | undefined { - const idPrefix = `window:${activeWindow.id}:`; - const idMatch = sources.find((source) => source.id.startsWith(idPrefix)); - if (idMatch) return idMatch; - - const title = activeWindow.title.trim(); - if (!title) return undefined; - const titleMatches = sources.filter((source) => source.name.trim() === title); - return titleMatches.length === 1 ? titleMatches[0] : undefined; -} - export function isWaylandSession( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 3d6556ffae9a3..aeda73f2bfad3 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -156,8 +156,6 @@ export class DesktopUpdateUnexpectedActionError extends Schema.TaggedError; readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; - readonly configure: Effect.Effect; + readonly configure: Effect.Effect; readonly setChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index df7e1fd2e85ac..9d8efd056665b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -199,6 +199,41 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("never binds Cmd/Ctrl+W to closing the window", () => + Effect.gen(function* () { + for (const platform of ["darwin", "linux", "win32"] as const) { + const applicationMenuTemplate = + yield* Deferred.make(); + yield* configureMenu(yield* Deferred.make(), applicationMenuTemplate, { + platform, + }); + + const items: Electron.MenuItemConstructorOptions[] = []; + const collect = (template: readonly Electron.MenuItemConstructorOptions[]) => { + for (const item of template) { + items.push(item); + if (Array.isArray(item.submenu)) collect(item.submenu); + } + }; + collect(yield* Deferred.await(applicationMenuTemplate)); + + assert.isUndefined( + items.find((item) => item.role === "close"), + platform, + ); + // Electron's default windowMenu submenu includes a close role. + assert.isUndefined( + items.find((item) => item.role === "windowMenu" && item.submenu === undefined), + platform, + ); + assert.isUndefined( + items.find((item) => item.accelerator?.toLowerCase().endsWith("+w")), + platform, + ); + } + }), + ); + it.effect("reloads the main window even while a browser page has focus", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 7c70656c1ac4c..07fd8654af6be 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -200,7 +200,10 @@ export const make = Effect.gen(function* () { }, { type: "separator" as const }, ]), - { role: environment.platform === "darwin" ? "close" : "quit" }, + // Cmd/Ctrl+W belongs to the renderer's tab close; it must never close the window. + environment.platform === "darwin" + ? { label: "Close Window", click: (_item, window) => window?.close() } + : { role: "quit" }, ], }, { @@ -289,7 +292,17 @@ export const make = Effect.gen(function* () { { role: "togglefullscreen" }, ], }, - { role: "windowMenu" }, + { + // Electron's default Window submenu adds a Ctrl+W close role off macOS. + role: "windowMenu", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(environment.platform === "darwin" + ? [{ type: "separator" as const }, { role: "front" as const }] + : []), + ], + }, { role: "help", submenu: [ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6270f80a4a7e6..ab5fc8be26a92 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -131,7 +131,7 @@ function getIconOption( } function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { - return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; + return shouldUseDarkColors ? "#000000" : "#ffffff"; } type DisplayBounds = Pick; @@ -619,7 +619,6 @@ export const make = Effect.gen(function* () { } }); - // Terminal-close repeats can outlive the terminal; reject them before the native close-window accelerator. const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, getMode: () => @@ -645,6 +644,7 @@ export const make = Effect.gen(function* () { }); window.webContents.on("before-input-event", (event, input) => { quitShortcutHandler(event, input); + // A held Cmd/Ctrl+W closes one tab or terminal, not each one behind it. if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 36939729682ba..ab52ca07a0d59 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -437,10 +437,8 @@ cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at // launch (which, in wsl-only mode, would just fail to launch with no fallback). // -// The sentinel must be a package the CLI bundle leaves external. It used to be -// "effect", back when the bundle externalized its runtime deps and the whole -// node_modules tree was unpacked. The bundle now inlines its JS dependencies, -// so "effect" no longer exists on disk and only the native packages do. +// The sentinel must be a package the CLI bundle leaves external. The bundle +// inlines its JS dependencies, so only the native packages exist on disk. // Resolving node-pty is what actually validates the unpacked tree. try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp deleted file mode 100644 index a05a5738a6cd7..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp deleted file mode 100644 index 1a16b037cf0b0..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp deleted file mode 100644 index dd72a9110265c..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp deleted file mode 100644 index 3aa1c274507b9..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp deleted file mode 100644 index e2510c829bb15..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp deleted file mode 100644 index f3ba3edb51909..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp deleted file mode 100644 index 8971323e7af55..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp deleted file mode 100644 index aa535e600a573..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp and /dev/null differ diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 92e587f171c7b..ea885f819984f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -17,6 +17,12 @@ const runtimeVersionPolicy = const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; +// Compiled into the native project. Without a relay, Observe's native side could dispatch to +// Expo's own ingest before observe.ts turns it off; `.invalid` never resolves (RFC 6761). +const observeEndpointUrl = repoEnv.T3CODE_RELAY_URL?.trim() + ? new URL("/v1/mobile/observe", repoEnv.T3CODE_RELAY_URL.trim()).toString() + : "https://observe.invalid"; + const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; const androidAdaptiveForeground = "./assets/android-icon-foreground.png"; @@ -220,7 +226,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.1.0", + version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.2.0", runtimeVersion: { // Skip costly fingerprinting on dev launches; preview and production OTAs require matching native projects. policy: runtimeVersionPolicy, @@ -419,7 +425,7 @@ const config: ExpoConfig = { ["expo-sensors", { motionPermission: false }], // Mods run in reverse order; expo-widgets must create and wipe the target before assets are added. ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []), - "./plugins/withAndroidCleartextTraffic.cjs", + "./plugins/withAndroidNetworkSecurityConfig.cjs", "./plugins/withAndroidGradleHeap.cjs", "./plugins/withAndroidInputBackground.cjs", "./plugins/withAndroidModernPopupMenu.cjs", @@ -450,6 +456,7 @@ const config: ExpoConfig = { }, eas: { projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", + observe: { endpointUrl: observeEndpointUrl }, }, }, owner: "pingdotgg", diff --git a/apps/mobile/assets/context-chip-icons/server.png b/apps/mobile/assets/context-chip-icons/server.png new file mode 100644 index 0000000000000..92bc2ce457f16 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/server.png differ diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index 46b0539e5c97b..939ac2d9147ed 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,3 +1,3 @@ -The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), and development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)). +The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)), and `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [md4c#5](https://github.com/software-mansion-labs/md4c/pull/5)). To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz new file mode 100644 index 0000000000000..06ac560ff2eba Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz differ diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz deleted file mode 100644 index 3cf77c4adf971..0000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz deleted file mode 100644 index 2e076b177f3f8..0000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz new file mode 100644 index 0000000000000..f1eb3858e9865 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index f694fc8d6ef26..f773c2c5bbc3a 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -66,7 +66,8 @@ "corepack": true, "env": { "APP_VARIANT": "production", - "NODE_OPTIONS": "--max-old-space-size=4096" + "NODE_OPTIONS": "--max-old-space-size=4096", + "SOURCEMAP_FILE": "/tmp/t3code-ios-main.jsbundle.map" }, "channel": "production", "environment": "production", diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 6aac4869d9717..9367ade408e95 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,8 @@ -// Must stay first: the render counter wraps the DevTools hook before any React commit. +// Must stay first: the render counter and behavior probe wrap the DevTools hook before any React commit. import "./src/features/development/render-counter-entry"; +import "./src/features/development/behavior-probe-entry"; +// Next, so errors thrown while the rest of the app loads are recorded too. +import "./src/features/observability/observe"; import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; import { LogBox } from "react-native"; diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index afbef3362d6d5..6371da34fd190 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -56,10 +56,6 @@ async function generateMobileThirdPartyLicenses() { const manifest = await generateThirdPartyLicenseManifest({ configFile: path.join(workspaceRoot, "third-party-licenses.config.json"), packageManifests: [{ bundle: "mobile", path: path.join(__dirname, "package.json") }], - allowMissingGeneratedNotices: - process.env.NODE_ENV !== "production" && - process.env.EAS_BUILD !== "true" && - process.env.T3CODE_LICENSES_STRICT !== "1", }); await Promise.all([ diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index 315d25c130072..736f7cd3019f0 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable @@ -71,6 +72,11 @@ class T3WorkspaceNavigationModule : Module() { Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String -> view.selectedTab = value } + Prop("floating") { view: T3WorkspaceNavigationView, value: Boolean -> + view.floating = value + view.clipToPadding = !value + view.clipChildren = !value + } Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value } Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value } Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value } @@ -123,6 +129,7 @@ class T3WorkspaceNavigationView( var tabs by mutableStateOf(listOf("threads", "pullRequests", "usage", "settings")) var selectedTab by mutableStateOf("threads") var rail by mutableStateOf(false) + var floating by mutableStateOf(false) var dark by mutableStateOf(false) var textScale by mutableStateOf(1f) var newEnabled by mutableStateOf(false) @@ -161,6 +168,7 @@ class T3WorkspaceNavigationView( init { addView( ComposeView(context).apply { + clipChildren = false setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool ) @@ -168,6 +176,8 @@ class T3WorkspaceNavigationView( MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) { if (rail) { rail() + } else if (floating) { + floatingTabs() } else { NavigationBar( modifier = Modifier.fillMaxSize(), @@ -199,6 +209,48 @@ class T3WorkspaceNavigationView( ) } + @Composable + private fun floatingTabs() { + Surface( + modifier = Modifier.fillMaxSize().padding(8.dp), + shape = CircleShape, + color = Color(containerColor), + shadowElevation = 6.dp, + ) { + Row( + modifier = Modifier.fillMaxSize().selectableGroup(), + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEach { tab -> + val selected = selectedTab == tab + val tint = Color(if (selected) selectedColor else unselectedColor) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(CircleShape) + .selectable( + selected = selected, + role = Role.Tab, + onClick = { onTabSelected(mapOf("tab" to tab)) }, + ) + .semantics { contentDescription = expandedTabTitle(tab) } + .padding(4.dp) + .background(if (selected) Color(indicatorColor) else Color.Transparent, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + tabIconPainter(tab), + contentDescription = null, + modifier = Modifier.size((24 * textScale).dp), + tint = tint, + ) + } + } + } + } + } + @Composable private fun rail() { // One rail surface widens in place over the list. Material's ModalWideNavigationRail @@ -232,6 +284,7 @@ class T3WorkspaceNavigationView( }, ) { val fraction = progress.coerceIn(0f, 1f) + val railShape = RoundedCornerShape(topEnd = 16.dp * fraction, bottomEnd = 16.dp * fraction) if (fraction > 0f) { Scrim( contentDescription = "Close navigation rail", @@ -243,8 +296,8 @@ class T3WorkspaceNavigationView( Modifier .fillMaxHeight() .width(lerp(RAIL_WIDTH, EXPANDED_RAIL_WIDTH, progress.coerceAtLeast(0f))) - .shadow(RAIL_SHADOW * fraction, RAIL_EXPANDED_SHAPE, clip = false) - .background(Color(containerColor), RAIL_EXPANDED_SHAPE) + .shadow(RAIL_SHADOW * fraction, railShape, clip = false) + .background(Color(containerColor), railShape) .padding(top = safeAreaTop.dp, bottom = safeAreaBottom.dp + 12.dp) .selectableGroup(), ) { @@ -267,11 +320,14 @@ class T3WorkspaceNavigationView( ) } } + // Other tabs keep the button's slot so the destinations below never move between tabs. if (selectedTab == "threads") { newThreadButton(fraction) { expanded = false onNewThread(emptyMap()) } + } else { + Spacer(Modifier.padding(top = 12.dp).height(FAB_SIZE)) } Spacer(Modifier.weight(1f)) tabs.forEach { tab -> @@ -385,7 +441,6 @@ class T3WorkspaceNavigationView( val FAB_SIZE = 48.dp val ICON_SIZE = 24.dp val RAIL_SHADOW = 6.dp - val RAIL_EXPANDED_SHAPE = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp) const val DISABLED_ALPHA = 0.4f // Labels fade in over the second half of the widening, once there is room for them, and diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index 2d92537bf4312..549331a56bef8 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -53,7 +53,7 @@ public final class T3KeyboardCommandsView: ExpoView { input: "c", modifiers: [.command, .shift], action: #selector(copyThreadReference), - title: "Copy PR Link or Thread ID" + title: "Copy PR Link or Thread" ), enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), enabledCommand("cycleHost", input: "h", modifiers: [.command, .shift], action: #selector(cycleHost), title: "Next Machine"), diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 8426ddb565a40..8cb7ad17e0d0b 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -1423,13 +1423,24 @@ private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f } +// A single-line preview never shows this many characters, so longer text is cut before measuring. +private const val ELLIPSIZE_MAX_CHARS = 500 + private fun ellipsize(value: String, paint: Paint, width: Float): String { - if (paint.measureText(value) <= width) return value + val truncated = value.length > ELLIPSIZE_MAX_CHARS + val capEnd = surrogateSafeEnd(value, ELLIPSIZE_MAX_CHARS) + val text = if (truncated) value.substring(0, capEnd) else value + if (!truncated && paint.measureText(text) <= width) return value val suffix = "..." val available = max(0f, width - paint.measureText(suffix)) - var end = value.length - while (end > 0 && paint.measureText(value, 0, end) > available) end -= 1 - return value.substring(0, end) + suffix + val end = surrogateSafeEnd(text, paint.breakText(text, true, available, null)) + return text.substring(0, end) + suffix +} + +private fun surrogateSafeEnd(value: String, end: Int): Int { + val splitsPair = end in 1 until value.length && + Character.isSurrogatePair(value[end - 1], value[end]) + return if (splitsPair) end - 1 else end } private fun indexFileHeaders(rows: List): IntArray { diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index b8c92f2d9332f..703bcdf361b49 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -66,8 +66,16 @@ class T3TerminalModule : Module() { Prop("captureRequest") { view: T3TerminalView, request: Double -> view.captureRequest = request } + + Prop("sharedGrid") { view: T3TerminalView, sharedGrid: List -> + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") + OnViewDidUpdateProps { view: T3TerminalView -> + view.applyPendingBuffer() + } + OnViewDestroys { view: T3TerminalView -> view.cleanup() } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index f4de25460d5c6..1deb3a6170350 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -58,6 +58,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private var streamedOutput: TerminalOutputBuffer? = null private var cols = 0 private var rows = 0 + private var measuredCols = 0 + private var measuredRows = 0 private var clearingInput = false private var isCleanedUp = false private var backgroundColorValue = Color.parseColor("#24292E") @@ -74,12 +76,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex recreateTerminal() } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" set(value) { if (field == value) return field = value - feedPendingBuffer() + hasPendingBuffer = true } + private var hasPendingBuffer = false var fontSize: Float = 10f set(value) { @@ -91,6 +97,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex var appearanceScheme: String = "dark" + // The PTY grid shared by every attached client, or empty before the server reports one. + // The terminal draws this grid, clamped to the view's own fit (the server's grid lags a + // local resize, and read-only observers never constrain it), so its wrapping and cursor + // match the PTY; the view's own fit still goes out through onResize. + var sharedGrid: List = emptyList() + set(value) { + if (field == value) return + field = value + applyGrid() + } + var themeConfig: String = "" set(value) { field = value @@ -327,6 +344,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex val nextRows = (terminalCanvas.usableHeight() / terminalCanvas.cellHeightPx) .toInt() .coerceIn(2, 200) + if (nextCols != measuredCols || nextRows != measuredRows) { + measuredCols = nextCols + measuredRows = nextRows + onResize(mapOf("cols" to measuredCols, "rows" to measuredRows)) + } + applyGrid() + } + + private fun applyGrid() { + if (measuredCols <= 0 || measuredRows <= 0 || isCleanedUp) return + val shared = sharedGrid.takeIf { it.size == 2 && it[0] > 0 && it[1] > 0 } + val nextCols = shared?.get(0)?.coerceAtMost(measuredCols) ?: measuredCols + val nextRows = shared?.get(1)?.coerceAtMost(measuredRows) ?: measuredRows if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return cols = nextCols rows = nextRows @@ -343,8 +373,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex ) } emitResponse(response) - onResize(mapOf("cols" to cols, "rows" to rows)) - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -369,7 +398,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (terminalHandle == 0L) return destroyTerminal() createTerminal() - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -381,6 +410,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalCanvas.resetSelectionState() } + fun applyPendingBuffer() { + if (!hasPendingBuffer) return + hasPendingBuffer = false + feedPendingBuffer() + } + private fun feedPendingBuffer() { if (streamedOutput != null || terminalHandle == 0L || initialBuffer == fedBuffer) return if (!initialBuffer.startsWith(fedBuffer)) { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 2f7445265886c..d68e46a5416b1 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -63,7 +63,15 @@ public class T3TerminalModule: Module { Prop("captureRequest") { (view: T3TerminalView, request: Double) in view.captureRequest = request } + + Prop("sharedGrid") { (view: T3TerminalView, sharedGrid: [Int]) in + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") + + OnViewDidUpdateProps { (view: T3TerminalView) in + view.applyPendingBuffer() + } } } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 364880aaeb39b..0ae146bb1241a 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -243,9 +243,23 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" { didSet { - applyRemoteBuffer(initialBuffer) + hasPendingBuffer = true + } + } + private var hasPendingBuffer = false + + // The PTY grid shared by every attached client, or empty before the server reports one. + // The surface draws this grid so its wrapping and cursor match the PTY; the view's own + // fit still goes out through onResize. + var sharedGrid: [Int] = [] { + didSet { + guard oldValue != sharedGrid else { return } + resizeSurface() } } @@ -529,7 +543,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { resizeSurface() if let streamedOutput { feedData(streamedOutput.data) - } else { + } else if !hasPendingBuffer { feedBuffer(initialBuffer) } } @@ -559,6 +573,19 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } surface = nil app = nil + // Ghostty adds its IOSurface layer to the viewport on iOS but never removes it on + // free, leaving the last frame on screen under the next surface's layer. The + // viewport has no subviews, so every sublayer belongs to a freed surface. + CATransaction.begin() + CATransaction.setDisableActions(true) + terminalViewport.layer.sublayers?.forEach { $0.removeFromSuperlayer() } + CATransaction.commit() + } + + func applyPendingBuffer() { + guard hasPendingBuffer else { return } + hasPendingBuffer = false + applyRemoteBuffer(initialBuffer) } private func applyRemoteBuffer(_ buffer: String) { @@ -647,10 +674,25 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { terminalViewport.contentScaleFactor = scale ghostty_surface_set_content_scale(surface, Double(scale), Double(scale)) ghostty_surface_set_size(surface, width, height) + let fitted = ghostty_surface_size(surface) + let fittedCols = max(1, Int(fitted.columns)) + let fittedRows = max(1, Int(fitted.rows)) + // Trimming whole cells keeps ghostty's padding, and the surface can only shrink + // within the view, so a stale larger grid falls back to the fitted one. + var surfaceWidth = width + var surfaceHeight = height + if sharedGrid.count == 2, sharedGrid[0] > 0, sharedGrid[1] > 0 { + surfaceWidth -= UInt32(max(fittedCols - sharedGrid[0], 0)) * fitted.cell_width_px + surfaceHeight -= UInt32(max(fittedRows - sharedGrid[1], 0)) * fitted.cell_height_px + if surfaceWidth != width || surfaceHeight != height { + ghostty_surface_set_size(surface, surfaceWidth, surfaceHeight) + } + } ghostty_surface_set_occlusion(surface, window != nil) - configureIOSurfaceLayers() + configureIOSurfaceLayers( + size: CGSize(width: CGFloat(surfaceWidth) / scale, height: CGFloat(surfaceHeight) / scale)) redrawSurface() - emitGhosttyResize() + emitResize(cols: fittedCols, rows: fittedRows) } private func redrawSurface() { @@ -658,19 +700,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_surface_refresh(surface) ghostty_surface_draw(surface) markIOSurfaceLayersForDisplay() - emitGhosttyResize() - } - - private func emitGhosttyResize() { - guard let surface else { - emitEstimatedResize() - return - } - - let size = ghostty_surface_size(surface) - let cols = max(1, Int(size.columns)) - let rows = max(1, Int(size.rows)) - emitResize(cols: cols, rows: rows) } private func emitEstimatedResize() { @@ -721,8 +750,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_app_keyboard_changed(app) } - private func configureIOSurfaceLayers() { - let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size) + private func configureIOSurfaceLayers(size: CGSize) { + let targetBounds = CGRect(origin: .zero, size: size) CATransaction.begin() CATransaction.setDisableActions(true) terminalViewport.layer.sublayers?.forEach { sublayer in diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d60f6117e9967..f5c1c6521812e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -46,6 +46,7 @@ "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", "generate:highlights": "node scripts/generate-highlights.mts", + "eas-build-on-success": "node scripts/upload-sourcemaps.mts build", "test": "vp test run --project unit --project react", "typecheck": "tsc --noEmit" }, @@ -73,13 +74,6 @@ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-source-text": "file:./modules/t3-source-text", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", - "@t3tools/provider-acp-registry": "workspace:*", - "@t3tools/provider-core": "workspace:*", - "@t3tools/provider-cursor": "workspace:*", - "@t3tools/provider-grok": "workspace:*", - "@t3tools/provider-muse": "workspace:*", - "@t3tools/provider-opencode": "workspace:*", - "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tabler/icons-react-native": "^3.44.0", "clsx": "^2.1.1", @@ -110,6 +104,7 @@ "expo-linking": "~58.0.13", "expo-network": "~58.0.3", "expo-notifications": "~58.1.5", + "expo-observe": "~58.0.15", "expo-paste-input": "^0.1.15", "expo-quick-actions": "^6.0.2", "expo-secure-store": "~58.0.2", @@ -126,7 +121,7 @@ "react-dom": "catalog:", "react-native": "0.88.0", "react-native-blob-util": "0.25.1", - "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz", + "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz", "react-native-gesture-handler": "~3.3.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.22.8", @@ -134,7 +129,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.45.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.48.tgz", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", "react-native-worklets": "0.13.0", @@ -152,6 +147,7 @@ "@types/mdast": "^4.0.4", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "aws4fetch": "1.0.20", "babel-preset-expo": "~58.0.17", "jsdom": "^30.0.1", "tailwindcss": "^4.0.0", diff --git a/apps/mobile/plugins/withAndroidCleartextTraffic.cjs b/apps/mobile/plugins/withAndroidCleartextTraffic.cjs deleted file mode 100644 index f612f751e9151..0000000000000 --- a/apps/mobile/plugins/withAndroidCleartextTraffic.cjs +++ /dev/null @@ -1,18 +0,0 @@ -const { withAndroidManifest } = require("expo/config-plugins"); - -module.exports = function withAndroidCleartextTraffic(config) { - return withAndroidManifest(config, (nextConfig) => { - const application = nextConfig.modResults.manifest.application?.[0]; - - if (application == null) { - throw new Error( - "AndroidManifest.xml is missing the application element required for cleartext traffic configuration.", - ); - } - - application.$ ??= {}; - application.$["android:usesCleartextTraffic"] = "true"; - - return nextConfig; - }); -}; diff --git a/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs b/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs new file mode 100644 index 0000000000000..90fea189e8d01 --- /dev/null +++ b/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs @@ -0,0 +1,56 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { withAndroidManifest, withDangerousMod } = require("expo/config-plugins"); + +// Environments behind a private CA installed in the user store must work like they do in Chrome. +// Android ignores usesCleartextTraffic once a network security config exists, so permit it here. +const NETWORK_SECURITY_CONFIG = ` + + + + + + + + +`; + +function withNetworkSecurityConfigManifest(config) { + return withAndroidManifest(config, (nextConfig) => { + const application = nextConfig.modResults.manifest.application?.[0]; + + if (application == null) { + throw new Error( + "AndroidManifest.xml is missing the application element required for network security configuration.", + ); + } + + application.$ ??= {}; + application.$["android:networkSecurityConfig"] = "@xml/network_security_config"; + + return nextConfig; + }); +} + +function withNetworkSecurityConfigResource(config) { + return withDangerousMod(config, [ + "android", + async (config) => { + const xmlDir = path.join( + config.modRequest.platformProjectRoot, + "app", + "src", + "main", + "res", + "xml", + ); + fs.mkdirSync(xmlDir, { recursive: true }); + fs.writeFileSync(path.join(xmlDir, "network_security_config.xml"), NETWORK_SECURITY_CONFIG); + return config; + }, + ]); +} + +module.exports = function withAndroidNetworkSecurityConfig(config) { + return withNetworkSecurityConfigResource(withNetworkSecurityConfigManifest(config)); +}; diff --git a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift b/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift deleted file mode 100644 index db279d0c62e75..0000000000000 --- a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation - -enum NWInterface { - enum InterfaceType { - case other, wifi, cellular, loopback, wiredEthernet - } -} - -struct NWPath { - enum Status { - case satisfied, unsatisfied - } - - // A reference inside the value models the native path's retained storage. - final class Storage { - let type: NWInterface.InterfaceType - init(_ type: NWInterface.InterfaceType) { self.type = type } - } - - let status: Status - let storage: Storage - - init(_ type: NWInterface.InterfaceType, status: Status = .satisfied) { - self.status = status - storage = Storage(type) - } - - func usesInterfaceType(_ type: NWInterface.InterfaceType) -> Bool { - storage.type == type - } -} - -final class NWPathMonitor { - static var scenario = "first" - static let deliveries = DispatchGroup() - var pathUpdateHandler: ((NWPath) -> Void)? - - func start(queue: DispatchQueue) { - guard let handler = pathUpdateHandler else { fatalError("Missing path handler") } - if Self.scenario == "offline" { - handler(NWPath(.wifi, status: .unsatisfied)) - } else if Self.scenario == "first" { - handler(NWPath(.wifi)) - handler(NWPath(.cellular)) - } else { - queue.async(group: Self.deliveries) { - handler(NWPath(.wifi)) - DispatchQueue.concurrentPerform(iterations: 128) { index in - handler(NWPath(index.isMultiple(of: 2) ? .cellular : .wiredEthernet)) - } - } - } - } - - // Already queued callbacks can run after cancellation. - func cancel() {} -} - -@main -enum NetworkModuleRegression { - static func main() { - NWPathMonitor.scenario = CommandLine.arguments[1] - let module = NetworkModule() - if NWPathMonitor.scenario == "concurrent" { - DispatchQueue.concurrentPerform(iterations: 64) { _ in - let state = module.getNetworkStateAsync() - precondition(state["type"] as? String == "WIFI", "The first path must survive late callbacks") - precondition(state["isConnected"] as? Bool == true) - } - NWPathMonitor.deliveries.wait() - } else { - let state = module.getNetworkStateAsync() - let connected = NWPathMonitor.scenario != "offline" - precondition(state["type"] as? String == (connected ? "WIFI" : "NONE")) - precondition(state["isConnected"] as? Bool == connected) - precondition(state["isInternetReachable"] as? Bool == connected) - } - print("passed") - } -} diff --git a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift deleted file mode 100644 index 6d13972d5742e..0000000000000 --- a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift +++ /dev/null @@ -1,189 +0,0 @@ -import Foundation - -// The registry runs on macOS without starting a simulator. Only the OS-facing -// types are replaced; the tests compile the dependency's actual Swift source. -public enum UIBackgroundFetchResult { - case noData -} - -public struct UNNotificationPresentationOptions: OptionSet { - public let rawValue: Int - public init(rawValue: Int) { self.rawValue = rawValue } -} - -public final class UNNotification: NSObject {} - -public final class UNNotificationResponse: NSObject { - let identifier: String - init(_ identifier: String) { self.identifier = identifier } -} - -public protocol UNUserNotificationCenterDelegate: AnyObject {} - -public final class UNUserNotificationCenter: NSObject { - private static let instance = UNUserNotificationCenter() - public weak var delegate: UNUserNotificationCenterDelegate? - public static func current() -> UNUserNotificationCenter { instance } -} - -private final class TestDelegate: NotificationDelegate { - private let lock = NSLock() - private var recordedEvents: [String] = [] - var onEvent: ((String) -> Void)? - var onResponse: ((UNNotificationResponse) -> Bool)? - - var events: [String] { lock.withLock { recordedEvents } } - - private func record(_ event: String) { - lock.withLock { recordedEvents.append(event) } - onEvent?(event) - } - - func didRegister(_ deviceToken: String) { record("registered") } - func didFailRegistration(_ error: Error) { record("failed") } - func openSettings(_ notification: UNNotification?) { record("settings") } - - func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { - record("present") - return false - } - - func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { - record("background") - return false - } - - func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { - record(response.identifier) - return onResponse?(response) ?? false - } -} - -private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { - if !condition() { - throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) - } -} - -private func deliver(_ identifier: String) { - NotificationCenterManager.shared.userNotificationCenter( - UNUserNotificationCenter.current(), - didReceive: UNNotificationResponse(identifier), - withCompletionHandler: {} - ) -} - -private func reentrantCallbacks() throws { - let manager = NotificationCenterManager.shared - let center = UNUserNotificationCenter.current() - let callbacks: [(String, () -> Void)] = [ - ("registered", { manager.didRegister("token") }), - ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }), - ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }), - ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }), - ("background", { manager.didReceive([:], completionHandler: { _ in }) }) - ] - - for (event, callback) in callbacks { - let original = TestDelegate() - let replacement = TestDelegate() - original.onEvent = { [weak original] _ in - guard let original else { return } - manager.removeDelegate(original) - manager.addDelegate(replacement) - } - manager.addDelegate(original) - callback() - try require(original.events == [event], "The original delegate must receive the callback once") - try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot") - callback() - try require(replacement.events == [event], "Reentrant registration must take effect for the next callback") - manager.removeDelegate(replacement) - } -} - -private func registrationDuringDelivery() throws { - let manager = NotificationCenterManager.shared - let original = TestDelegate() - let receiver = TestDelegate() - receiver.onResponse = { _ in true } - original.onResponse = { _ in - manager.addDelegate(receiver) - return false - } - manager.addDelegate(original) - deliver("handoff") - try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response") - manager.removeDelegate(original) - manager.removeDelegate(receiver) -} - -private func responseDuringReplay() throws { - let manager = NotificationCenterManager.shared - deliver("first") - let original = TestDelegate() - original.onResponse = { response in - if response.identifier == "first" { - deliver("second") - return true - } - return false - } - manager.addDelegate(original) - let receiver = TestDelegate() - receiver.onResponse = { _ in true } - manager.addDelegate(receiver) - try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks") - manager.removeDelegate(original) - manager.removeDelegate(receiver) -} - -private func concurrentRegistrations() throws { - let manager = NotificationCenterManager.shared - let delegates = (0..<512).map { _ in - let delegate = TestDelegate() - delegate.onResponse = { _ in true } - return delegate - } - DispatchQueue.concurrentPerform(iterations: delegates.count) { index in - manager.addDelegate(delegates[index]) - if index.isMultiple(of: 16) { - manager.didRegister("during-add") - deliver("during-add") - } - } - let before = delegates.map { $0.events.count } - manager.didRegister("after-add") - for (index, delegate) in delegates.enumerated() { - try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once") - } - DispatchQueue.concurrentPerform(iterations: delegates.count) { index in - manager.removeDelegate(delegates[index]) - if index.isMultiple(of: 16) { - manager.didRegister("during-remove") - deliver("during-remove") - } - } - let removed = delegates.map { $0.events.count } - manager.didRegister("after-remove") - try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks") -} - -@main -private enum RegressionTests { - static func main() { - do { - switch CommandLine.arguments.last { - case "reentrant": try reentrantCallbacks() - case "handoff": try registrationDuringDelivery() - case "pending": try responseDuringReplay() - case "concurrent": try concurrentRegistrations() - default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2) - } - print("passed") - } catch { - FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) - exit(1) - } - } -} diff --git a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m b/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m deleted file mode 100644 index 7124178383d49..0000000000000 --- a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m +++ /dev/null @@ -1,55 +0,0 @@ -@interface TestPermissionRequester : NSObject -@end - -@implementation TestPermissionRequester -+ (NSString *)permissionType { return NSStringFromClass(self); } -- (NSDictionary *)getPermissions { return @{ @"status": @(EXPermissionStatusGranted) }; } -- (void)requestPermissionsWithResolver:(EXPromiseResolveBlock)resolve rejecter:(EXPromiseRejectBlock)reject { - resolve([self getPermissions]); -} -@end - -typedef struct { - __unsafe_unretained EXPermissionsService *service; - __unsafe_unretained NSArray *requesters; - size_t offset; -} Worker; - -static void *exerciseRegistry(void *context) { - Worker *worker = context; - for (size_t index = 0; index < 250; index++) { - @autoreleasepool { - id requester = worker->requesters[(index + worker->offset) % worker->requesters.count]; - [worker->service registerRequesters:@[requester]]; - id resolved = [worker->service getPermissionRequesterForType:[[requester class] permissionType]]; - assert(resolved == requester); - NSDictionary *permissions = [worker->service getPermissionUsingRequesterClass:[requester class]]; - assert([permissions[@"status"] isEqualToString:@"granted"]); - } - } - return NULL; -} - -int main(void) { - @autoreleasepool { - EXPermissionsService *service = [EXPermissionsService new]; - NSMutableArray *requesters = [NSMutableArray new]; - for (int i = 0; i < 32; i++) { - NSString *name = [NSString stringWithFormat:@"TestPermission%d", i]; - Class cls = objc_allocateClassPair([TestPermissionRequester class], name.UTF8String, 0); - objc_registerClassPair(cls); - [requesters addObject:[cls new]]; - } - Worker workers[8]; - pthread_t threads[8]; - for (size_t index = 0; index < 8; index++) { - workers[index] = (Worker){ service, requesters, index }; - assert(pthread_create(&threads[index], NULL, exerciseRegistry, &workers[index]) == 0); - } - for (size_t index = 0; index < 8; index++) { - assert(pthread_join(threads[index], NULL) == 0); - } - puts("passed"); - } - return 0; -} diff --git a/apps/mobile/scripts/sourcemap-upload.mts b/apps/mobile/scripts/sourcemap-upload.mts new file mode 100644 index 0000000000000..a962f98a37805 --- /dev/null +++ b/apps/mobile/scripts/sourcemap-upload.mts @@ -0,0 +1,165 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { mobileSourceMapKey, type MobilePlatform } from "@t3tools/shared/mobileSourceMaps"; +import { AwsClient } from "aws4fetch"; + +export interface SourceMapUpload { + readonly key: string; + readonly path: string; +} + +/** The `MOBILE_SOURCEMAPS_R2` secret: the relay stack's `mobileSourceMapsUploadCredentials`. */ +interface UploadCredentials { + readonly endpoint: string; + readonly bucketName: string; + readonly accessKeyId: string; + readonly secretAccessKey: string; +} + +const isPlatform = (value: unknown): value is MobilePlatform => + value === "ios" || value === "android"; + +const readJson = (path: string): unknown => JSON.parse(NodeFS.readFileSync(path, "utf8")); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export function parseCredentials(value: string | undefined): UploadCredentials | undefined { + if (!value?.trim()) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + // JSON.parse's message quotes the input, which here is the secret. + throw new Error("MOBILE_SOURCEMAPS_R2 is not valid JSON."); + } + if (!isRecord(parsed)) throw new Error("MOBILE_SOURCEMAPS_R2 is not a JSON object."); + const { endpoint, bucketName, accessKeyId, secretAccessKey } = parsed; + if ( + typeof endpoint !== "string" || + typeof bucketName !== "string" || + typeof accessKeyId !== "string" || + typeof secretAccessKey !== "string" + ) { + throw new Error( + "MOBILE_SOURCEMAPS_R2 needs endpoint, bucketName, accessKeyId and secretAccessKey.", + ); + } + return { endpoint, bucketName, accessKeyId, secretAccessKey }; +} + +/** One map per update `eas update --emit-metadata` published, next to the bundle `metadata.json` names. */ +export function otaSourceMapUploads(distDir: string): ReadonlyArray { + const metadata = readJson(NodePath.join(distDir, "metadata.json")); + const easMetadata = readJson(NodePath.join(distDir, "eas-update-metadata.json")); + const fileMetadata = isRecord(metadata) ? metadata.fileMetadata : undefined; + const updates = isRecord(easMetadata) ? easMetadata.updates : undefined; + if (!isRecord(fileMetadata) || !Array.isArray(updates)) { + throw new Error(`${distDir} is missing Expo export or EAS update metadata.`); + } + return updates.flatMap((update): Array => { + if (!isRecord(update) || !isPlatform(update.platform) || typeof update.id !== "string") { + return []; + } + const platformFiles = fileMetadata[update.platform]; + const bundle = isRecord(platformFiles) ? platformFiles.bundle : undefined; + if (typeof bundle !== "string") return []; + return [ + { + key: mobileSourceMapKey(update.platform, "ota", update.id), + path: NodePath.join(distDir, `${bundle}.map`), + }, + ]; + }); +} + +/** Android's Gradle plugin writes the map under the release variant; iOS to `SOURCEMAP_FILE` (eas.json). */ +export function buildSourceMapUpload( + env: Readonly>, + projectDir: string, +): SourceMapUpload { + const platform = env.EAS_BUILD_PLATFORM; + const buildId = env.EAS_BUILD_ID; + if (!isPlatform(platform) || !buildId) { + throw new Error("EAS_BUILD_PLATFORM and EAS_BUILD_ID are only set on EAS Build."); + } + if (platform === "ios") { + if (!env.SOURCEMAP_FILE) throw new Error("SOURCEMAP_FILE is not set for this iOS build."); + return { key: mobileSourceMapKey("ios", "build", buildId), path: env.SOURCEMAP_FILE }; + } + const variantsDir = NodePath.join(projectDir, "android/app/build/generated/sourcemaps/react"); + const variant = NodeFS.existsSync(variantsDir) + ? NodeFS.readdirSync(variantsDir).find((name) => /release$/iu.test(name)) + : undefined; + if (!variant) throw new Error(`No release source map under ${variantsDir}.`); + return { + key: mobileSourceMapKey("android", "build", buildId), + path: NodePath.join(variantsDir, variant, "index.android.bundle.map"), + }; +} + +/** + * EAS writes the app's debug symbols next to the `.ipa`, so native frames in iOS crash + * reports can be read with `atos`. Undefined when the build left none. + */ +export function buildDsymUpload( + env: Readonly>, + projectDir: string, +): SourceMapUpload | undefined { + if (env.EAS_BUILD_PLATFORM !== "ios" || !env.EAS_BUILD_ID) return undefined; + const buildDir = NodePath.join(projectDir, "ios/build"); + const dsym = NodeFS.existsSync(buildDir) + ? NodeFS.readdirSync(buildDir).find((name) => name.endsWith(".app.dSYM.zip")) + : undefined; + if (!dsym) return undefined; + return { key: `ios/dsym/${env.EAS_BUILD_ID}.zip`, path: NodePath.join(buildDir, dsym) }; +} + +/** Only what the relay reads: source text stays out of the bucket, and the rest is dead weight. */ +function stripToMappings(text: string): string { + const map: unknown = JSON.parse(text); + if (!isRecord(map)) throw new Error("Source map is not a JSON object."); + const { version, sources, sourceRoot, mappings } = map; + return JSON.stringify({ version, sources, sourceRoot, mappings }); +} + +export function uploadSourceMap( + credentials: UploadCredentials, + upload: SourceMapUpload, +): Promise { + return putObject( + credentials, + upload.key, + stripToMappings(NodeFS.readFileSync(upload.path, "utf8")), + "application/json", + ); +} + +export function uploadDsym(credentials: UploadCredentials, upload: SourceMapUpload) { + const body = new Uint8Array(NodeFS.readFileSync(upload.path)); + return putObject(credentials, upload.key, body, "application/zip"); +} + +async function putObject( + credentials: UploadCredentials, + key: string, + body: string | Uint8Array, + contentType: string, +): Promise { + const client = new AwsClient({ + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + service: "s3", + region: "auto", + }); + const url = `${credentials.endpoint.replace(/\/$/u, "")}/${credentials.bucketName}/${key}`; + const response = await client.fetch(url, { + method: "PUT", + body, + headers: { "content-type": contentType }, + }); + if (!response.ok) { + throw new Error(`R2 answered ${response.status}: ${(await response.text()).slice(0, 500)}`); + } + return body.length; +} diff --git a/apps/mobile/scripts/sourcemap-upload.test.ts b/apps/mobile/scripts/sourcemap-upload.test.ts new file mode 100644 index 0000000000000..ee8ae57d690ae --- /dev/null +++ b/apps/mobile/scripts/sourcemap-upload.test.ts @@ -0,0 +1,136 @@ +import * as NodeFS from "node:fs"; +import * as NodeHttp from "node:http"; +import type * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + buildDsymUpload, + buildSourceMapUpload, + otaSourceMapUploads, + uploadSourceMap, +} from "./sourcemap-upload.mts"; + +const directories: Array = []; +const tempDir = () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "sourcemap-upload-")); + directories.push(dir); + return dir; +}; +const writeFile = (path: string, contents: string) => { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, contents); +}; + +afterEach(() => { + for (const dir of directories.splice(0)) NodeFS.rmSync(dir, { recursive: true, force: true }); +}); + +describe("uploadSourceMap", () => { + it("PUTs each published update's map, only what the relay reads, under its update id", async () => { + const dist = tempDir(); + writeFile( + NodePath.join(dist, "metadata.json"), + JSON.stringify({ + fileMetadata: { + android: { bundle: "_expo/static/js/android/index-abc.hbc" }, + ios: { bundle: "_expo/static/js/ios/index-def.hbc" }, + }, + }), + ); + writeFile( + NodePath.join(dist, "eas-update-metadata.json"), + JSON.stringify({ + updates: [ + { id: "update-android", platform: "android" }, + { id: "update-ios", platform: "ios" }, + { id: "update-web", platform: "web" }, + ], + }), + ); + for (const platform of ["android", "ios"]) { + writeFile( + NodePath.join( + dist, + `_expo/static/js/${platform}/index-${platform === "ios" ? "def" : "abc"}.hbc.map`, + ), + JSON.stringify({ + version: 3, + sources: [`${platform}.ts`], + sourcesContent: ["secret"], + names: ["render"], + x_facebook_sources: [[null]], + mappings: "", + }), + ); + } + + const requests: Array<{ method?: string; url?: string; auth?: string; body: string }> = []; + const server = NodeHttp.createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + requests.push({ + method: request.method, + url: request.url, + auth: request.headers.authorization, + body, + }); + response.end(); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address() as NodeNet.AddressInfo; + const credentials = { + endpoint: `http://127.0.0.1:${address.port}`, + bucketName: "maps", + accessKeyId: "key-id", + secretAccessKey: "secret-key", + }; + for (const upload of otaSourceMapUploads(dist)) { + await uploadSourceMap(credentials, upload); + } + expect(requests).toEqual( + ["android", "ios"].map((platform) => ({ + method: "PUT", + url: `/maps/${platform}/ota/update-${platform}.map`, + auth: expect.stringMatching(/^AWS4-HMAC-SHA256 Credential=key-id\/\d{8}\/auto\/s3\//), + body: JSON.stringify({ version: 3, sources: [`${platform}.ts`], mappings: "" }), + })), + ); + } finally { + server.close(); + } + }); +}); + +describe("buildSourceMapUpload", () => { + it("names the EAS build's embedded bundle map under its build id", () => { + const project = tempDir(); + const androidMap = NodePath.join( + project, + "android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", + ); + writeFile(androidMap, "{}"); + expect( + buildSourceMapUpload({ EAS_BUILD_PLATFORM: "android", EAS_BUILD_ID: "build-1" }, project), + ).toEqual({ key: "android/build/build-1.map", path: androidMap }); + expect( + buildSourceMapUpload( + { EAS_BUILD_PLATFORM: "ios", EAS_BUILD_ID: "build-2", SOURCEMAP_FILE: "/tmp/main.map" }, + project, + ), + ).toEqual({ key: "ios/build/build-2.map", path: "/tmp/main.map" }); + }); + + it("names an iOS build's dSYMs under its build id", () => { + const project = tempDir(); + const dsym = NodePath.join(project, "ios/build/T3Code.app.dSYM.zip"); + writeFile(dsym, "zip"); + expect( + buildDsymUpload({ EAS_BUILD_PLATFORM: "ios", EAS_BUILD_ID: "build-3" }, project), + ).toEqual({ key: "ios/dsym/build-3.zip", path: dsym }); + }); +}); diff --git a/apps/mobile/scripts/sync-context-chip-icons.mjs b/apps/mobile/scripts/sync-context-chip-icons.mjs index aac4983d75e2c..d99d4d3c98478 100644 --- a/apps/mobile/scripts/sync-context-chip-icons.mjs +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -43,6 +43,7 @@ const lucideIcons = [ "message-circle", "messages-square", "mouse-pointer-click", + "server", "terminal", ]; diff --git a/apps/mobile/scripts/upload-sourcemaps.mts b/apps/mobile/scripts/upload-sourcemaps.mts new file mode 100644 index 0000000000000..1656035906e44 --- /dev/null +++ b/apps/mobile/scripts/upload-sourcemaps.mts @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Usage: `ota ` after `eas update --emit-metadata`, or `build` from eas-build-on-success. +// Problems are warnings, never failures: an unreadable stack is not worth failing a release over. +import { + buildDsymUpload, + buildSourceMapUpload, + otaSourceMapUploads, + parseCredentials, + uploadDsym, + uploadSourceMap, + type SourceMapUpload, +} from "./sourcemap-upload.mts"; + +const warn = (message: string) => { + // GitHub Actions shows this as an annotation; elsewhere it is a log line. + console.warn(process.env.GITHUB_ACTIONS ? `::warning::${message}` : `warning: ${message}`); +}; + +const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +async function main() { + const [mode, distDir] = process.argv.slice(2); + const credentials = parseCredentials(process.env.MOBILE_SOURCEMAPS_R2); + if (!credentials) { + warn("MOBILE_SOURCEMAPS_R2 is not set; crash stacks from this bundle stay minified."); + return; + } + let uploads: ReadonlyArray; + if (mode === "ota" && distDir) { + uploads = otaSourceMapUploads(distDir); + } else if (mode === "build") { + uploads = [buildSourceMapUpload(process.env, process.cwd())]; + } else { + throw new Error("Usage: upload-sourcemaps.mts ota | build"); + } + if (uploads.length === 0) warn("Found no source maps to upload."); + for (const upload of uploads) { + try { + const bytes = await uploadSourceMap(credentials, upload); + console.log(`Uploaded ${upload.key} (${(bytes / 1e6).toFixed(1)} MB)`); + } catch (error) { + warn(`Could not upload ${upload.key}: ${errorMessage(error)}`); + } + } + if (mode !== "build" || process.env.EAS_BUILD_PLATFORM !== "ios") return; + const dsym = buildDsymUpload(process.env, process.cwd()); + if (!dsym) { + warn("Found no dSYM under ios/build; native iOS frames from this build stay unreadable."); + return; + } + try { + const bytes = await uploadDsym(credentials, dsym); + console.log(`Uploaded ${dsym.key} (${(bytes / 1e6).toFixed(1)} MB)`); + } catch (error) { + warn(`Could not upload ${dsym.key}: ${errorMessage(error)}`); + } +} + +await main().catch((error: unknown) => { + warn(`Source map upload skipped: ${errorMessage(error)}`); +}); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 8dff8bdc50396..225496bb7a407 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,4 +1,6 @@ import { installPermissionUpdateNotice } from "./features/connection/permissionUpdateNotice"; +import { installComposerAttachmentUploads } from "./state/composer-attachment-uploads"; +import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; @@ -7,11 +9,15 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { - createNavigationContainerRef, + CommonActions, + createPathConfigForStaticNavigation, createStaticNavigation, getActionFromState, getStateFromPath, + type NavigationAction, + type NavigationState, } from "@react-navigation/native"; +import { pipe } from "effect/Function"; import { RegistryContext } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; @@ -19,12 +25,26 @@ import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet import * as Dialog from "./components/ui/Dialog"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; -import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; +import { installShowcaseCapture } from "./features/showcase/ShowcaseCaptureCoordinator"; +import { + presentPendingIncomingShare, + pruneIncomingShareImports, + startIncomingShareInbox, +} from "./features/sharing/IncomingShareProvider"; +import { + installAgentNotificationNavigation, + setAgentNotificationPathname, +} from "./features/agent-awareness/notificationNavigation"; +import { + installAppShortcuts, + recordAppShortcutNavigationState, +} from "./features/shortcuts/appShortcutsRuntime"; import { AppearancePreferencesProvider, + installAppearanceRuntime, useAppearancePreferences, } from "./features/settings/appearance/AppearancePreferencesProvider"; -import { RootStack } from "./Stack"; +import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts"; import { environmentThreadShells } from "./state/threads"; @@ -33,13 +53,27 @@ import { OverlayPortalHost } from "./components/ui/OverlayPortal"; import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; import { hasCloudPublicConfig } from "./features/cloud/publicConfig"; import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state"; +import { + pairingFormLinkAction, + startDevAutoConnect, + withoutPairingTarget, +} from "./features/connection/devAutoConnect"; +import { + resolvePendingNewTaskPromptLink, + takeNewTaskPromptLink, +} from "./features/prompt-links/newTaskPromptLink"; +import { + resolvePendingThreadPromptLink, + takeThreadPromptLink, +} from "./features/prompt-links/threadPromptLink"; import { disposeOnFoundationReplace, type FoundationHotModule, } from "./lib/foundation-fast-refresh"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; -import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; +import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; +import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; import { @@ -50,6 +84,15 @@ import { } from "./features/workspace/workspace-selection-store"; import { workspaceDetailLinkAction } from "./features/workspace/workspace-detail-selection"; import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; +import { + attachRootNavigation, + flushRootNavigationQueue, + linkToRootPath, + rootNavigationRef, +} from "./lib/rootNavigation"; +import { pruneNewTaskFlowStates } from "./features/threads/new-task-flow-state"; +import { pruneNewTaskBranchCheckouts } from "./features/threads/NewTaskDraftRouteScreen"; +import { installAppUpdateChecks } from "./features/updates/app-updates"; import "../global.css"; @@ -60,6 +103,24 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { void SplashScreen.preventAutoHideAsync().catch(() => {}); void hydrateWorkspaceSelections(); +const linkingConfig = { + initialRouteName: "Home", + screens: createPathConfigForStaticNavigation(RootStack, { initialRouteName: "Home" }) ?? {}, +} as const; + +function getAppStateFromPath(path: string, options: Parameters[1]) { + return pipe( + getStateFromPath(path, options), + (state) => restoreDraftLinkState(state, path), + (state) => restoreSettingsLinkState(state, settingsLinkPage()), + takeThreadPromptLink, + takeNewTaskPromptLink, + withoutPairingTarget, + startDevAutoConnect, + takeScheduledTaskNewLink, + ); +} + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], config: { initialRouteName: "Home" }, @@ -71,30 +132,73 @@ const appLinking = { ): ReturnType => // Our router handles the selection; React Navigation's type only lists its own actions. (workspaceDetailLinkAction(state) as ReturnType) ?? + pairingFormLinkAction( + state, + rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined, + ) ?? getActionFromState(state, options), - getStateFromPath: (path: string, options: Parameters[1]) => - takeScheduledTaskNewLink( - restoreSettingsLinkState( - restoreDraftLinkState(getStateFromPath(path, options), path), - settingsLinkPage(), - ), - ), + getStateFromPath: getAppStateFromPath, }; +function appLinkAction(href: string): NavigationAction { + const state = getAppStateFromPath(href, linkingConfig); + if (!state) throw new Error(`Failed to parse the href to a navigation state (${href}).`); + return appLinking.getActionFromState(state, linkingConfig) ?? CommonActions.reset(state); +} + +function linkToAppPath(href: string): void { + linkToRootPath(href, appLinkAction); +} + const Navigation = createStaticNavigation(RootStack); -const navigationRef = createNavigationContainerRef(); + +function handleNavigationStateChange(state: NavigationState | undefined): void { + recordWorkspaceSelections(state); + presentPendingIncomingShare(); + resolvePendingNewTaskPromptLink(state); + resolvePendingThreadPromptLink(state); + if (!state) return; + setAgentNotificationPathname(workspaceLocationFromState(state).pathname); + recordAppShortcutNavigationState(state); + pruneNewTaskFlowStates(state); + pruneNewTaskBranchCheckouts(state); + pruneIncomingShareImports(state); +} + +function handleNavigationReady(): void { + handleNavigationStateChange(rootNavigationRef.getRootState()); + flushRootNavigationQueue(); +} declare const module: { readonly hot?: FoundationHotModule } | undefined; -const disposePermissionUpdateNotice = installPermissionUpdateNotice(); -disposeOnFoundationReplace( - typeof module === "undefined" ? undefined : module.hot, - disposePermissionUpdateNotice, -); +const appServiceDisposers = [ + installUiRuntimeMemoryWarningGc(), + subscribeComposerDraftModelReconciliation({ + registry: appAtomRegistry, + threadShellAtom: environmentThreadShells.threadShellAtom, + threadRefsAtom: environmentThreadShells.threadRefsAtom, + shellStateAtom: environmentShell.stateAtom, + }), + installAppearanceRuntime(), + installSubscriptionUsageRefresh(), + installPermissionUpdateNotice(), + installThreadOutboxDelivery(), + installComposerAttachmentUploads(), + startIncomingShareInbox(), + installAgentNotificationNavigation(linkToAppPath), + installAppShortcuts(linkToAppPath), + installAppUpdateChecks(), + installShowcaseCapture(rootPathnameFromState), +]; +disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => { + for (const dispose of appServiceDisposers) dispose(); +}); function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once appearance preferences have loaded useEffect(() => { if (isReady) void SplashScreen.hide(); }, [isReady]); @@ -103,18 +207,6 @@ function SplashScreenCoordinator() { } export default function App() { - useUiRuntimeMemoryWarningGc(); - useEffect( - () => - subscribeComposerDraftModelReconciliation({ - registry: appAtomRegistry, - threadShellAtom: environmentThreadShells.threadShellAtom, - threadRefsAtom: environmentThreadShells.threadRefsAtom, - shellStateAtom: environmentShell.stateAtom, - }), - [], - ); - return ( @@ -141,15 +233,13 @@ function AppContent() { {/* native-stack needs the theme's dark flag to select dark native headers. */} - - recordWorkspaceSelections(navigationRef.getRootState())} - theme={navigationTheme} - onStateChange={recordWorkspaceSelections} - /> - + diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index ffc4b0bc8de93..7a0042903a3c3 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,7 +15,7 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; -import { useEffect, useRef, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { Platform, Pressable, ScrollView, StyleSheet, useWindowDimensions } from "react-native"; import { useResolveClassNames } from "uniwind"; @@ -24,6 +24,11 @@ import { androidWorkspaceDestinationAnimation } from "./lib/android-workspace-na import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { WorkspaceConnectionStatusProvider } from "./features/home/WorkspaceConnectionTitle"; import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; +import { + recordHomeFocus, + recordHomeTransitionEnd, + recordHomeTransitionStart, +} from "./features/home/home-route-visibility"; import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; import { PullRequestRouteScreen, @@ -41,9 +46,7 @@ import { type RenderFailureProps, } from "./components/RenderErrorBoundary"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; -import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; -import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; @@ -86,6 +89,7 @@ import { NewTaskThreadSettingsRouteScreen, } from "./features/threads/ThreadSettingsSheet"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; +import { newTaskFlowKey } from "./features/threads/new-task-flow-state"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsNavigationRouteScreen } from "./features/settings/SettingsNavigationRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; @@ -97,9 +101,9 @@ import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/Settin import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsFollowUpRouteScreen } from "./features/settings/SettingsFollowUpRouteScreen"; import { - SettingsEnvironmentAgentBehaviorRouteScreen, SettingsEnvironmentMaintenanceRouteScreen, - SettingsEnvironmentNewThreadsRouteScreen, + SettingsThreadsRouteScreen, + SettingsWorktreesRouteScreen, SettingsEnvironmentSourceControlRouteScreen, } from "./features/settings/SettingsServerControlsRouteScreen"; import { @@ -125,7 +129,7 @@ import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsAboutRouteScreen } from "./features/settings/SettingsAboutRouteScreen"; import { SettingsNotificationsRouteScreen } from "./features/settings/SettingsNotificationsRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; -import { SettingsThreadsRouteScreen } from "./features/settings/SettingsThreadsRouteScreen"; +import { SettingsLabsRouteScreen } from "./features/settings/ThreadSettingsSections"; import { SettingsEnvironmentFilterProvider } from "./features/settings/settings-environment-filter"; import { openSettings, settingsOpenIn } from "./features/settings/settings-navigation"; import { DEFAULT_SETTINGS_PAGE } from "./features/workspace/workspace-tab-navigation"; @@ -133,28 +137,17 @@ import { isWorkspaceOverlayRoute, registerPresentedRoutes, } from "./features/layout/workspace-overlay-routes"; -import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; +import { ShowcaseReadyMarker } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, SettingsLegalDocumentExternalHeaderButton, } from "./features/settings/components/SettingsLegalDocumentRouteScreen"; -import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; -import { useIncomingShare } from "./features/sharing/IncomingShareProvider"; -import { - EMPTY_INCOMING_SHARE_PRESENTATION_STATE, - transitionIncomingSharePresentation, -} from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; -import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; -import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; -import { - selectWorkspaceDetail, - workspaceDetailRouter, -} from "./features/workspace/workspace-detail-selection"; +import { workspaceDetailRouter } from "./features/workspace/workspace-detail-selection"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -263,10 +256,11 @@ const SETTINGS_SCREENS = { linking: "settings/environments/:environmentId", options: { ...SETTINGS_PAGE_OPTIONS, title: "Environment" }, }), + // Old links to New threads and Agent behavior open Threads, which absorbed them. SettingsEnvironmentNewThreads: createNativeStackScreen({ - screen: SettingsEnvironmentNewThreadsRouteScreen, + screen: SettingsThreadsRouteScreen, linking: "settings/new-threads", - options: { ...SETTINGS_PAGE_OPTIONS, title: "New threads" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, }), SettingsEnvironmentSourceControl: createNativeStackScreen({ screen: SettingsEnvironmentSourceControlRouteScreen, @@ -274,14 +268,24 @@ const SETTINGS_SCREENS = { options: { ...SETTINGS_PAGE_OPTIONS, title: "Source control" }, }), SettingsEnvironmentAgentBehavior: createNativeStackScreen({ - screen: SettingsEnvironmentAgentBehaviorRouteScreen, + screen: SettingsThreadsRouteScreen, linking: "settings/agent-behavior", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Agent behavior" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, + }), + SettingsWorktrees: createNativeStackScreen({ + screen: SettingsWorktreesRouteScreen, + linking: "settings/worktrees", + options: { ...SETTINGS_PAGE_OPTIONS, title: "Worktrees" }, + }), + SettingsLabs: createNativeStackScreen({ + screen: SettingsLabsRouteScreen, + linking: "settings/labs", + options: { ...SETTINGS_PAGE_OPTIONS, title: "Labs" }, }), SettingsProviderAccounts: createNativeStackScreen({ screen: SettingsProviderAccountsRouteScreen, linking: "settings/provider-accounts", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Provider accounts" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Providers" }, }), SettingsEnvironmentMaintenance: createNativeStackScreen({ screen: SettingsEnvironmentMaintenanceRouteScreen, @@ -296,12 +300,12 @@ const SETTINGS_SCREENS = { SettingsThreads: createNativeStackScreen({ screen: SettingsThreadsRouteScreen, linking: "settings/thread-preferences", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Thread behavior" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, }), SettingsAbout: createNativeStackScreen({ screen: SettingsAboutRouteScreen, linking: "settings/about", - options: { ...SETTINGS_PAGE_OPTIONS, title: "About T3 Code" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "About" }, }), SettingsEnvironmentNew: createNativeStackScreen({ screen: ConnectionsNewRouteScreen, @@ -316,7 +320,7 @@ const SETTINGS_SCREENS = { linking: "settings/archive", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Archived Threads", + title: "Archive", }, }), SettingsNavigation: createNativeStackScreen({ @@ -337,7 +341,7 @@ const SETTINGS_SCREENS = { linking: "settings/project-grouping", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Organization", + title: "General", }, }), SettingsOrganization: createNativeStackScreen({ @@ -345,13 +349,13 @@ const SETTINGS_SCREENS = { linking: "settings/organization", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Organization", + title: "General", }, }), SettingsProjectOverview: createNativeStackScreen({ screen: SettingsProjectOverviewRouteScreen, linking: "settings/project", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Project overview" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Projects" }, }), SettingsKeyboard: createNativeStackScreen({ screen: SettingsKeyboardRouteScreen, @@ -374,7 +378,7 @@ const SETTINGS_SCREENS = { linking: "settings/follow-ups", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Follow-ups", + title: "Chat", }, }), SettingsScheduledTasks: createNativeStackScreen({ @@ -382,7 +386,7 @@ const SETTINGS_SCREENS = { linking: "settings/scheduled-tasks", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Scheduled Tasks", + title: "Scheduled tasks", headerTitleStyle: { fontSize: 16, fontWeight: "800" }, }, }), @@ -472,88 +476,67 @@ const SETTINGS_SCREENS = { // iOS 26 header morphing requires one UINavigationBar; nested stacks make the whole screen slide. const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; -function workspaceLocationFromState(state: NavigationState) { +export function workspaceLocationFromState(state: NavigationState) { const routes = state.routes.filter((route) => !isWorkspaceOverlayRoute(route.name)); const effectiveState = routes.length > 0 && routes.length !== state.routes.length ? ({ ...state, routes, index: routes.length - 1 } as NavigationState) : state; const path = getPathFromState(effectiveState, navigationPathConfig); + const route = effectiveState.routes[effectiveState.index]; return { pathname: path.startsWith("/") ? path : `/${path}`, - routeKey: effectiveState.routes[effectiveState.index]?.key, + routeKey: route?.key, + routeName: route?.name, + routeKeys: effectiveState.routes.map((stackRoute) => stackRoute.key), }; } -// Isolate subscriptions so streaming updates cannot re-render RootStackLayout and every screen. -function ThreadOutboxDrainWorker() { - useThreadOutboxDrain(); - useComposerAttachmentUploadWorker(); - return null; +export function rootPathnameFromState(state: NavigationState): string { + const path = getPathFromState(state, navigationPathConfig); + return path.startsWith("/") ? path : `/${path}`; } function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; }) { - const navigation = useNavigation(); - const { pendingShare } = useIncomingShare(); - const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); - const path = getPathFromState(props.state, navigationPathConfig); - const pathname = path.startsWith("/") ? path : `/${path}`; + const pathname = rootPathnameFromState(props.state); const workspaceLocation = workspaceLocationFromState(props.state); const settingsOpen = settingsOpenIn( props.state.routes .slice(0, props.state.index + 1) .filter((route) => !isWorkspaceOverlayRoute(route.name)), ); - const draftRoute = props.state.routes.findLast((route) => route.name === "Thread"); + // The flow follows the draft route even while a pushed Thread covers it, so + // its state lives exactly as long as the route. + const draftRoute = props.state.routes.findLast(isDraftThreadRoute); + const draftRouteOnTop = + draftRoute !== undefined && + props.state.routes.findLast((route) => route.name === "Thread") === draftRoute; const draftParams = ( - draftRoute?.params as - | { draft?: { draftId?: string | string[]; pendingTaskId?: string | string[] } } - | undefined + draftRoute?.params as { draft?: { draftId?: string | string[] } } | undefined )?.draft; const routeDraftId = Array.isArray(draftParams?.draftId) ? draftParams.draftId[0] : draftParams?.draftId; - const routePendingTaskId = Array.isArray(draftParams?.pendingTaskId) - ? draftParams.pendingTaskId[0] - : draftParams?.pendingTaskId; - useAgentNotificationNavigation(workspaceLocation.pathname); - useConnectOnboardingNavigation(); - useAppShortcuts(props.state); - useEffect(() => { - const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { - isShareSheetPresented: isDraftThreadRoute(props.state.routes[props.state.index]), - pendingShareId: pendingShare?.id ?? null, - }); - sharePresentationRef.current = transition.state; - if (!transition.shareIdToPresent) { - return; - } - navigation.dispatch( - selectWorkspaceDetail({ - name: "Thread", - params: { draft: { incomingShareId: transition.shareIdToPresent } }, - }), - ); - }, [navigation, pendingShare, props.state]); return ( - - + {props.children} @@ -627,6 +610,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ Home: createNativeStackScreen({ screen: WorkspaceRootScreen, linking: "", + listeners: { + transitionStart: ({ data }) => recordHomeTransitionStart(data.closing), + transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing), + focus: recordHomeFocus, + }, options: { ...GLASS_HEADER_OPTIONS, ...FIXED_DUO_HEADER, diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 272d3be266dd7..7a251a2e57c16 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -10,6 +10,8 @@ import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../lib/mobileBranding"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; +const BRAND_MAX_FONT_SCALE = 1.4; + export function brandTitleOffset(): number { if (Platform.OS !== "ios") return 0; return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0; @@ -49,8 +51,10 @@ export function CompactBrandTitle( ]} > + {/* A logo beside a fixed-size wordmark: it may grow with Dynamic Type, not crowd the bar. */} @@ -67,6 +71,7 @@ export function CompactBrandTitle( > @@ -89,7 +94,7 @@ export function CompactBrandTitle( ); } -export function renderCompactBrandTitle() { +function renderCompactBrandTitle() { return ; } diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 20c7485ef15d6..8121bfdece763 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -324,6 +324,9 @@ export function ComposerContextSheet(props: { ) : null} ) : null} + {record.kind === "environment" ? ( + + ) : null} {record.kind === "skill" ? ( diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 0098605414bd2..59c0a74e4525f 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -27,6 +27,7 @@ import { startComposerDraftOperation } from "../state/composerDraftOperations"; import { appAtomRegistry } from "../state/atom-registry"; import { mobilePreferencesAtom } from "../state/preferences"; import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread"; +import { useLinkedEnvironments } from "../state/peerLinks"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./ui/AppText"; import { @@ -61,6 +62,7 @@ export function ComposerEditor({ ? preferencesResult.value.composerEnterBehavior : undefined; const contextHistory = createComposerDraftContextHistory(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the attachment file leases held for native text undo when the history is replaced or unmounts useEffect(() => () => contextHistory.dispose(), [contextHistory]); const changeText = (text: string) => { const restored = contextHistory.restore( @@ -76,6 +78,7 @@ export function ComposerEditor({ const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( null, ); + const linkedEnvironments = useLinkedEnvironments(environmentId ?? null); const contextImports = useAtomValue(composerContextImportsAtom); const importing = draftKey ? contextImports[draftKey] === true : false; const pasteContext = async ( @@ -100,6 +103,7 @@ export function ComposerEditor({ signal, retained.context?.records.length ?? 0, environmentId, + linkedEnvironments, ); if (signal.aborted) return; if (!result) { diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index 431cf61b1ec62..e422cfabd4591 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -70,7 +70,7 @@ export function ComposerModelControl(props: { /> ) } - label={label} + label={props.modelOption?.shortName ?? label} iconOnly={props.iconOnly} suffix={ summary ? ( diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index f2b92eaf89260..8963a4039d5f3 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -1,5 +1,5 @@ import type { ComponentProps, ReactNode } from "react"; -import { useCallback, useMemo, useState } from "react"; +import { useState } from "react"; import { Pressable, ScrollView, @@ -154,33 +154,31 @@ export function ComposerToolbarScroller(props: { viewportWidth: 0, }); - const scrollEdges = useMemo(() => { - const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); - return { - showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, - showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, - }; - }, [metrics]); + const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); + const scrollEdges = { + showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, + showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, + }; - const handleLayout = useCallback((event: LayoutChangeEvent) => { + const handleLayout = (event: LayoutChangeEvent) => { const viewportWidth = event.nativeEvent.layout.width; setMetrics((current) => current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth }, ); - }, []); + }; - const handleContentSizeChange = useCallback((contentWidth: number) => { + const handleContentSizeChange = (contentWidth: number) => { setMetrics((current) => current.contentWidth === contentWidth ? current : { ...current, contentWidth }, ); - }, []); + }; - const handleScroll = useCallback((event: NativeSyntheticEvent) => { + const handleScroll = (event: NativeSyntheticEvent) => { const offsetX = event.nativeEvent.contentOffset.x; setMetrics((current) => Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX }, ); - }, []); + }; return ( diff --git a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx index 901a032845f9a..f4dce3c978081 100644 --- a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx +++ b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx @@ -11,7 +11,7 @@ export const ENVIRONMENT_MACHINE_SYMBOLS = { "mac-studio": "macstudio", } as const satisfies Record; -export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { +const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { server: "Server", cloud: "Cloud VM", linux: "Linux/WSL", diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index ed5c6e56ee891..45f09779f13be 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -34,6 +34,7 @@ function NativeFilePreview(props: { else Dialog.alert("Could not open preview", "The file could not be loaded. Please try again."); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- presents the native file preview and dismisses it on cleanup useEffect(() => { let canceled = false; const presentation = diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index b0cf1fc5c3ab0..486cd9e236b8e 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -55,6 +55,7 @@ function DocumentPreview(props: { "A compatible viewer must be installed. Check your connection and try again.", ); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the attachment in an external viewer and aborts the open on cleanup useEffect(() => { const controller = new AbortController(); const input = { diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 8079379fd4895..849bacd8ac41b 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -86,6 +86,7 @@ function ResolvedFilePreview(props: { props.onRequestClose(); }); // Resolves once per presentation and holds local file leases until it closes. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resolves preview files and holds their local file leases until the preview closes useEffect(() => { if (items.every((item) => "uri" in item)) return; const controller = new AbortController(); diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index ea39cac15baff..c6dc31ca00ad2 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -1,6 +1,6 @@ import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; @@ -28,11 +28,12 @@ function LoadedMediaVideo(props: { player.staysActiveInBackground = false; player.bufferOptions = { preferredForwardBufferDuration: 5 }; }); - const playback = useMemo(() => createAudioPlaybackSession(player, true), [player]); + const playback = createAudioPlaybackSession(player, true); const { active, fullscreen, createPlayRequest, onFullscreenEnter, onFullscreenExit } = useMediaVideoPlayback(player, props, playback); const previousPlaybackRequested = useRef(false); const onPlaybackStart = useEffectEvent(() => props.onPlaybackStart?.()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to native video player events to keep the shared audio session in step useEffect(() => { const subscription = player.addListener("playingChange", ({ isPlaying }) => { if (isPlaying && !active.current && !fullscreen.current) playback.pause(); @@ -74,6 +75,7 @@ function LoadedMediaVideo(props: { if (!signal.aborted && props.playRequested) await playIfUninterrupted(); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the video source into the native player and aborts the load on cleanup useEffect(() => { const controller = new AbortController(); // oxlint-disable-next-line react/set-state-in-effect -- Starting a native media load publishes pending before its async result. diff --git a/apps/mobile/src/components/ProjectFavicon.test.tsx b/apps/mobile/src/components/ProjectFavicon.test.tsx new file mode 100644 index 0000000000000..62eb2bb6b2ba4 --- /dev/null +++ b/apps/mobile/src/components/ProjectFavicon.test.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { act, Profiler } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + Passthrough: ({ children }: { children?: unknown }) => children, +})); + +vi.mock("react-native", () => ({ View: harness.Passthrough })); +vi.mock("expo-image", () => ({ Image: () => })); +vi.mock("./ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("./ui/AppText", () => ({ + AppText: ({ children }: { children?: ReactNode }) => {children}, +})); +vi.mock("../state/assets", async () => { + const { Atom } = await import("effect/reactivity"); + const url = Atom.make("https://example.test/favicon.png"); + return { projectFaviconUrlAtom: () => url }; +}); + +import { EnvironmentId } from "@t3tools/contracts"; + +import { ProjectFavicon } from "./ProjectFavicon"; + +it("mounts the favicon image in the first commit", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement("div"); + const root = createRoot(container); + const commits: string[] = []; + try { + await act(async () => + root.render( + commits.push(phase)}> + + , + ), + ); + expect(container.querySelector("img")).not.toBeNull(); + expect(commits).toEqual(["mount"]); + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 3e0f09741266f..1aa33197321c7 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,7 +1,7 @@ import { SymbolView } from "./ui/AppSymbol"; import { AppText } from "./ui/AppText"; import { Image } from "expo-image"; -import { memo, useLayoutEffect, useMemo, useState } from "react"; +import { memo, useLayoutEffect, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; import { @@ -52,28 +52,14 @@ export const ProjectFavicon = memo(function ProjectFavicon(props: { faviconPath: props.faviconPath, }), ); - const renderableFaviconUrl = useMemo( - () => (isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl), - [faviconUrl], - ); + const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; // Key remote images by revision so token rotation reuses the cache while changed icons reload. - const cacheKey = useMemo( - () => - renderableFaviconUrl && props.workspaceRoot - ? renderableFaviconUrl.startsWith("data:") - ? getProjectFaviconResourceKey( - props.environmentId, - props.workspaceRoot, - props.faviconPath, - ) - : getProjectFaviconCacheKey( - props.environmentId, - props.workspaceRoot, - renderableFaviconUrl, - ) - : null, - [renderableFaviconUrl, props.environmentId, props.workspaceRoot, props.faviconPath], - ); + const cacheKey = + renderableFaviconUrl && props.workspaceRoot + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + : null; if (symbol !== null) { return ( @@ -159,18 +145,13 @@ function ProjectFaviconImage(props: { readonly projectTitle: string; readonly size: number; }) { - const faviconRequest = useMemo( - () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), - [props.cacheKey, props.faviconUrl], - ); - const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); + const faviconRequest = createProjectFaviconRequest(props.cacheKey, props.faviconUrl); + // Registers with the module-level loader before the Image's native onLoad/onError can fire, + // so stale loads for a superseded URL under the same cacheKey are ignored. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- marks this favicon URL as the current request in the module registry before the native Image reports load or error useLayoutEffect(() => { if (faviconRequest === null) return; - - const endRequest = beginProjectFaviconRequest(faviconRequest); - // oxlint-disable-next-line react/set-state-in-effect -- Publishes the favicon request registered with the external loader until its cleanup runs. - setActiveFaviconRequest(faviconRequest); - return endRequest; + return beginProjectFaviconRequest(faviconRequest); }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => @@ -179,8 +160,7 @@ function ProjectFaviconImage(props: { : "loading", ); - const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; - const showImage = requestIsActive && status === "loaded"; + const showImage = faviconRequest !== null && status === "loaded"; const fallbackGlyph = showImage ? null : projectAutoMonogram(props.projectTitle); return ( @@ -203,7 +183,7 @@ function ProjectFaviconImage(props: { /> )} - {requestIsActive ? ( + {faviconRequest !== null ? ( { + const rememberWidth = ({ width }: { width: number }) => { setButtonWidth((previous) => Math.max(previous, width)); - }, []); + }; return ( { const controller = new AbortController(); let token: ReturnType | null = null; diff --git a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx index 1b259f1ecbfa1..7931717682fa1 100644 --- a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx @@ -1,7 +1,7 @@ import { AppActivityIndicator } from "./AppActivityIndicator"; import { Column, Row, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; import { weight } from "@expo/ui/jetpack-compose/modifiers"; -import { useState, type ReactNode } from "react"; +import { use, useState, type ReactNode } from "react"; import { View } from "react-native"; import type { AppSymbolName } from "./AppSymbol"; @@ -12,6 +12,7 @@ import { splitAndroidHeaderActions } from "./headerMenu.android"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; import { useToolbarLayout } from "./useToolbarLayout"; import { useControlTheme } from "./useControlTheme"; +import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; export interface AndroidHeaderAction { readonly accessibilityLabel: string; @@ -50,7 +51,8 @@ export function AndroidScreenHeader(props: { const { colors } = useControlTheme(); const titleTypography = useScaledTextRole("title"); const subtitleTypography = useScaledTextRole("label"); - const { paddingTop, paddingBottom } = useToolbarLayout(props.embedded); + const inFormSheet = use(RoutedFormSheetContext); + const { paddingTop, paddingBottom } = useToolbarLayout(props.embedded || inFormSheet); const [headerWidth, setHeaderWidth] = useState(0); const { visibleActions, overflowActions } = splitAndroidHeaderActions( props.actions ?? [], @@ -67,6 +69,12 @@ export function AndroidScreenHeader(props: { borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > + {inFormSheet ? ( + + ) : null} 0); const onSearchChange = search?.onChangeText; - const closeSearch = useCallback(() => { + const closeSearch = () => { onSearchChange?.(""); setSearchOpen(false); Keyboard.dismiss(); - }, [onSearchChange]); + }; useHardwareBack(searching && search?.mode !== "inline", () => { closeSearch(); return true; diff --git a/apps/mobile/src/components/ui/ListChoiceRow.tsx b/apps/mobile/src/components/ui/ListChoiceRow.tsx index 748752f5cd415..9e231b7c25b90 100644 --- a/apps/mobile/src/components/ui/ListChoiceRow.tsx +++ b/apps/mobile/src/components/ui/ListChoiceRow.tsx @@ -26,10 +26,8 @@ export function ListChoiceRow(props: { onPress={props.onPress} android_ripple={{ color: rippleStyle.backgroundColor }} unstable_pressDelay={Platform.OS === "android" ? 50 : undefined} - className={cn( - props.selected ? "bg-subtle" : "bg-grouped-card", - props.separated && "border-t border-border-subtle", - )} + // Like UIKit and Material lists, only the checkmark or radio marks the selected row. + className={cn("bg-grouped-card", props.separated && "border-t border-border-subtle")} > , "colorScheme" | "seedColor" | "colorOverrides">, ) { const { colors, colorScheme, systemColorsActive } = useControlTheme(); - const colorOverrides = useMemo( - () => (systemColorsActive ? undefined : createNativeThemeColors(colors)), - [colors, systemColorsActive], - ); + const colorOverrides = systemColorsActive ? undefined : createNativeThemeColors(colors); return ; } diff --git a/apps/mobile/src/components/ui/Notice.tsx b/apps/mobile/src/components/ui/Notice.tsx index 44ca6c8cf7fe7..90a31f7054363 100644 --- a/apps/mobile/src/components/ui/Notice.tsx +++ b/apps/mobile/src/components/ui/Notice.tsx @@ -16,6 +16,7 @@ export function Notice(props: { readonly dismissLabel?: string; }) { const danger = props.tone === "danger"; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- announces the message to VoiceOver through AccessibilityInfo useEffect(() => { // Live regions reach TalkBack; VoiceOver needs an explicit announcement. if (Platform.OS === "ios") AccessibilityInfo.announceForAccessibility(props.message); diff --git a/apps/mobile/src/components/ui/OverlayPortal.tsx b/apps/mobile/src/components/ui/OverlayPortal.tsx index a4f07f46024f1..b5cd51e6c9058 100644 --- a/apps/mobile/src/components/ui/OverlayPortal.tsx +++ b/apps/mobile/src/components/ui/OverlayPortal.tsx @@ -38,6 +38,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { // happen after commit. No dependency array: re-project after every render so // the host always shows the current content (menus re-render while open — // drill-in, theme). + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- projects children into the out-of-tree overlay host store after every commit useEffect(() => { entries.set(key, props.children); emit(); @@ -45,6 +46,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { // Removal is unmount-only: deleting on every re-projection would move the // entry to the end of the map and restack it above overlays opened later. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- removes this entry from the overlay host store on unmount useEffect( () => () => { entries.delete(key); diff --git a/apps/mobile/src/components/ui/RoutedFormSheetContext.ts b/apps/mobile/src/components/ui/RoutedFormSheetContext.ts new file mode 100644 index 0000000000000..29ee63d996e57 --- /dev/null +++ b/apps/mobile/src/components/ui/RoutedFormSheetContext.ts @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const RoutedFormSheetContext = createContext(false); diff --git a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx index 4862e99cdabfa..15883d300da41 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx @@ -1,11 +1,8 @@ import { requireNativeView } from "expo"; import { View, type NativeSyntheticEvent, type ViewProps } from "react-native"; -import { - flattenThemeColor, - themeColorWithAlpha, - themeColorWithContrast, -} from "../../lib/mobileTheme"; +import { SegmentedControl as FlatSegmentedControl } from "./SegmentedControl.shared"; +import { segmentedControlColors } from "./segmentedControlColors"; import { useControlTheme } from "./useControlTheme"; import type { SegmentedControlProps } from "./SegmentedControl.types"; @@ -27,17 +24,16 @@ const NativeSegmentedControl = requireNativeView< export function SegmentedControl( props: SegmentedControlProps, ) { - const { colors, colorScheme } = useControlTheme(); - const trackColor = flattenThemeColor( - themeColorWithAlpha(colors["--color-prominent-action"], colorScheme === "dark" ? 0.18 : 0.07), - colors["--color-screen"], + const { colors, colorScheme, themeId } = useControlTheme(); + // UIKit adds a gray fill over the sunken card track. + if (props.variant === "card") return ; + const { trackColor, selectedColor, selectedTextColor, mutedColor } = segmentedControlColors( + colors, + colorScheme, + props.variant, + "ios", + themeId, ); - const selectedColor = - colorScheme === "dark" ? colors["--color-prominent-action"] : colors["--color-secondary"]; - const selectedTextColor = - colorScheme === "dark" - ? colors["--color-prominent-action-foreground"] - : colors["--color-secondary-foreground"]; return ( ( trackColor={trackColor} selectedColor={selectedColor} textColor={selectedTextColor} - mutedColor={themeColorWithContrast( - colors["--color-foreground-muted"], - trackColor, - colorScheme === "dark" ? 7 : 4.5, - )} + mutedColor={mutedColor} appearance={colorScheme} onSelectionChange={({ nativeEvent }) => { const option = props.options[nativeEvent.index]; diff --git a/apps/mobile/src/components/ui/SegmentedControl.shared.tsx b/apps/mobile/src/components/ui/SegmentedControl.shared.tsx new file mode 100644 index 0000000000000..9eac685a8ca21 --- /dev/null +++ b/apps/mobile/src/components/ui/SegmentedControl.shared.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { I18nManager, Platform, Pressable, View } from "react-native"; +import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated"; + +import { segmentedControlColors } from "./segmentedControlColors"; +import { AppText } from "./AppText"; +import { useControlTheme } from "./useControlTheme"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; + +export type { SegmentedControlProps } from "./SegmentedControl.types"; + +export function SegmentedControl( + props: SegmentedControlProps, +) { + const { colorScheme, colors, themeId } = useControlTheme(); + const [layout, setLayout] = useState({ width: 0, height: 0 }); + const { width } = layout; + const reduceMotion = useReducedMotion(); + const selectedIndex = props.options.findIndex((option) => option.value === props.selected); + const segmentWidth = props.options.length ? Math.max(0, width - 6) / props.options.length : 0; + const selectionOffset = selectedIndex * segmentWidth * (I18nManager.isRTL ? -1 : 1); + const selectionStyle = useAnimatedStyle(() => ({ + width: segmentWidth, + transform: [{ translateX: withTiming(selectionOffset, { duration: reduceMotion ? 0 : 160 }) }], + })); + const { trackColor, selectedColor, selectedTextColor, mutedColor } = segmentedControlColors( + colors, + colorScheme, + props.variant, + Platform.OS, + themeId, + ); + const minHeight = props.size === "compact" ? 32 : Platform.OS === "android" ? 40 : 36; + + return ( + + setLayout(event.nativeEvent.layout)} + className="flex-row rounded-full p-[3px]" + style={{ + minHeight, + backgroundColor: trackColor, + }} + > + {selectedIndex >= 0 && width > 0 ? ( + + ) : null} + {props.options.map((option) => { + const selected = option.value === props.selected; + return ( + props.onSelect(option.value)} + className="flex-1 items-center justify-center rounded-full px-2 py-1 active:opacity-70" + style={{ minHeight: minHeight - 6 }} + > + + {option.label} + + + ); + })} + + + ); +} diff --git a/apps/mobile/src/components/ui/SegmentedControl.tsx b/apps/mobile/src/components/ui/SegmentedControl.tsx index 0fdc46c0f27ef..bbcbc4a5f4593 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.tsx @@ -1,78 +1 @@ -import { useState } from "react"; -import { I18nManager, Platform, Pressable, View } from "react-native"; -import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated"; - -import { AppText } from "./AppText"; -import { useControlTheme } from "./useControlTheme"; -import type { SegmentedControlProps } from "./SegmentedControl.types"; - -export type { SegmentedControlProps } from "./SegmentedControl.types"; - -export function SegmentedControl( - props: SegmentedControlProps, -) { - const { colorScheme, colors } = useControlTheme(); - const [layout, setLayout] = useState({ width: 0, height: 0 }); - const { width } = layout; - const reduceMotion = useReducedMotion(); - const selectedIndex = props.options.findIndex((option) => option.value === props.selected); - const segmentWidth = props.options.length ? Math.max(0, width - 6) / props.options.length : 0; - const selectionOffset = selectedIndex * segmentWidth * (I18nManager.isRTL ? -1 : 1); - const selectionStyle = useAnimatedStyle(() => ({ - width: segmentWidth, - transform: [{ translateX: withTiming(selectionOffset, { duration: reduceMotion ? 0 : 160 }) }], - })); - const dark = colorScheme === "dark"; - const minHeight = props.size === "compact" ? 32 : Platform.OS === "android" ? 40 : 36; - - return ( - - setLayout(event.nativeEvent.layout)} - className="flex-row rounded-full p-[3px]" - style={{ - minHeight, - backgroundColor: colors[dark ? "--color-screen" : "--color-grouped-card"], - }} - > - {selectedIndex >= 0 && width > 0 ? ( - - ) : null} - {props.options.map((option) => { - const selected = option.value === props.selected; - return ( - props.onSelect(option.value)} - className="flex-1 items-center justify-center rounded-full px-2 py-1 active:opacity-70" - style={{ minHeight: minHeight - 6 }} - > - - {option.label} - - - ); - })} - - - ); -} +export { SegmentedControl, type SegmentedControlProps } from "./SegmentedControl.shared"; diff --git a/apps/mobile/src/components/ui/SegmentedControl.types.ts b/apps/mobile/src/components/ui/SegmentedControl.types.ts index 779b49fe1a4b4..472ed46b0e2f2 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.types.ts +++ b/apps/mobile/src/components/ui/SegmentedControl.types.ts @@ -8,6 +8,7 @@ export interface SegmentedControlProps { readonly onSelect: (value: Value) => void; /** Compact uses a 32-point capsule; default uses 36 points on iOS and 40 on Android. */ readonly size?: "default" | "compact"; + readonly variant?: "standalone" | "card"; readonly role?: "tab" | "button"; readonly className?: string; } diff --git a/apps/mobile/src/components/ui/Sheet.android.tsx b/apps/mobile/src/components/ui/Sheet.android.tsx index 08f8b8ad9a4a6..aaafffb8013da 100644 --- a/apps/mobile/src/components/ui/Sheet.android.tsx +++ b/apps/mobile/src/components/ui/Sheet.android.tsx @@ -3,26 +3,38 @@ import { View } from "react-native"; import { FormSheet } from "react-native-screens"; import { useResolveClassNames } from "uniwind"; import { AndroidSheetHeader } from "./AndroidScreenHeader"; +import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; +import { ScreenContent } from "./ScreenContent"; import type { SheetProps } from "./Sheet.types"; export type { SheetProps } from "./Sheet.types"; export function Sheet(props: SheetProps) { - const containerStyle = useResolveClassNames("bg-sheet"); + const containerStyle = useResolveClassNames("bg-header"); const [isOpen, setIsOpen] = useState(true); return ( - {/* Android FormSheet does not bound flex children. Material drags target the first scroll view, so place content before the header. */} - - {props.children} - setIsOpen(false)} /> - + + {/* Android FormSheet does not bound flex children. Material drags target the first scroll view, so place content before the header. */} + + {props.children} + setIsOpen(false)} + hideBottomBorder + /> + + + ); } diff --git a/apps/mobile/src/components/ui/Toast.tsx b/apps/mobile/src/components/ui/Toast.tsx index 4426cee5552af..2483ac9fa1285 100644 --- a/apps/mobile/src/components/ui/Toast.tsx +++ b/apps/mobile/src/components/ui/Toast.tsx @@ -1,5 +1,5 @@ import { MOBILE_MOTION, MOBILE_SHAPE } from "../../lib/designTokens"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { AccessibilityInfo, Platform, StyleSheet, View, useWindowDimensions } from "react-native"; import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; import Animated, { @@ -55,6 +55,7 @@ function ToastContent(props: ToastProps & { readonly glassReady?: boolean }) { const action = props.action ? ( + ), + TextInput: (props: { + readonly accessibilityLabel?: string; + readonly value: string; + readonly onChangeText: (value: string) => void; + }) => ( + props.onChangeText(event.target.value)} + /> + ), + }; +}); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { + readonly data: ZipTreeRow[]; + readonly ListHeaderComponent: ReactNode; + readonly ListEmptyComponent: ReactNode; + readonly keyExtractor: (row: ZipTreeRow) => string; + readonly renderItem: (input: { item: ZipTreeRow }) => ReactNode; + }) => ( +
+ {props.ListHeaderComponent} + {props.data.length === 0 ? props.ListEmptyComponent : null} + {props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} +
+ ), +})); +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + Paths: { document: { uri: "file:///documents" } }, + File: class { + constructor(private readonly uri: string) {} + get exists() { + return false; + } + get size() { + return files.get(this.uri)?.byteLength ?? null; + } + async arrayBuffer() { + const bytes = files.get(this.uri); + if (!bytes) throw new Error(`Missing ZIP fixture: ${this.uri}`); + return Uint8Array.from(bytes).buffer; + } + }, +})); +vi.mock("expo/fetch", () => ({ fetch: vi.fn() })); +vi.mock("expo-crypto", () => ({ randomUUID: vi.fn() })); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ canGoBack: () => false }), + usePreventRemove: (enabled: boolean, remove: () => void) => { + navigation.remove = enabled ? remove : null; + }, +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); +vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: () => null })); +vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../components/ui/Toolbar", () => { + const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; + return { + Toolbar: Object.assign(Slot, { + Button: () => null, + Menu: Slot, + MenuAction: (props: { + readonly children?: ReactNode; + readonly disabled?: boolean; + readonly onPress?: () => void; + }) => ( + + ), + }), + }; +}); +vi.mock("../../components/ui/AndroidScreenHeader", () => ({ AndroidScreenHeader: () => null })); +vi.mock("../../components/ui/Menu", () => ({ Menu: () => null })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../components/ui/Progress", () => ({ Progress: () => null })); +vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("../../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +vi.mock("../../lib/copyTextWithHaptic", () => ({})); +vi.mock("./SourceFileSurface", () => ({ + SourceFileSurface: (props: { readonly contents: string }) =>
{props.contents}
, +})); +vi.mock("./AudioFilePreview", () => ({ AudioFilePreview: () => null })); +vi.mock("./FileMarkdownPreview", () => ({ FileMarkdownPreview: () => null })); +vi.mock("./WorkspaceFileWebPreview", () => ({ WorkspaceFileWebPreview: () => null })); +vi.mock("../threads/HtmlRenderWebView", () => ({ HtmlRenderWebView: () => null })); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ + appearance: { codeWordBreak: false }, + themeVariables: { "--color-icon": "#000000" }, + setCodeWordBreak: () => {}, + }), +})); + +import { ZipArchiveView } from "./ZipArchiveView"; +import { AttachmentFileScreen } from "./AttachmentFileScreen"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { composerDraftsAtom } from "../../state/use-composer-drafts"; + +function setup() { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers({ toFake: ["setImmediate", "clearImmediate"] }); + files.clear(); + navigation.remove = null; + server.capabilities = { repositoryIdentity: false }; + server.refresh.mockClear(); + appAtomRegistry.set(composerDraftsAtom, {}); + files.set( + "file:///first.zip", + Uint8Array.from( + atob( + "UEsDBBQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAZmlyc3QudHh0Rmlyc3QgYXJjaGl2ZSBjb250ZW50c1BLAQIUAxQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAAAAAAAAAAACAAQAAAABmaXJzdC50eHRQSwUGAAAAAAEAAQA3AAAAPQAAAAAA", + ), + (character) => character.charCodeAt(0), + ), + ); + + const container = document.createElement("div"); + const root = createRoot(container); + onTestFinished(() => { + act(() => root.unmount()); + files.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + const props: ComponentProps = { + name: "archive.zip", + subtitle: "ZIP", + tintColor: "#000000", + sizeBytes: 0, + uri: "file:///first.zip", + isLocal: true, + refresh: async () => null, + environmentId: null, + draftKey: null, + maxAttachmentBytes: undefined, + onSave: () => {}, + onRemove: null, + }; + return { + container, + root, + props, + render: async (input = props) => { + await act(async () => root.render()); + }, + settle: async (assert: () => void) => { + await act(async () => vi.runAllTimersAsync()); + assert(); + }, + }; +} + +it("opens a ZIP chat attachment as a browsable archive on the attachment file screen", async () => { + const screen = setup(); + server.capabilities = { + repositoryIdentity: false, + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 1024 }, + }; + const { fetch } = await import("expo/fetch"); + vi.mocked(fetch, { partial: true }).mockImplementation(async () => ({ + ok: true, + body: new Response(Uint8Array.from(files.get("file:///first.zip")!)).body, + })); + await act(async () => + screen.root.render( + , + ), + ); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + const add = Array.from(screen.container.querySelectorAll("button")).find( + (button) => button.textContent === "Add to chat", + ); + expect(add?.disabled).toBe(false); +}); + +it("keeps the ZIP filter and archive open when native Back closes a selected entry", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + const filter = screen.container.querySelector( + '[aria-label="Filter files in ZIP"]', + )!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + filter, + "first", + ); + filter.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + + expect(navigation.remove).not.toBeNull(); + await act(async () => navigation.remove!()); + + expect(screen.container.querySelector("pre")).toBeNull(); + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(); + expect( + screen.container.querySelector('[aria-label="Filter files in ZIP"]')?.value, + ).toBe("first"); +}); + +it("explains why an encrypted ZIP cannot be browsed", async () => { + const screen = setup(); + const bytes = files.get("file:///first.zip")!; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const directory = view.getUint32(bytes.length - 6, true); + view.setUint16(directory + 8, view.getUint16(directory + 8, true) | 1, true); + await screen.render(); + await screen.settle(() => expect(screen.container.textContent).toContain("Encrypted zip")); + expect(screen.container.querySelector('[aria-label="first.txt"]')).toBeNull(); +}); + +it("keeps the archive and selected entry when the parent re-renders with a new refresh", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + files.delete("file:///first.zip"); + + await screen.render({ ...screen.props, refresh: async () => null }); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + expect(navigation.remove).not.toBeNull(); +}); diff --git a/apps/mobile/src/features/files/ZipArchiveView.tsx b/apps/mobile/src/features/files/ZipArchiveView.tsx new file mode 100644 index 0000000000000..bb9931d4e3c00 --- /dev/null +++ b/apps/mobile/src/features/files/ZipArchiveView.tsx @@ -0,0 +1,870 @@ +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { + ARCHIVE_ENTRY_MAX_BYTES, + ARCHIVE_MAX_BYTES, + boundedZip, +} from "@t3tools/client-runtime/zip/bounded-zip"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; +import { + decodeFilePreviewText, + filePreviewKind, + hostPreviewMimeTypeFromExtension, +} from "@t3tools/shared/filePreview"; +import { LegendList } from "@legendapp/list/react-native"; +import { fetch } from "expo/fetch"; +import { File } from "expo-file-system"; +import { useNavigation, usePreventRemove } from "@react-navigation/native"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { Keyboard, Platform, Pressable, ScrollView, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import { EmptyState } from "../../components/ui/EmptyState"; +import { Progress } from "../../components/ui/Progress"; +import { Toolbar } from "../../components/ui/Toolbar"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import * as Dialog from "../../components/ui/Dialog"; +import { FilePreviewModal } from "../../components/FilePreviewModal"; +import { + nativeViewerErrorMessage, + shareGeneratedAttachment, + writeGeneratedAttachmentFile, +} from "../../lib/attachmentDownload"; +import { attachmentDocumentPresentation } from "../../lib/attachmentDocumentPresentation"; +import { + createComposerFileAttachment, + removePersistedComposerAttachmentFile, +} from "../../lib/composerImages"; +import { appendComposerDraftAttachments } from "../../state/use-composer-drafts"; +import { AttachmentDocumentBody, nativeViewerKind } from "./AttachmentDocumentBody"; +import { FilePreviewLoading } from "./FilePreviewFeedback"; +import { + buildZipTree, + flattenZipTree, + zipDownloadTimeLeft, + zipFolderLabel, + type ZipEntry, + type ZipTreeRow, +} from "./zipTree"; + +export function isZipFile(file: { readonly name: string; readonly mimeType: string }) { + const mime = file.mimeType.split(";", 1)[0]?.trim().toLowerCase(); + return ( + mime === "application/zip" || + mime === "application/x-zip-compressed" || + file.name.toLowerCase().endsWith(".zip") + ); +} + +interface ZipArchive { + readonly entries: ReadonlyArray; + readonly fileCount: number; +} + +interface ZipFailure { + readonly title: string; + readonly detail: string; + readonly retry: boolean; +} + +const SAVE_ZIP_HINT = "Save the ZIP to open it in another app."; +const TOO_LARGE: ZipFailure = { + title: "Too large to browse here", + detail: `Browsing works up to ${formatAttachmentSize(ARCHIVE_MAX_BYTES)}. ${SAVE_ZIP_HINT}`, + retry: false, +}; +const CANCELLED: ZipFailure = { + title: "Download cancelled", + detail: "The ZIP wasn't downloaded. Try again to list its files.", + retry: true, +}; +const NETWORK: ZipFailure = { + title: "Couldn't download this ZIP", + detail: "Check the connection to this environment and try again.", + retry: true, +}; + +class ZipFailureError extends Error { + constructor(readonly failure: ZipFailure) { + super(failure.title); + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error ? cause.message : fallback; +} + +async function readArchiveBody( + body: ReadableStream, + onProgress: (receivedBytes: number) => void, +) { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + chunks.push(value); + onProgress(size); + } + } finally { + void reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes.buffer; +} + +async function openArchive(bytes: ArrayBuffer): Promise { + let files: Awaited>; + try { + files = await boundedZip(bytes); + } catch (cause) { + throw new ZipFailureError({ + title: "Couldn't browse this ZIP", + detail: `${errorMessage(cause, "It may be incomplete or damaged.")} ${SAVE_ZIP_HINT}`, + retry: false, + }); + } + const entries = [...files].map(([path, file]): ZipEntry => { + const name = path.split("/").findLast(Boolean) ?? path; + const dot = name.lastIndexOf("."); + return { + path, + name, + isDirectory: file.directory, + sizeBytes: file.bytes.byteLength, + mimeType: + (dot > 0 ? hostPreviewMimeTypeFromExtension(name.slice(dot).toLowerCase()) : null) ?? + "application/octet-stream", + bytes: file.bytes, + }; + }); + return { entries, fileCount: entries.filter((entry) => !entry.isDirectory).length }; +} + +type ArchiveState = + | { readonly status: "loading"; readonly receivedBytes: number; readonly timeLeft: string | null } + | { readonly status: "ready"; readonly archive: ZipArchive } + | { readonly status: "failed"; readonly failure: ZipFailure }; + +function useZipArchive(input: { + readonly uri: string | null; + readonly isLocal: boolean; + readonly sizeBytes: number; + readonly refresh: () => Promise; +}) { + const [state, setState] = useState({ + status: "loading", + receivedBytes: 0, + timeLeft: null, + }); + const [revision, setRevision] = useState(0); + const controller = useRef(null); + const { uri, isLocal, sizeBytes } = input; + // The parent mints a new refresh callback on every render; it must not reload the archive. + const refresh = useEffectEvent(input.refresh); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Load archive bytes and cancel the stream on cleanup. + useEffect(() => { + if (!uri) return; + const abort = new AbortController(); + controller.current = abort; + const startedAt = Date.now(); + // oxlint-disable-next-line react/set-state-in-effect -- A new archive load resets its progress. + setState({ status: "loading", receivedBytes: 0, timeLeft: null }); + void (async () => { + if (sizeBytes > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + if (isLocal) { + const file = new File(uri); + if ((file.size ?? 0) > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + return openArchive(await file.arrayBuffer()); + } + const url = (await refresh()) ?? uri; + const response = await fetch(url, { signal: abort.signal }); + if (!response.ok || !response.body) throw new ZipFailureError(NETWORK); + const bytes = await readArchiveBody(response.body, (receivedBytes) => + setState({ + status: "loading", + receivedBytes, + timeLeft: zipDownloadTimeLeft({ + receivedBytes, + totalBytes: sizeBytes, + elapsedMs: Date.now() - startedAt, + }), + }), + ); + abort.signal.throwIfAborted(); + return openArchive(bytes); + })() + .then((archive) => { + if (!abort.signal.aborted) setState({ status: "ready", archive }); + }) + .catch((cause: unknown) => { + if (!abort.signal.aborted) + setState({ + status: "failed", + failure: cause instanceof ZipFailureError ? cause.failure : NETWORK, + }); + }); + return () => abort.abort(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reload the archive. + }, [uri, isLocal, sizeBytes, revision]); + return { + state, + retry: () => setRevision((value) => value + 1), + cancel: () => { + controller.current?.abort(); + setState({ status: "failed", failure: CANCELLED }); + }, + }; +} + +function ZipTreeRowView(props: { + readonly row: ZipTreeRow; + readonly collapsed: boolean; + readonly onToggle: (path: string) => void; + readonly onOpen: (entry: ZipEntry) => void; +}) { + const { node, depth } = props.row; + const entry = node.entry; + const folder = entry ? null : zipFolderLabel(node.label); + return ( + (entry ? props.onOpen(entry) : props.onToggle(node.path))} + className="mx-2 h-11 flex-row items-center gap-1.5 rounded-lg pr-2.5 active:bg-subtle" + style={{ paddingLeft: 8 + depth * 18 }} + > + + {entry ? null : ( + + )} + + + + + + {folder ? ( + <> + {folder.prefix} + {folder.name} + + ) : ( + node.label + )} + + + {entry + ? formatAttachmentSize(entry.sizeBytes) + : props.collapsed + ? `${node.fileCount} ${node.fileCount === 1 ? "file" : "files"}` + : ""} + + + ); +} + +function ZipArchiveHeader(props: { + readonly name: string; + readonly subtitle: string; + readonly tintColor: string; + readonly onSave: () => void; + readonly onRemove: (() => void) | null; +}) { + const navigation = useNavigation(); + const isAndroid = Platform.OS === "android"; + return ( + <> + + {isAndroid ? ( + { + if (navigation.canGoBack()) navigation.goBack(); + }} + actions={[ + { accessibilityLabel: "Save ZIP", icon: "square.and.arrow.up", onPress: props.onSave }, + ...(props.onRemove + ? [ + { + accessibilityLabel: "Remove from draft", + icon: "trash" as const, + onPress: props.onRemove, + destructive: true, + overflowOnly: true, + }, + ] + : []), + ]} + /> + ) : ( + + {props.onRemove ? ( + + + Save ZIP + + + Remove from draft + + + ) : ( + + )} + + )} + + ); +} + +function ZipArchiveProgress(props: { + readonly receivedBytes: number; + readonly totalBytes: number; + readonly timeLeft: string | null; + readonly onCancel: () => void; +}) { + const { timeLeft } = props; + return ( + + + {formatAttachmentSize(props.receivedBytes)} + {props.totalBytes > 0 ? ( + + {` of ${formatAttachmentSize(props.totalBytes)}`} + + ) : null} + + 0 ? props.receivedBytes / props.totalBytes : undefined} + label="ZIP download progress" + /> + + {`Downloading the ZIP so its files can be listed.${timeLeft ? ` ${timeLeft} on this connection.` : ""}`} + + + ), +})); +vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0 }) })); +vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: Nothing })); +vi.mock("../../native/native-layout-metrics", () => ({ useNativeColumnLayoutMetrics: () => null })); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { + data: ReadonlyArray; + keyExtractor: (item: T) => string; + renderItem: (info: { item: T }) => ReactNode; + ListHeaderComponent?: ReactNode; + ListEmptyComponent?: ReactNode; + }) => ( +
+ {props.ListHeaderComponent} + {props.data.length === 0 + ? props.ListEmptyComponent + : props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} +
+ ), +})); +vi.mock("../../components/ui/Button", () => ({ + Button: ({ + label, + onPress, + disabled, + }: { + label: string; + onPress: () => void; + disabled?: boolean; + }) => ( + + ), +})); +vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: Nothing })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: Nothing })); +vi.mock("../../components/ui/AppText", () => ({ AppText: Children })); +vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: Nothing })); +vi.mock("../../lib/cn", () => ({ cn: () => "" })); + +import { FileTreeBrowser } from "./FileTreeBrowser"; +import { useFileTreeEntries } from "./useFileTreeEntries"; + +type Hook = typeof useFileTreeEntries; +type Input = Parameters[0]; + +function setup(withBrowser = false) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const state = mocks.createState(); + mocks.state = state; + const rootQuery: QueryState = { + success: null, + data: { entries: rootEntries, truncated: false }, + error: null, + failure: null, + isPending: false, + refresh: vi.fn(), + }; + state.rootQuery = rootQuery; + const searchQuery: QueryState = { ...rootQuery, data: { entries: [], truncated: false } }; + state.searchQuery = searchQuery; + const { requests } = state; + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + let current: ReturnType; + let renders = 0; + let controlledSelection: string | undefined; + function Probe(input: Input) { + if (++renders > 100 && withBrowser) throw new Error("File tree did not settle"); + const result = useFileTreeEntries(input); + useLayoutEffect(() => { + current = result; + }); + return withBrowser ? ( + + ) : null; + } + const unmount = () => { + act(() => root.unmount()); + container.remove(); + }; + return { + requests, + rootQuery, + searchQuery, + get searchSuccess() { + const result = searchQuery.success ?? state.results.get(searchQuery)?.result; + if (result?._tag !== "Success") throw new Error("Search has not succeeded"); + return result; + }, + get responseTimestamp() { + return state.responseTimestamp; + }, + controlSelection(path: string) { + controlledSelection = path; + }, + updateView(expanded: string[], selected: string) { + renders = 0; + act(() => { + current.setExpandedPaths(() => new Set(expanded)); + current.selectPath(selected); + }); + }, + unmount, + get current() { + return current; + }, + get rows() { + return [...container.querySelectorAll("button")].map((button) => + button.getAttribute("aria-label"), + ); + }, + press(label: string) { + renders = 0; + const button = container.querySelector(`button[aria-label="${label}"]`); + if (!button) throw new Error(`No row labelled ${label}`); + act(() => button.click()); + }, + render(target: Partial = {}) { + renders = 0; + const probe = ; + act(() => root.render(probe)); + }, + load(path = "src", refresh = false) { + renders = 0; + act(() => { + void current.loadDirectory(path, refresh); + }); + }, + async succeed(index: number, entries: ProjectEntry[] = []) { + renders = 0; + await act(async () => { + requests[index]!.resolve({ + _tag: "Success", + value: { entries, truncated: false }, + timestamp: ++state.responseTimestamp, + }); + }); + }, + async fail(index: number, message = "Directory unavailable") { + renders = 0; + await act(async () => { + requests[index]!.resolve({ _tag: "Failure", cause: Cause.fail(new Error(message)) }); + }); + }, + [Symbol.dispose]: unmount, + }; +} + +describe("file tree workspace state", () => { + it("retries failed folders once on reconnect", async () => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.fail(0); + act(() => mocks.state.reconnect()); + expect(screen.requests).toHaveLength(2); + act(() => mocks.state.reconnect()); + expect(screen.requests).toHaveLength(2); + await screen.succeed(1); + act(() => mocks.state.reconnect()); + expect(screen.requests).toHaveLength(2); + }); + + it("publishes immutable loading and loaded snapshots", async () => { + using screen = setup(); + screen.render(); + const initial = screen.current; + screen.load(); + const loading = screen.current; + expect(loading.isPending).toBe(true); + expect([...loading.loadingDirectories]).toEqual(["src"]); + expect([...initial.loadingDirectories]).toEqual([]); + await screen.succeed(0, [{ kind: "file", path: "src/first.ts" }]); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/first.ts"]); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + expect(screen.current.isPending).toBe(false); + expect([...screen.current.loadingDirectories]).toEqual([]); + expect(loading.entries.map((entry) => entry.path)).toEqual(["src"]); + expect([...loading.loadedDirectories]).toEqual([]); + expect([...loading.loadingDirectories]).toEqual(["src"]); + }); + + it.each([{ cwd: "/second" }, { environmentId: EnvironmentId.make("second") }])( + "isolates directory state and restores it on return from %j", + async (target) => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, [{ kind: "file", path: "src/first.ts" }]); + + screen.updateView(["src"], "src/first.ts"); + const firstView = screen.current; + screen.render(target); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBeNull(); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect([...screen.current.loadedDirectories]).toEqual([]); + expect([...screen.current.loadingDirectories]).toEqual([]); + screen.load(); + expect(screen.requests[1]!.query).toEqual({ + environmentId: target.environmentId ?? firstTarget.environmentId, + input: { cwd: target.cwd ?? firstTarget.cwd, directoryPath: "src" }, + }); + await screen.succeed(1, [{ kind: "file", path: "src/second.ts" }]); + + screen.updateView([], "src/second.ts"); + expect([...firstView.expandedPaths]).toEqual(["src"]); + expect(firstView.selectedPath).toBe("src/first.ts"); + screen.render(); + expect([...screen.current.expandedPaths]).toEqual(["src"]); + expect(screen.current.selectedPath).toBe("src/first.ts"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/first.ts"]); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + screen.load(); + expect(screen.requests).toHaveLength(3); + await screen.succeed(2, [{ kind: "file", path: "src/first.ts" }]); + + screen.render(target); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBe("src/second.ts"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/second.ts"]); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + }, + ); + + it("ignores stale successes and failures after leaving and returning to a workspace", async () => { + using screen = setup(); + screen.render(); + screen.load(); + screen.load("src/other"); + screen.render({ cwd: "/second" }); + screen.render(); + screen.load(); + + await screen.succeed(0, [{ kind: "file", path: "src/stale.ts" }]); + await screen.fail(1, "Stale error"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect(screen.current.error).toBeNull(); + expect([...screen.current.loadingDirectories]).toEqual(["src"]); + expect(screen.current.isPending).toBe(true); + + await screen.succeed(2, [{ kind: "file", path: "src/current.ts" }]); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/current.ts"]); + expect(screen.current.error).toBeNull(); + expect(screen.current.isPending).toBe(false); + expect([...screen.current.loadingDirectories]).toEqual([]); + }); + + it("retains recent listings and expansion when leaving a large workspace", async () => { + using screen = setup(); + screen.render(); + const paths = Array.from({ length: 70 }, (_, index) => `src/directory-${index}`); + screen.load(); + await screen.succeed( + 0, + paths.map((path) => ({ kind: "directory", path })), + ); + for (const path of paths) { + screen.load(path); + await screen.succeed(screen.requests.length - 1, [{ kind: "file", path: `${path}/file.ts` }]); + } + screen.updateView(["src", ...paths], `${paths[69]}/file.ts`); + screen.render({ cwd: "/other" }); + screen.render(); + expect(screen.current.loadedDirectories.size).toBe(64); + expect(screen.current.loadedDirectories.has(paths[69]!)).toBe(true); + expect(screen.current.expandedPaths.size).toBe(71); + expect(screen.current.selectedPath).toBe(`${paths[69]}/file.ts`); + for (let index = 1; index <= 8; index++) screen.render({ cwd: `/workspace-${index}` }); + screen.render(); + expect(screen.current.loadedDirectories.size).toBe(0); + expect(screen.current.expandedPaths.size).toBe(0); + expect(screen.current.selectedPath).toBeNull(); + }); + + it("retries a failed folder only when the user expands it again", async () => { + using screen = setup(true); + screen.render(); + expect(screen.rows).toEqual(["src"]); + expect(screen.requests).toHaveLength(0); + + screen.press("src"); + expect(screen.requests).toHaveLength(1); + await screen.fail(0); + expect(screen.current.error).toBe("Directory unavailable"); + // Re-rendering and the browser's passive expansion effect never retry a failed listing. + screen.render(); + screen.render(); + expect(screen.requests).toHaveLength(1); + expect(screen.rows).toEqual(["src"]); + + screen.press("src"); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.requests).toHaveLength(1); + screen.press("src"); + expect([...screen.current.expandedPaths]).toEqual(["src"]); + expect(screen.requests).toHaveLength(2); + await screen.succeed(1, [{ kind: "file", path: "src/file.ts" }]); + expect(screen.current.error).toBeNull(); + expect(screen.rows).toEqual(["src", "src/file.ts"]); + + // A loaded folder serves from cache on collapse and expand. + screen.press("src"); + screen.press("src"); + expect(screen.rows).toEqual(["src", "src/file.ts"]); + expect(screen.requests).toHaveLength(2); + }); + + it("reveals a controlled leaf-only search result after search clears", () => { + using screen = setup(true); + screen.render(); + screen.searchQuery.data = { + entries: [{ kind: "file", path: "src/new/file.ts" }], + truncated: false, + }; + screen.controlSelection("src/new/file.ts"); + screen.render({ searchQuery: "file" }); + expect([...screen.current.expandedPaths]).toEqual(["src", "src/new"]); + screen.render(); + expect(screen.current.entries.map((entry) => entry.path)).toContain("src/new/file.ts"); + }); + + it.each(["collapse", "delete"])("settles controlled selection after %s", async (action) => { + using screen = setup(true); + screen.render(); + screen.updateView([], "src/nested/file.ts"); + await screen.succeed(0, [{ kind: "directory", path: "src/nested" }]); + expect([...screen.current.expandedPaths]).toEqual(["src", "src/nested"]); + if (action === "collapse") { + screen.updateView([], "src/nested/file.ts"); + expect([...screen.current.expandedPaths]).toEqual([]); + screen.render({ cwd: "/second" }); + screen.render(); + expect([...screen.current.expandedPaths]).toEqual([]); + } + screen.rootQuery.data = { entries: [], truncated: false }; + screen.render(); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBeNull(); + if (action === "delete") { + screen.controlSelection("src/nested/file.ts"); + screen.searchQuery.data = { + entries: [ + { kind: "directory", path: "src" }, + { kind: "directory", path: "src/nested" }, + { kind: "file", path: "src/nested/file.ts" }, + ], + truncated: false, + }; + screen.rootQuery.failure = new ProjectListEntriesError({ + cwd: "/first", + failure: "workspace_root_not_found", + }); + screen.rootQuery.error = screen.rootQuery.failure.message; + const requestCount = screen.requests.length; + for (const error of [null, "Search unavailable"]) { + screen.searchQuery.error = error; + screen.render({ searchQuery: "nested" }); + expect(screen.current.entries).toEqual([]); + expect([...screen.current.expandedPaths]).toEqual([]); + expect([...screen.current.loadedDirectories]).toEqual([]); + expect(screen.current.isPending).toBe(false); + expect(screen.requests).toHaveLength(requestCount); + } + } + }); + + it.each(["return", "missing root"])( + "revalidates retained children after %s without clearing for transient errors", + async (scenario) => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, [{ kind: "file", path: "src/old.ts" }]); + screen.updateView(["src"], "src/old.ts"); + screen.render({ cwd: "/second" }); + screen.render(); + screen.rootQuery.error = "Connection unavailable"; + screen.render(); + expect(screen.current.selectedPath).toBe("src/old.ts"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/old.ts"]); + if (scenario === "missing root") { + screen.rootQuery.failure = new ProjectListEntriesError({ + cwd: "/first", + failure: "workspace_root_not_found", + }); + screen.rootQuery.error = screen.rootQuery.failure.message; + expect(screen.rootQuery.error).toBe("Failed to list workspace entries in '/first'."); + screen.render(); + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.expandedPaths]).toEqual([]); + expect([...screen.current.loadedDirectories]).toEqual([]); + } + screen.rootQuery.error = null; + screen.rootQuery.failure = null; + screen.render(); + if (scenario === "missing root") { + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect(screen.current.selectedPath).toBeNull(); + } + screen.load(); + expect(screen.requests).toHaveLength(2); + await screen.succeed(1, []); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect(screen.current.selectedPath).toBeNull(); + }, + ); + + it.each(["new", "src/new"])( + "keeps search discovery %s until its parent listing revalidates it", + async (path) => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, []); + screen.searchQuery.data = { + entries: [{ kind: "file", path: `${path}/file.ts` }], + truncated: false, + }; + screen.render({ searchQuery: "new" }); + const expanded = path.includes("/") ? ["src", path] : [path]; + screen.updateView(expanded, `${path}/file.ts`); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + screen.load(path); + await screen.succeed(1, [ + { kind: "file", path: `${path}/file.ts` }, + { kind: "file", path: `${path}/other.ts` }, + ]); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + + const cachedSearch = screen.searchSuccess; + for (const searchQuery of ["", "other"]) { + screen.searchQuery.data = { entries: [], truncated: false }; + screen.render({ searchQuery }); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/file.ts` }); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/other.ts` }); + } + + screen.render(); + for (const target of [{ cwd: "/second" }, { environmentId: EnvironmentId.make("second") }]) { + screen.render(target); + screen.render(); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/other.ts` }); + } + + screen.searchQuery.data = { + entries: [{ kind: "file", path: `${path}/file.ts` }], + truncated: false, + }; + screen.render({ searchQuery: "new" }); + for (const equalContents of [false, true]) { + // A later parent listing wins over retained discovery evidence. + if (path.includes("/")) { + // Refresh re-lists the root and every reachable folder; the empty parent prunes the child. + const refreshed = screen.requests.length; + act(() => { + void screen.current.refresh(); + }); + expect(screen.requests.slice(refreshed).map((request) => request.query.input)).toEqual([ + { cwd: "/first", directoryPath: "" }, + { cwd: "/first", query: "new", limit: 200 }, + { cwd: "/first", directoryPath: "src" }, + { cwd: "/first", directoryPath: path }, + ]); + // The parent settles last so its listing carries the newest response timestamp. + for (const index of [refreshed, refreshed + 1, refreshed + 3, refreshed + 2]) { + await screen.succeed(index, []); + } + } else { + const cachedData = screen.rootQuery.data; + screen.rootQuery.isPending = true; + screen.render({ searchQuery: "new" }); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + screen.rootQuery.isPending = false; + expect(screen.rootQuery.data).toBe(cachedData); + screen.render({ searchQuery: "new" }); + } + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.expandedPaths]).toEqual(path.includes("/") ? ["src"] : []); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + + // Cover an older cache and the timestamp's ambiguous same-millisecond boundary. + for (const success of [ + cachedSearch, + AsyncResult.success(cachedSearch.value, { timestamp: screen.responseTimestamp }), + ]) { + screen.searchQuery.data = { entries: [], truncated: false }; + screen.render({ searchQuery: "other" }); + screen.searchQuery.success = success; + screen.searchQuery.data = success.value; + screen.render({ searchQuery: "new" }); + screen.updateView(expanded, `${path}/file.ts`); + const requestCount = screen.requests.length; + screen.load(path); + expect(screen.requests).toHaveLength(requestCount + 1); + await screen.fail(requestCount); + screen.searchQuery.success = null; + screen.render(); + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.expandedPaths]).toEqual(path.includes("/") ? ["src"] : []); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + expect( + screen.current.entries.some( + (entry) => entry.path === path || entry.path.startsWith(`${path}/`), + ), + ).toBe(false); + } + + const cachedSearchData = screen.searchQuery.data; + screen.searchQuery.isPending = true; + screen.render({ searchQuery: "new" }); + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + screen.searchQuery.isPending = false; + if (!equalContents) { + screen.searchQuery.data = { + ...cachedSearchData, + entries: [...cachedSearchData.entries, { kind: "file", path: `${path}/added.ts` }], + }; + } + expect(screen.searchQuery.data === cachedSearchData).toBe(equalContents); + screen.render({ searchQuery: "new" }); + screen.updateView(expanded, `${path}/file.ts`); + screen.load(path); + await screen.succeed(screen.requests.length - 1, [ + { kind: "file", path: `${path}/file.ts` }, + { kind: "file", path: `${path}/recreated.ts` }, + ]); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/file.ts` }); + expect(screen.current.entries).toContainEqual({ + kind: "file", + path: `${path}/recreated.ts`, + }); + } + }, + ); + + it("prunes deleted directory contents, expansion and selection after a fresh parent listing", async () => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, [{ kind: "directory", path: "src/nested" }]); + screen.load("src/nested"); + await screen.succeed(1, [{ kind: "file", path: "src/nested/file.ts" }]); + screen.updateView(["src", "src/nested"], "src/nested/file.ts"); + screen.rootQuery.data = { entries: [], truncated: true }; + screen.render(); + expect([...screen.current.loadedDirectories]).toEqual(["src", "src/nested"]); + expect(screen.current.selectedPath).toBe("src/nested/file.ts"); + screen.rootQuery.data = { entries: [], truncated: false }; + screen.render(); + expect(screen.current.entries).toEqual([]); + expect([...screen.current.loadedDirectories]).toEqual([]); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBeNull(); + screen.rootQuery.data = { entries: rootEntries, truncated: false }; + screen.render(); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + }); +}); diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index 3c728d48ad090..62c2e33746a9f 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -1,13 +1,104 @@ +import { useAtomMount, useAtomSubscribe } from "@effect/atom-react"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; +import { ProjectListEntriesError, type EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { useEffect, useReducer, useRef, useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; +import { Atom } from "effect/reactivity"; +import { environmentCatalog } from "../../connection/catalog"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; import { useDebouncedValue } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; +function createDirectories() { + return { + target: {}, + entries: new Map>(), + errors: new Map(), + completeDirectories: new Set(), + listingCompletedAt: new Map(), + discoveries: new Map(), + rootListing: null as { readonly timestamp: number } | null, + validatedDirectories: new Set(), + loading: new Set(), + expandedPaths: new Set(), + selectedPath: null as string | null, + revealedSelection: { path: null as string | null, ancestors: new Set() }, + isRefreshing: false, + }; +} + +function boundDirectories(current: ReturnType, preserveVisible = true) { + const visible = (path: string) => { + const parts = path.split("/"); + return parts.every((_, index) => + current.expandedPaths.has(parts.slice(0, index + 1).join("/")), + ); + }; + const inactive = [...new Set([...current.entries.keys(), ...current.errors.keys()])].filter( + (path) => !preserveVisible || !visible(path), + ); + const ownedPaths = [ + ...current.entries.keys(), + ...current.expandedPaths, + current.selectedPath, + ].filter((path) => path !== null); + const discoveries = new Map( + [...current.discoveries].filter(([path]) => + ownedPaths.some((owned) => owned === path || owned.startsWith(`${path}/`)), + ), + ); + if (inactive.length <= 64) + return discoveries.size === current.discoveries.size ? current : { ...current, discoveries }; + const evicted = new Set(inactive.slice(0, -64)); + // Evict listing data, not the user's expansion and selection. A missing parent reloads on return. + return { + ...current, + discoveries, + entries: new Map([...current.entries].filter(([path]) => !evicted.has(path))), + errors: new Map([...current.errors].filter(([path]) => !evicted.has(path))), + completeDirectories: new Set( + [...current.completeDirectories].filter((path) => !evicted.has(path)), + ), + validatedDirectories: new Set( + [...current.validatedDirectories].filter((path) => !evicted.has(path)), + ), + listingCompletedAt: new Map( + [...current.listingCompletedAt].filter(([path]) => !evicted.has(path)), + ), + }; +} + +function contradictsListing(path: string, parent: string, entries: ReadonlyArray) { + if (parent !== "" && !path.startsWith(`${parent}/`)) return false; + const child = path.slice(parent === "" ? 0 : parent.length + 1).split("/")[0]; + const childPath = parent === "" ? child : `${parent}/${child}`; + const entry = entries.find((item) => item.path === childPath); + return !entry || (path !== childPath && entry.kind !== "directory"); +} + +function createRequests() { + return { + active: true, + refreshVersion: 0, + pending: new Map(), + }; +} + +function getRequests( + requestsByTarget: WeakMap>, + target: object, +) { + let requests = requestsByTarget.get(target); + if (!requests) { + requests = createRequests(); + requestsByTarget.set(target, requests); + } + return requests; +} +const noSessionGeneration = Atom.make(0); + export function useFileTreeEntries(input: { readonly cwd: string | null; readonly environmentId: EnvironmentId | null; @@ -22,6 +113,7 @@ export function useFileTreeEntries(input: { ? projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }) : null, ); + const { refresh: refreshRoot } = root; const search = useEnvironmentQuery( searching && debouncedQuery.length > 0 && cwd !== null && environmentId !== null ? projectEnvironment.searchEntries({ @@ -30,42 +122,98 @@ export function useFileTreeEntries(input: { }) : null, ); - const [, render] = useReducer((value: number) => value + 1, 0); - const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0); - const refreshVersion = useRef(0); - const directories = { - cwd, - environmentId, - entries: new Map>(), - requested: new Set(), - pending: new Map(), - errors: new Map(), - }; - const [refreshingDirectories, setRefreshingDirectories] = useState( - null, - ); - useEffect( - () => () => { - refreshVersion.current++; - for (const controller of directories.pending.values()) controller.abort(); - directories.pending.clear(); - }, - [directories], - ); + const workspaceKey = JSON.stringify([environmentId, cwd]); + const [cache, setCache] = useState(() => ({ + key: workspaceKey, + workspaces: new Map([[workspaceKey, createDirectories()]]), + })); + const savedDirectories = cache.workspaces.get(workspaceKey) ?? createDirectories(); + // Requests belong to a visit, so returning cannot revive an old completion. + const directories = + cache.key === workspaceKey + ? savedDirectories + : { + ...savedDirectories, + target: {}, + validatedDirectories: new Set(), + errors: new Map(), + loading: new Set(), + isRefreshing: false, + }; + if (cache.key !== workspaceKey) { + const workspaces = new Map(cache.workspaces); + const previous = workspaces.get(cache.key); + if (previous) { + workspaces.set( + cache.key, + boundDirectories( + { + ...previous, + errors: new Map(), + loading: new Set(), + isRefreshing: false, + }, + false, + ), + ); + } + workspaces.delete(workspaceKey); + workspaces.set(workspaceKey, directories); + if (workspaces.size > 8) workspaces.delete(workspaces.keys().next().value!); + setCache({ key: workspaceKey, workspaces }); + } + const setDirectories = ( + update: (current: ReturnType) => ReturnType, + ) => + setCache((current) => { + const previous = current.workspaces.get(workspaceKey); + if (!previous) return current; + const next = boundDirectories(update(previous)); + if (next === previous) return current; + return { ...current, workspaces: new Map(current.workspaces).set(workspaceKey, next) }; + }); + const { target } = directories; + const requestsByTarget = useRef(new WeakMap>()); + // Cancel the old target before consumers' passive effects start new directory loads. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the root and aborts the old target's directory requests before consumers start new loads + useLayoutEffect(() => { + const requests = getRequests(requestsByTarget.current, target); + requests.active = true; + refreshRoot(); + return () => { + requests.active = false; + requests.refreshVersion++; + for (const controller of requests.pending.values()) controller.abort(); + requests.pending.clear(); + setDirectories((current) => + current.target === target + ? { ...current, loading: new Set(), isRefreshing: false } + : current, + ); + }; + }, [target, setDirectories, requestsByTarget, refreshRoot]); + const updateDirectories = ( + update: (current: ReturnType) => ReturnType, + ) => setDirectories((current) => (current.target === target ? update(current) : current)); const loadDirectory = (directoryPath: string, refresh = false) => { + const requests = getRequests(requestsByTarget.current, target); if ( + !requests.active || + missingRoot || cwd === null || environmentId === null || - (!refresh && directories.entries.has(directoryPath)) || - directories.pending.has(directoryPath) + (!refresh && directories.validatedDirectories.has(directoryPath)) || + requests.pending.has(directoryPath) ) { return; } const controller = new AbortController(); - directories.requested.add(directoryPath); - directories.pending.set(directoryPath, controller); - directories.errors.delete(directoryPath); - render(); + requests.pending.set(directoryPath, controller); + updateDirectories((current) => { + const errors = new Map(current.errors); + errors.delete(directoryPath); + return { ...current, errors, loading: new Set(current.loading).add(directoryPath) }; + }); const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); appAtomRegistry.refresh(atom); return executeAtomQuery(appAtomRegistry, atom, { @@ -74,31 +222,87 @@ export function useFileTreeEntries(input: { reportDefect: false, }).then((result) => { if (controller.signal.aborted) return; - directories.pending.delete(directoryPath); - if (result._tag === "Success") { - directories.entries.set( - directoryPath, - result.value.entries.filter( - (entry) => - entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, - ), - ); - entriesChanged(); - } else { + requests.pending.delete(directoryPath); + updateDirectories((current) => { + const validatedDirectories = new Set(current.validatedDirectories).add(directoryPath); + const loading = new Set(current.loading); + loading.delete(directoryPath); + if (result._tag === "Success") { + const entries = new Map(current.entries); + entries.delete(directoryPath); + entries.set( + directoryPath, + result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ); + const completeDirectories = new Set(current.completeDirectories); + if (result.value.truncated) completeDirectories.delete(directoryPath); + else completeDirectories.add(directoryPath); + return { + ...current, + entries, + loading, + completeDirectories, + discoveries: result.value.truncated + ? current.discoveries + : new Map( + [...current.discoveries].filter( + ([path]) => !contradictsListing(path, directoryPath, result.value.entries), + ), + ), + validatedDirectories, + listingCompletedAt: new Map(current.listingCompletedAt).set( + directoryPath, + result.timestamp, + ), + }; + } const error = Cause.squash(result.cause); - directories.errors.set( - directoryPath, - error instanceof Error ? error.message : "Files unavailable", - ); - } - render(); + return { + ...current, + loading, + validatedDirectories, + errors: new Map(current.errors).set( + directoryPath, + error instanceof Error ? error.message : "Files unavailable", + ), + }; + }); }); }; - const { refresh: refreshRoot, data: rootData } = root; - const { refresh: refreshSearch, data: searchData } = search; + const missingRoot = + root.failure instanceof ProjectListEntriesError && + root.failure.failure === "workspace_root_not_found"; + const rootData = missingRoot ? null : root.data; + const rootListing = root.successResult ?? directories.rootListing; + if ( + missingRoot && + (directories.entries.size > 0 || + directories.errors.size > 0 || + directories.loading.size > 0 || + directories.expandedPaths.size > 0 || + directories.selectedPath !== null || + directories.discoveries.size > 0) + ) { + setDirectories(() => createDirectories()); + } + const { refresh: refreshSearch, data: searchData, successResult: searchResult } = search; + // A folder read fails at once while its environment is unreachable; read it again once a new + // session is up rather than leaving the error until someone refreshes. + const sessionGeneration = + environmentId === null + ? noSessionGeneration + : environmentCatalog.sessionGenerationAtom(environmentId); + // A listener alone does not compute the atom, so nothing would follow the connection. + useAtomMount(sessionGeneration); + useAtomSubscribe(sessionGeneration, () => { + for (const path of directories.errors.keys()) void loadDirectory(path, true); + }); const snapshot = (() => { - const merged = new Map(); - if (searching) { + const merged = new Map(missingRoot ? [] : directories.discoveries); + if (searching && !missingRoot) { for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry); } const reachableDirectories = new Set(); @@ -111,31 +315,158 @@ export function useFileTreeEntries(input: { } } }; + if (!missingRoot) { + const discoveries = new Map(directories.discoveries); + // Search can return only leaves; their inferred parents still own loaded children. + for (const entry of directories.discoveries.values()) { + const parts = entry.path.split("/"); + for (let index = 1; index < parts.length; index++) { + const path = parts.slice(0, index).join("/"); + if (!discoveries.has(path)) discoveries.set(path, { kind: "directory", path }); + } + } + visit([...discoveries.values()]); + } visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); return { - revision: entriesRevision, entries: [...merged.values()], reachableDirectories, - searchResultPaths: searching - ? new Set((searchData?.entries ?? []).map((entry) => entry.path)) - : null, + searchResultPaths: + searching && !missingRoot + ? new Set((searchData?.entries ?? []).map((entry) => entry.path)) + : null, }; })(); + if (rootData) { + const deleted = (path: string, directory = false) => { + const parts = path.split("/"); + for (let index = 0; index < parts.length; index++) { + const parent = parts.slice(0, index).join("/"); + const listing = index === 0 ? rootData.entries : directories.entries.get(parent); + const child = parts.slice(0, index + 1).join("/"); + const complete = + index === 0 ? !rootData.truncated : directories.completeDirectories.has(parent); + const entry = listing?.find((item) => item.path === child); + if (complete && listing && !entry) { + const listingCompletedAt = + index === 0 && directories.rootListing !== rootListing + ? rootListing?.timestamp + : directories.listingCompletedAt.get(parent); + // Cached queries keep their completion time across query switches. + // Equal-millisecond responses cannot prove a path postdates the listing. + const retained = + !(index === 0 && directories.rootListing !== rootListing) && + [...directories.discoveries.keys()].some( + (path) => path === child || path.startsWith(`${child}/`), + ); + const discovered = + retained || + (searching && + query === debouncedQuery && + searchResult !== null && + searchResult.timestamp > (listingCompletedAt ?? -Infinity) && + searchData?.entries.some( + (item) => item.path === child || item.path.startsWith(`${child}/`), + )); + if (!discovered) return true; + } + if (entry && (directory || index < parts.length - 1) && entry.kind !== "directory") + return true; + } + return false; + }; + const reconciled = (() => { + const current = directories; + const discoveries = new Map(current.discoveries); + if (current.rootListing !== rootListing && !rootData.truncated) { + for (const path of discoveries.keys()) { + if (contradictsListing(path, "", rootData.entries)) discoveries.delete(path); + } + } + if (searching && query === debouncedQuery && searchResult !== null) { + const ownedPaths = [ + ...current.entries.keys(), + ...current.expandedPaths, + current.selectedPath, + ]; + for (const entry of searchData?.entries ?? []) { + if ( + ownedPaths.some((path) => path === entry.path || path?.startsWith(`${entry.path}/`)) && + !deleted(entry.path, entry.kind === "directory") + ) + discoveries.set(entry.path, entry); + } + } + const entries = new Map([...current.entries].filter(([path]) => !deleted(path, true))); + const errors = new Map([...current.errors].filter(([path]) => !deleted(path, true))); + const expandedPaths = new Set( + [...current.expandedPaths].filter((path) => !deleted(path, true)), + ); + const selectedPath = + current.selectedPath !== null && deleted(current.selectedPath) + ? null + : current.selectedPath; + if ( + entries.size === current.entries.size && + errors.size === current.errors.size && + expandedPaths.size === current.expandedPaths.size && + selectedPath === current.selectedPath && + current.rootListing === rootListing && + discoveries.size === current.discoveries.size && + [...discoveries].every(([path, entry]) => current.discoveries.get(path) === entry) + ) + return current; + const completeDirectories = new Set( + [...current.completeDirectories].filter((path) => entries.has(path)), + ); + return { + ...current, + rootListing, + discoveries, + listingCompletedAt: new Map( + [...current.listingCompletedAt].filter(([path]) => path === "" || entries.has(path)), + ).set( + "", + current.rootListing === rootListing + ? (current.listingCompletedAt.get("") ?? -Infinity) + : (rootListing?.timestamp ?? -Infinity), + ), + entries, + errors, + expandedPaths, + selectedPath, + completeDirectories, + validatedDirectories: new Set( + [...current.validatedDirectories].filter((path) => !deleted(path, true)), + ), + }; + })(); + if (reconciled !== directories) setDirectories(() => reconciled); + } + const refresh = () => { + const requests = getRequests(requestsByTarget.current, target); + if (!requests.active) return Promise.resolve(); refreshRoot(); - if (searching) refreshSearch(); + if (searching && !missingRoot) refreshSearch(); const paths = new Set( - [...directories.requested].filter((path) => snapshot.reachableDirectories.has(path)), + [...directories.entries.keys(), ...directories.errors.keys(), ...directories.loading].filter( + (path) => snapshot.reachableDirectories.has(path), + ), ); - for (const controller of directories.pending.values()) controller.abort(); - directories.pending.clear(); - directories.errors.clear(); - const version = ++refreshVersion.current; - setRefreshingDirectories(directories); + for (const controller of requests.pending.values()) controller.abort(); + requests.pending.clear(); + const version = ++requests.refreshVersion; + updateDirectories((current) => ({ + ...current, + errors: new Map(), + loading: new Set(), + isRefreshing: true, + })); const remaining = paths.values(); const worker = async () => { - while (version === refreshVersion.current) { + while (requests.active && version === requests.refreshVersion) { const next = remaining.next(); if (next.done) return; await loadDirectory(next.value, true); @@ -145,7 +476,7 @@ export function useFileTreeEntries(input: { cwd !== null && environmentId !== null ? [ projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }), - ...(searching + ...(searching && !missingRoot ? [ projectEnvironment.searchEntries({ environmentId, @@ -161,29 +492,80 @@ export function useFileTreeEntries(input: { ), ...Array.from({ length: Math.min(4, paths.size) }, () => worker()), ]; - render(); return Promise.all(work).finally(() => { - if (version === refreshVersion.current) setRefreshingDirectories(null); + if (version === requests.refreshVersion) { + updateDirectories((current) => ({ ...current, isRefreshing: false })); + } }); }; + const revealSelection = (path: string | null) => { + if (missingRoot) return; + updateDirectories((current) => { + const previous = current.revealedSelection; + const handled = previous.path === path ? previous.ancestors : new Set(); + const parts = path?.split("/").filter(Boolean) ?? []; + const ancestors = parts + .slice(1) + .map((_, index) => parts.slice(0, index + 1).join("/")) + .filter( + (ancestor) => + !handled.has(ancestor) && + snapshot.entries.some( + (entry) => + (entry.kind === "directory" && entry.path === ancestor) || + (entry.path === path && entry.path.startsWith(`${ancestor}/`)), + ), + ); + if (previous.path === path && ancestors.length === 0) return current; + return { + ...current, + discoveries: new Map([ + ...current.discoveries, + ...snapshot.entries + .filter((entry) => entry.path === path) + .map((entry) => [entry.path, entry] as const), + ]), + selectedPath: path, + revealedSelection: { path, ancestors: new Set([...handled, ...ancestors]) }, + expandedPaths: new Set([...current.expandedPaths, ...ancestors]), + }; + }); + }; + + const setExpandedPaths = (update: (current: ReadonlySet) => ReadonlySet) => + setDirectories((current) => { + const expandedPaths = update(current.expandedPaths); + return expandedPaths === current.expandedPaths + ? current + : { ...current, expandedPaths: new Set(expandedPaths) }; + }); + return { + revealSelection, + expandedPaths: directories.expandedPaths, + setExpandedPaths, + selectedPath: directories.selectedPath, + selectPath: (selectedPath: string) => + updateDirectories((current) => ({ ...current, selectedPath })), entries: snapshot.entries, searchResultPaths: snapshot.searchResultPaths, - isRefreshing: refreshingDirectories === directories, + isRefreshing: directories.isRefreshing, error: root.error ?? (searching ? search.error : null) ?? [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ?? null, isPending: - root.isPending || - directories.pending.size > 0 || - (searching && (query !== debouncedQuery || search.isPending)), - searchTruncated: searching && (search.data?.truncated ?? false), + !missingRoot && + (root.isPending || + directories.loading.size > 0 || + (searching && (query !== debouncedQuery || search.isPending))), + searchTruncated: !missingRoot && searching && (search.data?.truncated ?? false), loadedDirectories: new Set(directories.entries.keys()), - loadingDirectories: new Set(directories.pending.keys()), - loadDirectory, + loadingDirectories: directories.loading, + loadDirectory: (path: string, retry = false) => + loadDirectory(path, retry && directories.errors.has(path)), refresh, }; } diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index f109ac0042a8e..fa52af019cb0a 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -16,13 +16,10 @@ export function useWorkspaceFileAssetUrlState(props: { readonly threadId: ThreadId | null; readonly draftCwd?: string | null; }) { - const absolutePath = useMemo( - () => - props.cwd !== null && props.relativePath !== null - ? resolveWorkspaceFilePath(props.cwd, props.relativePath) - : null, - [props.cwd, props.relativePath], - ); + const absolutePath = + props.cwd !== null && props.relativePath !== null + ? resolveWorkspaceFilePath(props.cwd, props.relativePath) + : null; const relativePath = props.relativePath; const draftCwd = props.draftCwd ?? null; diff --git a/apps/mobile/src/features/files/zipTree.ts b/apps/mobile/src/features/files/zipTree.ts new file mode 100644 index 0000000000000..dcc9552c5c122 --- /dev/null +++ b/apps/mobile/src/features/files/zipTree.ts @@ -0,0 +1,139 @@ +export interface ZipEntry { + readonly path: string; + readonly name: string; + readonly isDirectory: boolean; + readonly sizeBytes: number; + readonly mimeType: string; + readonly bytes: Uint8Array; +} + +export interface ZipTreeNode { + readonly path: string; + readonly label: string; + readonly entry: ZipEntry | null; + readonly children: ReadonlyArray; + readonly fileCount: number; +} + +export interface ZipTreeRow { + readonly node: ZipTreeNode; + readonly depth: number; +} + +interface MutableNode { + path: string; + name: string; + entry: ZipEntry | null; + children: Map; +} + +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +function freeze(node: MutableNode): ZipTreeNode { + let current = node; + let label = node.name; + while (current.path !== "" && current.entry === null && current.children.size === 1) { + const [only] = current.children.values(); + if (!only || only.entry !== null) break; + current = only; + label = `${label}/${only.name}`; + } + const children = [...current.children.values()] + .sort((left, right) => + (left.entry === null) !== (right.entry === null) + ? left.entry === null + ? -1 + : 1 + : collator.compare(left.name, right.name), + ) + .map(freeze); + return { + path: current.path, + label, + entry: current.entry, + children, + fileCount: + current.entry === null ? children.reduce((sum, child) => sum + child.fileCount, 0) : 1, + }; +} + +export function buildZipTree(entries: ReadonlyArray): ReadonlyArray { + const root: MutableNode = { path: "", name: "", entry: null, children: new Map() }; + for (const entry of entries) { + const parts = entry.path.split("/").filter(Boolean); + let current = root; + parts.forEach((part, index) => { + const leaf = index === parts.length - 1 && !entry.isDirectory; + const key = leaf ? part : `${part}/`; + let child = current.children.get(key); + if (!child) { + child = { + path: parts.slice(0, index + 1).join("/"), + name: part, + entry: leaf ? entry : null, + children: new Map(), + }; + current.children.set(key, child); + } + current = child; + }); + } + return freeze(root).children; +} + +function flatten( + output: ZipTreeRow[], + node: ZipTreeNode, + depth: number, + collapsed: ReadonlySet, + query: string, +): boolean { + if (node.entry) { + if (query && !node.entry.path.toLowerCase().includes(query)) return false; + output.push({ node, depth }); + return true; + } + const children: ZipTreeRow[] = []; + let visible = !query; + if (query || !collapsed.has(node.path)) { + for (const child of node.children) { + if (flatten(children, child, depth + 1, collapsed, query)) visible = true; + } + } + if (!visible) return false; + output.push({ node, depth }, ...children); + return true; +} + +export function flattenZipTree( + nodes: ReadonlyArray, + collapsed: ReadonlySet, + query: string, +): ReadonlyArray { + const output: ZipTreeRow[] = []; + const normalized = query.trim().toLowerCase(); + for (const node of nodes) flatten(output, node, 0, collapsed, normalized); + return output; +} + +export function zipFolderLabel(label: string) { + const parts = label.split("/"); + const name = parts.at(-1) ?? label; + if (parts.length > 3) return { prefix: `${parts[0]}/…/${parts.at(-2)}/`, name }; + return { prefix: parts.length > 1 ? `${parts.slice(0, -1).join("/")}/` : "", name }; +} + +export function zipDownloadTimeLeft(input: { + readonly receivedBytes: number; + readonly totalBytes: number; + readonly elapsedMs: number; +}) { + if (input.receivedBytes <= 0 || input.totalBytes <= input.receivedBytes || input.elapsedMs < 1000) + return null; + const seconds = Math.ceil( + ((input.totalBytes - input.receivedBytes) * input.elapsedMs) / input.receivedBytes / 1000, + ); + return seconds < 60 + ? `About ${seconds} ${seconds === 1 ? "second" : "seconds"} left` + : `About ${Math.ceil(seconds / 60)} minutes left`; +} diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index 7c6fa8c6850fd..1a54ed4ad46c3 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, type ComponentProps } from "react"; +import { useRef, useState, type ComponentProps } from "react"; import { useWindowDimensions, View, @@ -14,17 +14,40 @@ import { import { useWorkspaceState } from "../../state/workspace"; import { FabScrollContext } from "./FabScrollContext"; import { updateFabScroll } from "./fab-scroll"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; +import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; +import { WorkspaceTabsContext, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; +import { + useAppearancePreferences, + useAndroidFloatingTabs, +} from "../settings/appearance/AppearancePreferencesProvider"; export function AndroidHomeFabLayout(props: ComponentProps) { + const floatingTabsEnabled = useAndroidFloatingTabs(); const insets = useSafeAreaInsets(); const { appearance } = useAppearancePreferences(); const { fontScale } = useWindowDimensions(); const [layoutWidth, setLayoutWidth] = useState(null); const { state } = useWorkspaceState(); const [expanded, setExpanded] = useState(true); + const { scale, fabSize } = useAndroidControlSizing(); + const tabs = useEnabledNavigationTabs(); + const floating = floatingTabsMetrics( + tabs.length + 1, + scale, + fabSize, + props.paneWidth ?? layoutWidth ?? undefined, + ); + const workspace = useWorkspaceTabs(); + const bottomClearance = + insets.bottom + + FLOATING_TABS.bottom + + FLOATING_TABS.edge + + Math.max(floating.height, floating.fabOffset + fabSize); + const context = floatingTabsEnabled && workspace ? { ...workspace, bottomClearance } : workspace; const scrollState = useRef({ anchor: 0, expanded: true }); - const onScroll = useCallback((event: NativeSyntheticEvent) => { + const onScroll = (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const next = updateFabScroll( scrollState.current, @@ -33,22 +56,33 @@ export function AndroidHomeFabLayout(props: ComponentProps setLayoutWidth(event.nativeEvent.layout.width)}> - {props.children} - {!props.disabled && !props.sidebar && state.hasConnections && layoutWidth !== null ? ( + + {props.children} + + {!props.disabled && + (!props.sidebar || floatingTabsEnabled) && + state.hasConnections && + layoutWidth !== null ? ( ) : null}
diff --git a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx index 5226fce97c2f3..b3a5453f3449f 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx @@ -5,6 +5,7 @@ export function AndroidHomeFabLayout(props: { readonly children: ReactNode; readonly sidebar?: boolean; readonly disabled?: boolean; + readonly paneWidth?: number; }) { return <>{props.children}; } diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx index 8666329f87aba..1d984c2f0fd3c 100644 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ b/apps/mobile/src/features/home/HomeHeader.android.tsx @@ -1,5 +1,4 @@ import type { MenuAction } from "@react-native-menu/menu"; -import { useCallback, useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { ThreadListToolbar } from "./ThreadListToolbar"; import type { HomeHeaderProps } from "./HomeHeader.types"; @@ -13,81 +12,75 @@ function checkedMenuState(checked: boolean) { export function HomeHeader(props: HomeHeaderProps) { const hasCustomListOptions = props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; - const menuActions = useMemo( - () => [ - { - id: "environment", - title: "Environment", - subactions: [ + const menuActions: MenuAction[] = [ + { + id: "environment", + title: "Environment", + subactions: [ + { + id: "environment:all", + title: "All environments", + state: checkedMenuState(props.selectedEnvironmentId === null), + }, + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), + })), + ], + }, + ...(props.projects.length === 0 + ? [] + : ([ { - id: "environment:all", - title: "All environments", - state: checkedMenuState(props.selectedEnvironmentId === null), + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + state: checkedMenuState(props.selectedProjectKey === null), + }, + ...props.projects.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: checkedMenuState(props.selectedProjectKey === project.key), + })), + ], }, - ...props.environments.map((environment) => ({ - id: `environment:${environment.environmentId}`, - title: environment.label, - state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), - })), - ], - }, - ...(props.projects.length === 0 - ? [] - : ([ - { - id: "project", - title: "Project", - subactions: [ - { - id: "project:all", - title: "All projects", - state: checkedMenuState(props.selectedProjectKey === null), - }, - ...props.projects.map((project) => ({ - id: `project:${project.key}`, - title: project.label, - state: checkedMenuState(props.selectedProjectKey === project.key), - })), - ], - }, - ] satisfies MenuAction[])), - ], - [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey], - ); - const handleMenuAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - const id = event.nativeEvent.event; - if (id === "environment:all") { - props.onEnvironmentChange(null); - return; - } + ] satisfies MenuAction[])), + ]; + const handleMenuAction = (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "environment:all") { + props.onEnvironmentChange(null); + return; + } - if (id.startsWith("environment:")) { - const environmentId = id.slice("environment:".length); - const environment = props.environments.find( - (candidate) => candidate.environmentId === environmentId, - ); - if (environment) { - props.onEnvironmentChange(environment.environmentId); - } - return; + if (id.startsWith("environment:")) { + const environmentId = id.slice("environment:".length); + const environment = props.environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (environment) { + props.onEnvironmentChange(environment.environmentId); } + return; + } - if (id === "project:all") { - props.onProjectChange(null); - return; - } + if (id === "project:all") { + props.onProjectChange(null); + return; + } - if (id.startsWith("project:")) { - const projectKey = id.slice("project:".length); - if (props.projects.some((project) => project.key === projectKey)) { - props.onProjectChange(projectKey); - } - return; + if (id.startsWith("project:")) { + const projectKey = id.slice("project:".length); + if (props.projects.some((project) => project.key === projectKey)) { + props.onProjectChange(projectKey); } - }, - [props], - ); + return; + } + }; return ( <> diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 5756a140e779b..fe1401326ab56 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -6,7 +6,7 @@ import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -22,7 +22,6 @@ import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; -import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; @@ -68,37 +67,24 @@ export function HomeRouteScreen() { else if (workspace) workspace.dispatch({ type: "query", query, tab: "threads" }); else setLocalSearchQuery(query); }; - const handleSelectThread = useCallback( - (thread: EnvironmentThreadShell) => - selectDetail({ - name: "Thread", - params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, - }), - [selectDetail], - ); - const selectThreadRoute = useCallback( - (params: ReactNavigation.RootParamList["Thread"]) => selectDetail({ name: "Thread", params }), - [selectDetail], - ); - const handleNewThreadOnBranch = useCallback( - (thread: EnvironmentThreadShell) => { - selectThreadRoute({ - draft: { - requestId: uuidv4(), - environmentId: String(thread.environmentId), - projectId: String(thread.projectId), - branch: thread.branch, - worktreePath: thread.worktreePath, - }, - }); - }, - [selectThreadRoute], - ); - - useEffect(() => { - void checkForAppUpdateOnLaunch(); - startAppUpdateForegroundRecheck(); - }, []); + const handleSelectThread = (thread: EnvironmentThreadShell) => + selectDetail({ + name: "Thread", + params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, + }); + const selectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => + selectDetail({ name: "Thread", params }); + const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { + selectThreadRoute({ + draft: { + requestId: uuidv4(), + environmentId: String(thread.environmentId), + projectId: String(thread.projectId), + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }); + }; const { archiveThread, @@ -117,22 +103,19 @@ export function HomeRouteScreen() { const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(selectThreadRoute); - const environments = useMemo(() => { - const connectionStateByEnvironmentId = new Map( - workspaceEnvironments.map( - (environment) => [environment.environmentId, environment.connectionState] as const, - ), - ); - return Arr.sort( - Object.values(savedConnectionsById).map((connection) => ({ - environmentId: connection.environmentId, - label: connection.environmentLabel, - connectionState: - connectionStateByEnvironmentId.get(connection.environmentId) ?? "available", - })), - Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label), - ); - }, [savedConnectionsById, workspaceEnvironments]); + const connectionStateByEnvironmentId = new Map( + workspaceEnvironments.map( + (environment) => [environment.environmentId, environment.connectionState] as const, + ), + ); + const environments = Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + connectionState: connectionStateByEnvironmentId.get(connection.environmentId) ?? "available", + })), + Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label), + ); const availableEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], @@ -143,16 +126,16 @@ export function HomeRouteScreen() { setSelectedProjectKey, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; - const projectScopeKey = listOptions.selectedProjectKey; - const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { - sidebarProjectGroupingMode: listOptions.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }); - useEffect(() => { - if (resolvedProjectKey !== projectScopeKey) { - setSelectedProjectKey(resolvedProjectKey); - } - }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); + // A legacy per-environment scratch key resolves here on every render; the stored + // value is only rewritten when the user picks a filter. + const resolvedProjectKey = resolveScratchProjectScopeKey( + listOptions.selectedProjectKey, + projects, + { + sidebarProjectGroupingMode: listOptions.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }, + ); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e9a71f2c253fc..1edb92f0d556b 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,7 +12,6 @@ import { import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { useFocusEffect } from "@react-navigation/native"; import { use, useEffect, useRef, useState, type ComponentProps } from "react"; import { Platform, @@ -51,6 +50,7 @@ import { useThreadSearch } from "../../state/queries"; import { requestThreadMessageJump } from "../threads/threadMessageJump"; import { useThreadListSearchMatches } from "../threads/useThreadListSearchMatches"; import { useThreadListV2 } from "../threads/useThreadListV2"; +import { useThreadListNow } from "../threads/use-thread-list-now"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; @@ -316,6 +316,7 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const searchMode = workspace?.state.tabs.threads.searchMode; const restoredOffset = workspace?.state.tabs.threads.scrollOffset; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores the thread list's saved scroll offset through the list ref when search goes back to resting useEffect(() => { if (searchMode !== "resting" || restoredOffset === undefined) return; listRef.current?.scrollToOffset({ @@ -333,6 +334,7 @@ export function HomeScreen(props: HomeScreenProps) { }; // Status-bar, accessibility and programmatic scrolls bypass the gate; activate visible rows after settling. const activationTimerRef = useRef | undefined>(undefined); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the row activation timer on unmount useEffect(() => () => clearTimeout(activationTimerRef.current), []); const handleListScroll = (event: NativeSyntheticEvent) => { onFabScroll?.(event); @@ -423,21 +425,14 @@ export function HomeScreen(props: HomeScreenProps) { toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); - // Keep time in state; the compiler would cache a render-time new Date(). - const [now, setNow] = useState(() => new Date().toISOString()); - const nowMinute = now.slice(0, 16); - const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - useFocusEffect(() => { - setNow(new Date().toISOString()); - const id = setInterval(() => setNow(new Date().toISOString()), 60_000); - return () => clearInterval(id); - }); + const now = useThreadListNow(props.threads); const listEnvironments = useAtomValue(threadListEnvironmentsAtom); const { providersByEnvironmentId, machineByEnvironmentId, settlementEnvironmentIds, snoozeEnvironmentIds, + snoozePresetsByEnvironmentId, pinningEnvironmentIds, autoSettleOptOutEnvironmentIds, pinReorderEnvironmentIds, @@ -445,7 +440,7 @@ export function HomeScreen(props: HomeScreenProps) { titleRegenerationEnvironmentIds, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); - const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const pendingOrder = usePendingThreadOrder(); const { layout: threadListV2Layout, items: threadListV2Items, @@ -473,19 +468,6 @@ export function HomeScreen(props: HomeScreenProps) { settledShelfExpanded, selectedThreadKey, }); - const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; - useEffect(() => { - if (nextSnoozeWakeAt === null) return; - const wakeAtMs = Date.parse(nextSnoozeWakeAt); - if (Number.isNaN(wakeAtMs)) return; - const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); - const id = setTimeout(() => { - setNow(new Date().toISOString()); - bumpSnoozeWakeTick((tick) => tick + 1); - }, delayMs); - return () => clearTimeout(id); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. - }, [nextSnoozeWakeAt, snoozeWakeTick]); // Called as `props.onSelectThread(...)`, the compiler would cache this handler on the whole // props object, which is new on every render of Home's parent. const { onSelectThread } = props; @@ -502,6 +484,7 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread(thread); }; useThreadJumpShortcuts(threadListV2Items, selectThread); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads the list's visible range after commit to enable swipe gestures on those rows useEffect(() => { if (swipeEnabled) activateVisibleRows(threadListV2Items); }, [activateVisibleRows, swipeEnabled, threadListV2Items]); @@ -620,6 +603,7 @@ export function HomeScreen(props: HomeScreenProps) { settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} + snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} reorderSupported={ diff --git a/apps/mobile/src/features/home/ThreadListToolbar.tsx b/apps/mobile/src/features/home/ThreadListToolbar.tsx index 90decba1d5b1f..9e8c55fd2b5e5 100644 --- a/apps/mobile/src/features/home/ThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/ThreadListToolbar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; +import { useEffect, useRef, useState, type ComponentProps } from "react"; import { useNavigation, useNavigationState } from "@react-navigation/native"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { @@ -42,20 +42,21 @@ export function ThreadListToolbar(props: { const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; - const openSearch = useCallback(() => { + const openSearch = () => { onRequestVisibility?.(); setSearchOpen(true); searchRef.current?.focus(); return true; - }, [onRequestVisibility]); + }; useHardwareKeyboardCommand("focusSearch", openSearch); - const closeSearch = useCallback(() => { + const closeSearch = () => { onSearchQueryChange(""); setSearchOpen(false); Keyboard.dismiss(); - }, [onSearchQueryChange]); + }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to BackHandler while searching to close search on Android back useEffect(() => { if (!searching || !listFocused) return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { diff --git a/apps/mobile/src/features/home/ThreadUndoNotice.tsx b/apps/mobile/src/features/home/ThreadUndoNotice.tsx index ec7c630d29ed2..364873a3eadeb 100644 --- a/apps/mobile/src/features/home/ThreadUndoNotice.tsx +++ b/apps/mobile/src/features/home/ThreadUndoNotice.tsx @@ -1,3 +1,4 @@ +import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; import { useState } from "react"; import { useKeyboardState } from "react-native-keyboard-controller"; import { Platform, View } from "react-native"; @@ -18,6 +19,7 @@ import { } from "./thread-undo-notice"; export function ThreadUndoNotice(props: { readonly visible: boolean }) { + const floatingTabsEnabled = useAndroidFloatingTabs(); const notice = useThreadUndoNotice(); const insets = useSafeAreaInsets(); const columnMetrics = useNativeColumnLayoutMetrics(); @@ -64,7 +66,9 @@ export function ThreadUndoNotice(props: { readonly visible: boolean }) { columnMetrics?.safeArea.bottom ?? 0, (workspace?.bottomClearance ?? 0) - workspacePadding, ) + 8 - : insets.bottom + 12; + : floatingTabsEnabled && keyboardHeight === 0 + ? Math.max(insets.bottom + 12, workspace?.bottomClearance ?? 0) + : insets.bottom + 12; return ( ("waiting"); - - useEffect(() => { - if (!hasStatus) return; - const timers = [ - setTimeout(() => setPhase("fading"), STATUS_SHOW_DELAY_MS), - setTimeout(() => setPhase("shown"), STATUS_SHOW_DELAY_MS + FADE_IN_MS), - ]; - return () => { - for (const timer of timers) clearTimeout(timer); - setPhase("waiting"); - }; - }, [hasStatus]); + // When the current status run began; a gap in the status restarts the delay. + const [statusSince, setStatusSince] = useState(() => (hasStatus ? Date.now() : null)); + if (hasStatus !== (statusSince !== null)) { + // oxlint-disable-next-line react/purity -- Reads the clock only in the render-phase adjustment that runs when the status appears. + setStatusSince(hasStatus ? Date.now() : null); + } + const since = hasStatus ? statusSince : null; + const fadeStarted = useDeadlinePassed(since === null ? null : since + STATUS_SHOW_DELAY_MS); + const fadeEnded = useDeadlinePassed( + since === null ? null : since + STATUS_SHOW_DELAY_MS + FADE_IN_MS, + ); const label = presentation?.label; const showsProgress = presentation?.showsProgress ?? false; - const status = useMemo( - () => - label === undefined || phase === "waiting" - ? null - : { label, showsProgress, fadeIn: phase === "fading" }, - [label, showsProgress, phase], - ); + const status = + label === undefined || since === null || !fadeStarted + ? null + : { label, showsProgress, fadeIn: !fadeEnded }; return {props.children}; } @@ -72,7 +68,7 @@ function StatusFadeIn(props: { }) { const { animate } = props; const [opacity] = useState(() => new Animated.Value(0)); - + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- runs the Animated opacity fade-in and stops it on cleanup useEffect(() => { if (!animate) return; const animation = Animated.timing(opacity, { diff --git a/apps/mobile/src/features/home/home-list-options.test.tsx b/apps/mobile/src/features/home/home-list-options.test.tsx new file mode 100644 index 0000000000000..f25d4ff690851 --- /dev/null +++ b/apps/mobile/src/features/home/home-list-options.test.tsx @@ -0,0 +1,49 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ saved: [] as unknown[] })); + +vi.mock("../../state/preferences", () => ({ updateMobilePreferencesAtom: {} })); +vi.mock("@effect/atom-react", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomSet: () => (value: unknown) => harness.saved.push(value), +})); + +import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options"; + +const ENVIRONMENT = "env-1" as EnvironmentId; +const AVAILABLE = new Set([ENVIRONMENT]); + +it("saves the scope from every update made in one event", async () => { + harness.saved = []; + let controls: ReturnType | undefined; + function Probe() { + const result = useHomeListOptions(AVAILABLE); + useLayoutEffect(() => { + controls = result; + }); + return null; + } + + const root = createRoot(document.createElement("div")); + await act(async () => + root.render( + + + , + ), + ); + act(() => { + controls!.setSelectedEnvironmentId(ENVIRONMENT); + controls!.setSelectedProjectKey("env-1:project-1"); + }); + + expect(harness.saved.at(-1)).toEqual({ + threadListFilters: { environmentId: ENVIRONMENT, projectKey: "env-1:project-1" }, + }); + expect(controls!.options.selectedEnvironmentId).toBe(ENVIRONMENT); + expect(controls!.options.selectedProjectKey).toBe("env-1:project-1"); + act(() => root.unmount()); +}); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 9ee8cadf67602..09c8be2c9a7c1 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -1,17 +1,8 @@ import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts"; -import { useAtomSet } from "@effect/atom-react"; -import { - createContext, - createElement, - useContext, - useEffect, - useRef, - useState, - type PropsWithChildren, - type Dispatch, - type SetStateAction, -} from "react"; +import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; +import { createContext, createElement, useContext, useState, type PropsWithChildren } from "react"; import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { updateMobilePreferencesAtom } from "../../state/preferences"; @@ -40,7 +31,7 @@ function defaultHomeListOptions(filters?: ThreadListFilters): HomeListOptions { interface HomeListOptionsContextValue { readonly options: HomeListOptions; - readonly setOptions: Dispatch>; + readonly updateOptions: (patch: Partial) => void; readonly projectGroupingMode: SidebarProjectGroupingMode; } @@ -54,20 +45,29 @@ export function HomeListOptionsProvider({ readonly projectGroupingMode: SidebarProjectGroupingMode; readonly savedFilters?: ThreadListFilters | undefined; }>) { - const [options, setOptions] = useState(() => defaultHomeListOptions(savedFilters)); + const registry = useContext(RegistryContext); + const [optionsAtom] = useState(() => Atom.make(defaultHomeListOptions(savedFilters))); + const options = useAtomValue(optionsAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const saved = useRef(savedFilters); - const { selectedEnvironmentId: environmentId, selectedProjectKey: projectKey } = options; - useEffect(() => { + // Only the scope persists; the sort order is per session. Updates read the + // registry, so several in one event each build on the last and save it. + const updateOptions = (patch: Partial) => { + const current = registry.get(optionsAtom); + const next = { ...current, ...patch }; + registry.set(optionsAtom, next); if ( - (saved.current?.environmentId ?? null) === environmentId && - (saved.current?.projectKey ?? null) === projectKey - ) - return; - saved.current = { environmentId, projectKey }; - savePreferences({ threadListFilters: saved.current }); - }, [environmentId, projectKey, savePreferences]); - const value = { options, setOptions, projectGroupingMode }; + next.selectedEnvironmentId !== current.selectedEnvironmentId || + next.selectedProjectKey !== current.selectedProjectKey + ) { + savePreferences({ + threadListFilters: { + environmentId: next.selectedEnvironmentId, + projectKey: next.selectedProjectKey, + }, + }); + } + }; + const value = { options, updateOptions, projectGroupingMode }; return createElement(HomeListOptionsContext, { value }, children); } @@ -75,7 +75,9 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet(defaultHomeListOptions); const options = shared?.options ?? localOptions; - const setOptions = shared?.setOptions ?? setLocalOptions; + const updateOptions = + shared?.updateOptions ?? + ((patch: Partial) => setLocalOptions((current) => ({ ...current, ...patch }))); const selectedEnvironmentId = options.selectedEnvironmentId !== null && availableEnvironmentIds.has(options.selectedEnvironmentId) @@ -91,13 +93,13 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { - setOptions((current) => ({ ...current, selectedEnvironmentId: value })); + updateOptions({ selectedEnvironmentId: value }); }; const setSelectedProjectKey = (value: string | null) => { - setOptions((current) => ({ ...current, selectedProjectKey: value })); + updateOptions({ selectedProjectKey: value }); }; const setProjectSortOrder = (value: HomeProjectSortOrder) => { - setOptions((current) => ({ ...current, projectSortOrder: value })); + updateOptions({ projectSortOrder: value }); }; return { options: resolvedOptions, diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts index 98f53a6c3aec0..5413ea1b1b0d4 100644 --- a/apps/mobile/src/features/home/home-route-visibility.ts +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -1,41 +1,59 @@ import { RegistryContext } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; -import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useContext, useRef, useSyncExternalStore } from "react"; import type { Atom } from "effect/reactivity"; -/** Fabric never freezes the route below focus; Home must stop its own work beneath Thread. */ +/** + * Home stays mounted under a pushed Thread, and native-stack never freezes the + * route directly below the focused one on Fabric, so Home has to stop its own + * work. The Home screen's static `listeners` (Stack.tsx) drive this store. + */ +let homeVisible: boolean | null = null; +const visibilityListeners = new Set<() => void>(); + +function setHomeVisible(next: boolean) { + if (next === homeVisible) return; + homeVisible = next; + for (const listener of visibilityListeners) listener(); +} + +function subscribeHomeVisibility(onChange: () => void) { + visibilityListeners.add(onChange); + return () => { + visibilityListeners.delete(onChange); + // The value describes one Home instance. A later Home can mount under a + // Thread with no event of its own (Android activity replacement, a cold + // deep link), so it must fall back to `isFocused()` instead of this one. + if (visibilityListeners.size === 0) homeVisible = null; + }; +} + +export function recordHomeTransitionStart(closing: boolean): void { + if (!closing) setHomeVisible(true); +} + +export function recordHomeTransitionEnd(closing: boolean): void { + if (closing) setHomeVisible(false); +} + +/** Popping a sheet or a deep-linked route above Home has no appear event. */ +export function recordHomeFocus(): void { + setHomeVisible(true); +} + export function useHomeRouteVisible(): boolean { - const navigation = - useNavigation>(); - // A cold deep link mounts Home under its Thread without ever showing it. - const [visible, setVisible] = useState(() => navigation.isFocused()); - useEffect(() => { - const show = () => setVisible(true); - const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { - if (!data.closing) show(); - }); - const removeTransitionEnd = navigation.addListener("transitionEnd", ({ data }) => { - if (data.closing) setVisible(false); - }); - // Popping a sheet or a deep-linked route above Home has no appear event. - const removeFocus = navigation.addListener("focus", show); - return () => { - removeTransitionStart(); - removeTransitionEnd(); - removeFocus(); - }; - }, [navigation]); - return visible; + const navigation = useNavigation(); + const visible = useSyncExternalStore(subscribeHomeVisibility, () => homeVisible); + // Before Home's first navigation event: a cold deep link mounts Home under + // its Thread without ever showing it. + return visible ?? navigation.isFocused(); } export function useAtomValueWhileVisible(atom: Atom.Atom, visible: boolean): A { const registry = useContext(RegistryContext); const held = useRef<{ readonly value: A } | null>(null); - const subscribe = useCallback( - (onChange: () => void) => (visible ? registry.subscribe(atom, onChange) : () => undefined), - [registry, atom, visible], - ); + const subscribe = (onChange: () => void) => + visible ? registry.subscribe(atom, onChange) : () => undefined; return useSyncExternalStore(subscribe, () => { if (visible || held.current === null) held.current = { value: registry.get(atom) }; return held.current.value; diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 0ad078f73b588..a6c48086f2c85 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -50,8 +50,8 @@ const ACTION_ICON_SIZE = 15; const COMPACT_ACTION_CIRCLE_SIZE = 28; const COMPACT_ACTION_ICON_SIZE = 13; -export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; -export const THREAD_SWIPE_SPRING = { +const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; +const THREAD_SWIPE_SPRING = { damping: 26, mass: 0.7, overshootClamping: true, @@ -164,6 +164,7 @@ export function useSwipeableScrollGate(options?: { settleTimerRef.current = null; } }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the scroll settle timer on unmount useEffect(() => clearSettle, [clearSettle]); const onScrollBeginDrag = useCallback( @@ -289,6 +290,7 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { pending.resolve(); }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the row's Reanimated animations and settles a pending dismissal on unmount useLayoutEffect(() => { mountedRef.current = true; return () => { @@ -302,6 +304,7 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]); // Reset recycled rows in place so their child views stay mounted. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resets the swipeable and its shared values when the list recycles this row for new content useLayoutEffect(() => { if (contentRef.current === content) return; contentRef.current = content; @@ -309,6 +312,7 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { restoreRow(); }, [content, finishDismiss, restoreRow]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes the open swipeable and resets its shared values when the row goes dormant useLayoutEffect(() => { if (props.dormant && activeTranslationRef.current && dismissalRef.current === null) { restoreRow(); @@ -339,6 +343,7 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { }, [actionOpacity, collapse, dismissing, finishDismiss, rowWidth], ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers this row's animated dismissal in the module-level thread dismissal registry useLayoutEffect( () => props.dormant @@ -711,7 +716,7 @@ function SwipeActionButton(props: { ); } -export function ThreadSwipeActions(props: { +function ThreadSwipeActions(props: { readonly backgroundColor: ColorValue; readonly compact: boolean; readonly fullSwipeAction?: "delete" | "primary"; diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 9a59367a9543a..ec0f6a14558dd 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -114,7 +114,10 @@ vi.mock("../../state/thread-order", () => ({ }, })); vi.mock("../../state/threads", () => ({ - environmentThreadShells: { threadShellsAtom: "thread-shells" }, + environmentThreadShells: { + threadShellsAtom: "thread-shells", + navigationThreadShellsAtom: "thread-shells", + }, threadEnvironment: Object.fromEntries( [ "archive", diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index ddbbf9ff343c5..cac37f0552802 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -2,7 +2,7 @@ import type { ThreadMoveDestination } from "../threads/threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo"; import { showThreadUndoNotice } from "./thread-undo-notice"; -import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; @@ -33,33 +33,8 @@ import { getThreadListV2OrderedSection } from "../threads/threadListV2"; import { threadCanArchive } from "./threadArchive"; import { resolveThreadTitleRename } from "../threads/thread-title-rename"; -/** Older servers default the settlement capability to false; never send them settle/unsettle. */ -function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadSettlement === true - ); -} - -function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadSnooze === true - ); -} - -function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadPinning === true - ); -} - -function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadPinReorder === true - ); +function environmentConfigLoaded(environmentId: EnvironmentThreadShell["environmentId"]) { + return appAtomRegistry.get(environmentServerConfigsAtom).has(environmentId); } function environmentSupportsAutoSettleOptOut( @@ -71,15 +46,6 @@ function environmentSupportsAutoSettleOptOut( ); } -function environmentSupportsTitleRegeneration( - environmentId: EnvironmentThreadShell["environmentId"], -) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadTitleRegeneration === true - ); -} - type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -142,7 +108,7 @@ function useThreadActionExecutor( const succeeded = await (async () => { if ( (action === "settle" || action === "unsettle") && - !environmentSupportsSettlement(thread.environmentId) + !environmentConfigLoaded(thread.environmentId) ) { Dialog.alert( actionFailureTitle(action), @@ -331,7 +297,7 @@ export function useThreadListActions(): { } snoozeInFlightThreadKeys.current.add(key); const succeeded = await (async () => { - if (!environmentSupportsSnooze(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not snooze thread", "This environment's server does not support snoozing yet. Update the server to use Snooze.", @@ -389,7 +355,7 @@ export function useThreadListActions(): { } snoozeInFlightThreadKeys.current.add(key); const succeeded = await (async () => { - if (!environmentSupportsSnooze(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not wake thread", "This environment's server does not support snoozing yet. Update the server to wake this thread.", @@ -427,7 +393,7 @@ export function useThreadListActions(): { (await executeAction("unsettle", thread)) === true; const pinThread = async (thread: EnvironmentThreadShell, restoreOrderKey?: string) => { if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false; - if (!environmentSupportsPinning(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not pin thread", "This environment's server does not support pinning yet. Update the server to use Pin.", @@ -435,19 +401,13 @@ export function useThreadListActions(): { return false; } selectionHaptic(); - let orderKey: string | undefined; - if (environmentSupportsPinReorder(thread.environmentId)) { - const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); - let firstKey: string | null = null; - for (const shell of shells) { - if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; - if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; - } - orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined; + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + let firstKey: string | null = null; + for (const shell of shells) { + if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; + if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; } - orderKey = environmentSupportsPinReorder(thread.environmentId) - ? (restoreOrderKey ?? orderKey) - : undefined; + const orderKey = restoreOrderKey ?? pinOrderKeyBetween(null, firstKey) ?? undefined; ThreadUndo.invalidate("pin", scopedThreadKey(thread.environmentId, thread.id)); // Pinning clears snooze, so older Settled or Snoozed undos would fight this pin. ThreadUndo.invalidate("settle", scopedThreadKey(thread.environmentId, thread.id)); @@ -475,7 +435,7 @@ export function useThreadListActions(): { }; const unpinThread = async (thread: EnvironmentThreadShell) => { if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false; - if (!environmentSupportsPinning(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not unpin thread", "This environment's server does not support pinning yet. Update the server to use Pin.", @@ -539,7 +499,7 @@ export function useThreadListActions(): { if (thread.titleRegeneration != null || titleRegenerationInFlightThreadKeys.current.has(key)) { return false; } - if (!environmentSupportsTitleRegeneration(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not regenerate title", "This environment's server does not support title regeneration yet. Update the server to regenerate thread titles.", @@ -611,7 +571,7 @@ export function useThreadListActions(): { const moveThread = async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; - const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const shells = appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom); const current = shells.find( (row) => row.id === thread.id && row.environmentId === thread.environmentId, ); @@ -624,20 +584,14 @@ export function useThreadListActions(): { ? "pinned" : "active"; if (section === "settled") { - if (!environmentSupportsSettlement(thread.environmentId)) return false; + if (!environmentConfigLoaded(thread.environmentId)) return false; appAtomRegistry.set(threadDropBusyAtom, true); const settled = await settleThread(thread); appAtomRegistry.set(threadDropBusyAtom, false); return settled; } const configs = appAtomRegistry.get(environmentServerConfigsAtom); - const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { - const capabilities = configs.get(environmentId)?.environment.capabilities; - return section === "pinned" - ? capabilities?.threadPinReorder === true - : capabilities?.threadActiveReorder === true; - }; - if (!supportsReorder(thread.environmentId)) { + if (!configs.has(thread.environmentId)) { Dialog.alert( "Could not move thread", "This environment's server does not support reordering these threads. Update the server to arrange them.", @@ -649,38 +603,20 @@ export function useThreadListActions(): { section, now: new Date().toISOString(), queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), - settlementEnvironmentIds: new Set( - [...configs].flatMap(([id, config]) => - config.environment.capabilities.threadSettlement === true ? [id] : [], - ), - ), - snoozeEnvironmentIds: new Set( - [...configs].flatMap(([id, config]) => - config.environment.capabilities.threadSnooze === true ? [id] : [], - ), - ), + settlementEnvironmentIds: new Set(configs.keys()), + snoozeEnvironmentIds: new Set(configs.keys()), }); const assignments = createThreadMovePlanner({ allThreads: shells, ordered, section, - reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), + reorderableEnvironmentIds: new Set(configs.keys()), })(scopedThreadKey(thread.environmentId, thread.id), direction); if (assignments === null) return false; const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); const crossSection = !ordered.some( (row) => row.id === thread.id && row.environmentId === thread.environmentId, ); - if ( - crossSection && - (((section === "pinned" || thread.pinnedAt != null) && - !environmentSupportsPinning(thread.environmentId)) || - (thread.settledOverride === "settled" && - !environmentSupportsSettlement(thread.environmentId)) || - (effectiveSnoozed(thread, { now: new Date().toISOString() }) && - !environmentSupportsSnooze(thread.environmentId))) - ) - return false; const shellByKey = new Map( shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), ); diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 7e7fea4527b8d..88987819ddf00 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { uuidv4 } from "../../lib/uuid"; import { useNavigation } from "@react-navigation/native"; import { @@ -16,7 +17,7 @@ import { pickedQuery, threadRecentUse, } from "@t3tools/shared/paletteSearch"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { FlatList, KeyboardAvoidingView, @@ -41,15 +42,25 @@ import { deriveBottomControlInsets } from "../../lib/reserved-regions"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; -import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; +import { + useProjects, + useServerConfigs, + useThreadShell, + useThreadShells, +} from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { requestThreadMessageJump } from "../threads/threadMessageJump"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; +import { serverEnvironment } from "../../state/server"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match"; +import { pickTriageEnvironment } from "../triage/triageTarget"; +import { useStartTriage } from "../triage/useStartTriage"; +import { useNewTaskFlow } from "../threads/new-task-flow-provider"; +import { useActiveNewTask } from "../../state/active-new-task"; import { filterCommandPaletteItems, nextPaletteIndex, @@ -216,7 +227,7 @@ export function CommandPalette(props: { const runCommand = props.onCommand; const projects = useProjects(); const threads = useThreadShells(); - const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]); + const activeThreadRef = parseActiveThreadPath(props.pathname); const activeThread = useThreadShell(activeThreadRef); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const environments = useWorkspaceEnvironments(); @@ -256,14 +267,26 @@ export function CommandPalette(props: { query.startsWith(">") ? "" : query, environmentLabels === undefined ? {} : { environmentLabels }, ); - const contentMatchByKey = useMemo( - () => - new Map( - search.matches - .filter((match) => match.source === "user" || match.source === "assistant") - .map((match) => [scopedThreadKey(match.environmentId, match.threadId), match]), - ), - [search.matches], + const serverConfigs = useServerConfigs(); + const startTriage = useStartTriage(); + const newTaskFlow = useNewTaskFlow(); + const draftEnvironmentId = + useActiveNewTask() === null ? null : (newTaskFlow.selectedProject?.environmentId ?? null); + const triageEnvironmentId = pickTriageEnvironment({ + // The route names the machine even before the thread shell loads. + onScreen: activeThreadRef?.environmentId ?? draftEnvironmentId, + connected: searchEnvironmentIds, + supportsTriage: (environmentId) => + serverConfigs.get(environmentId)?.environment.capabilities.triage === true, + }); + const canTriage = + useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) && + triageEnvironmentId !== null && + serverConfigs.get(triageEnvironmentId)?.environment.capabilities.triage === true; + const contentMatchByKey = new Map( + search.matches + .filter((match) => match.source === "user" || match.source === "assistant") + .map((match) => [scopedThreadKey(match.environmentId, match.threadId), match]), ); const items = useMemo(() => { const actions: CommandPaletteItem[] = [ @@ -324,6 +347,17 @@ export function CommandPalette(props: { run: () => openSettings(navigation, "SettingsArchive"), }, ]; + if (canTriage) { + actions.push({ + key: "triage", + kind: "action", + title: "Triage an issue", + // Mobile has no triage hero, so name the machine before it starts. + detail: `On ${savedConnectionsById[triageEnvironmentId]?.environmentLabel ?? "this machine"}`, + searchTerms: ["triage", "bug", "issue", "report", "broken", "debug", "help", "support"], + run: () => void startTriage(triageEnvironmentId), + }); + } const projectByKey = new Map( projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]), ); @@ -355,7 +389,7 @@ export function CommandPalette(props: { ["files", "Go to file", ["open", "files", "browse", "search"]], ["terminal", "Open terminal", ["shell", "console"]], ["review", "Review changes", ["diff", "git", "pull request"]], - ["copyThreadReference", "Copy PR link or thread ID", ["reference", "clipboard"]], + ["copyThreadReference", "Copy PR link or thread", ["reference", "clipboard"]], ] as const; actions.push( ...threadActions.map(([command, title, searchTerms]) => ({ @@ -430,12 +464,15 @@ export function CommandPalette(props: { activeThread, activeThreadRef, environments.length, + canTriage, navigation, projects, runCommand, savedConnectionsById, selectDetail, + startTriage, threads, + triageEnvironmentId, ]); const rankedMessageKeys = useMemo(() => { const subjectByKey = new Map( @@ -507,6 +544,7 @@ export function CommandPalette(props: { // The native list follows the highlighted row. Key presses move it, but so do // live thread updates and late message matches that reorder or drop the // selected row under a kept selection, so no single handler owns the scroll. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the native list to the highlighted row, which key presses and live result updates both move useEffect(() => { if (selectedIndex === 0) { // Centering before the list measures its height scrolls half the first row out of view. @@ -518,7 +556,7 @@ export function CommandPalette(props: { const dismissed = useRef(false); const dismissFallback = useRef | null>(null); - const handleDismissed = useCallback(() => { + const handleDismissed = () => { if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); dismissFallback.current = null; if (dismissed.current) return; @@ -526,7 +564,8 @@ export function CommandPalette(props: { // Present navigation sheets only after UIKit has dismissed this modal. props.onClose(); pendingAction.current?.(); - }, [props]); + }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the dismiss fallback timer on unmount useEffect( () => () => { if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 7b62a6af94cae..135715f064924 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -14,7 +14,7 @@ import { type ReactNode, } from "react"; -import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { copyThreadReference } from "../../lib/copyThreadReference"; import { firstWorkspaceDetailOpen, workspaceHasColumns, @@ -62,7 +62,8 @@ export function HardwareKeyboardCommandProvider({ activeThreadRef === null ? null : resolveThreadReferenceCopyTarget({ - threadId: activeThread?.id ?? activeThreadRef.threadId, + threadRef: activeThreadRef, + title: activeThread?.title ?? "", pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, @@ -89,6 +90,7 @@ export function HardwareKeyboardCommandProvider({ setCopyFeedback(EMPTY_COPY_FEEDBACK); }, COPY_FEEDBACK_DISMISS_MS); }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the copy feedback timer on unmount useEffect( () => () => { if (copyFeedbackTimerRef.current !== null) { @@ -133,16 +135,14 @@ export function HardwareKeyboardCommandProvider({ if (command === "copyThreadReference") { if (copyTarget === null) return; const requestId = ++copyRequestIdRef.current; - void tryCopyTextWithHaptic(copyTarget.value, { - target: copyTarget.clipboardTarget, - }).then((didCopy) => { + void copyThreadReference(copyTarget).then((didCopy) => { if (requestId !== copyRequestIdRef.current) return; showCopyFeedback( didCopy ? { phase: "success", label: copyTarget.successTitle, - description: copyTarget.value, + description: copyTarget.description, } : { phase: "error", diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index cabe2949788ed..3525e17828099 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -27,6 +27,7 @@ export function useHardwareKeyboardCommand( command: HardwareKeyboardCommand | ReadonlyArray, handler: CommandHandler, ): void { + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers the handler in the module-level hardware keyboard command registry useEffect(() => { const commands = typeof command === "string" ? [command] : command; for (const command of commands) { diff --git a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts index 3143d164c1781..8d6f131704423 100644 --- a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts +++ b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts @@ -1,6 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; -import { useCallback } from "react"; import type { ThreadListV2ListItem } from "../threads/threadListV2"; import { @@ -31,13 +30,10 @@ export function useThreadJumpShortcuts( items: ReadonlyArray, onSelectThread: (thread: EnvironmentThreadShell) => void, ) { - const jumpToThread = useCallback( - (command: HardwareKeyboardCommand) => { - const thread = threadJumpTarget(items, command); - if (thread !== null) onSelectThread(thread); - return true; - }, - [items, onSelectThread], - ); + const jumpToThread = (command: HardwareKeyboardCommand) => { + const thread = threadJumpTarget(items, command); + if (thread !== null) onSelectThread(thread); + return true; + }; useHardwareKeyboardCommand(THREAD_JUMP_KEYBINDING_COMMANDS, jumpToThread); } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index e2d758ee9606e..c3b46cf8e24c2 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -1,6 +1,11 @@ +import { + WorkspaceColumnsRendererContext, + WorkspacePresentationHostContext, +} from "./workspace-presentation-context"; +import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { AdaptiveWorkspaceContext, useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; -import { createDeferredNavigation } from "./deferred-navigation"; +import { threadSelectionDeferral } from "./deferred-navigation"; import { uuidv4 } from "../../lib/uuid"; import type { EnvironmentProject, @@ -10,16 +15,7 @@ import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3too import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; -import { - use, - useDeferredValue, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; +import { use, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { @@ -45,6 +41,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; import { useProjects } from "../../state/entities"; +import { useThreadCwd } from "../../state/use-selected-thread-worktree"; import { environmentThreadShells } from "../../state/threads"; import { mobilePreferencesAtom } from "../../state/preferences"; import { @@ -55,9 +52,13 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; +import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; +import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { AndroidWorkspaceNavigation, + FloatingWorkspaceTabs, ANDROID_WORKSPACE_RAIL_WIDTH, } from "../workspace/AndroidWorkspaceNavigation"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; @@ -95,9 +96,13 @@ import { currentWorkspaceSelections, workspaceSelectionCheck, } from "../workspace/workspace-selection-store"; -import { workspaceTabNavigationState } from "../workspace/workspace-tab-navigation"; +import { + workspaceTabColumns, + workspaceTabNavigationState, +} from "../workspace/workspace-tab-navigation"; import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; +import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; export { useAdaptiveWorkspaceLayout }; @@ -124,39 +129,11 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi ); })(); - const focusedRef = useRef(false); - const deactivateRef = useRef<(() => void) | null>(null); - - const syncRegistration = useEffectEvent(() => { - if (!focusedRef.current || wrappedRender === undefined) { - deactivateRef.current?.(); - return; - } - deactivateRef.current = registerWorkspaceInspector(wrappedRender); - }); - - useFocusEffect(() => { - focusedRef.current = true; - syncRegistration(); - return () => { - focusedRef.current = false; - syncRegistration(); - }; - }); - - useEffect(() => { - if (focusedRef.current) { - syncRegistration(); - } - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. - }, [wrappedRender]); - - useEffect( - () => () => { - deactivateRef.current?.(); - deactivateRef.current = null; - }, - [], + // useFocusEffect re-runs when the renderer changes while focused, so the + // pane always shows this render's content, and its cleanup deactivates on + // blur and unmount. + useFocusEffect(() => + wrappedRender === undefined ? undefined : registerWorkspaceInspector(wrappedRender), ); } @@ -164,6 +141,8 @@ export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; readonly workspaceRouteKey: string | undefined; + readonly workspaceRouteName: string | undefined; + readonly workspaceRouteKeys: ReadonlyArray; readonly selectedPullRequestKey?: string | null; }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); @@ -190,14 +169,18 @@ function AdaptiveWorkspaceLayoutContent( readonly children: ReactNode; readonly pathname: string; readonly workspaceRouteKey: string | undefined; + readonly workspaceRouteName: string | undefined; + readonly workspaceRouteKeys: ReadonlyArray; readonly selectedPullRequestKey?: string | null; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; readonly savedThreadListFilters?: ThreadListFilters | undefined; }, ) { + const floatingTabsEnabled = useAndroidFloatingTabs(); const projectGroupingMode = props.projectGroupingMode; const nativeWorkspace = use(NativeWorkspaceModeContext); + const usesPresentationHost = use(WorkspacePresentationHostContext); const windowDimensions = useWindowDimensions(); const nativeMetrics = useNativeLayoutMetrics(); const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; @@ -272,7 +255,11 @@ function AdaptiveWorkspaceLayoutContent( const showPrimarySidebar = !workspacePage && (primarySidebarPinned || (preferredSidebarVisible && !composerKeyboardActive)); - const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView; + const shouldRenderNavigationRail = + Platform.OS === "android" && baseLayout.usesSplitView && !floatingTabsEnabled; + const enabledTabs = useEnabledNavigationTabs(); + const { scale, fabSize } = useAndroidControlSizing(); + const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize); const navigationRailWidth = shouldRenderNavigationRail && (showPrimarySidebar || workspacePage) ? ANDROID_WORKSPACE_RAIL_WIDTH @@ -283,16 +270,31 @@ function AdaptiveWorkspaceLayoutContent( setHinge(next); setWindowHinge(nextWindow); }; - const layout = useMemo( - () => - alignListPaneToHinge({ - layout: baseLayout, - hinge, - viewportWidth, - leadingWidth: navigationRailWidth, - }), - [baseLayout, hinge, navigationRailWidth, viewportWidth], - ); + const layout = useMemo(() => { + const aligned = alignListPaneToHinge({ + layout: baseLayout, + hinge, + viewportWidth, + leadingWidth: navigationRailWidth, + }); + if ( + !floatingTabsEnabled || + !aligned.usesSplitView || + aligned.listPaneGap !== undefined || + aligned.listPaneWidth === null || + aligned.listPaneWidth >= floatingTabs.row || + viewportWidth - floatingTabs.row < 320 + ) + return aligned; + return { ...aligned, listPaneWidth: floatingTabs.row }; + }, [ + baseLayout, + hinge, + navigationRailWidth, + viewportWidth, + floatingTabs.row, + floatingTabsEnabled, + ]); const width = viewportWidth - navigationRailWidth; // The sidebar stays mounted under a workspace page, so its list is ready on the way back. const shouldRenderPrimarySidebar = layout.usesSplitView; @@ -344,16 +346,52 @@ function AdaptiveWorkspaceLayoutContent( const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; - const selectedThreadKey = (() => { + const selectedThreadRef = (() => { if (environmentId === null || threadId === null) { return null; } try { - return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)); + return { + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), + }; } catch { return null; } })(); + const selectedThreadKey = + selectedThreadRef === null + ? null + : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId); + const isFilesRoute = props.workspaceRouteName === "ThreadFiles"; + // Same resolution the Files screen uses: without a cwd it shows "Files unavailable" and + // registers no inspector, so revealing would only hide the sidebar beside that screen. + const filesCwd = useThreadCwd(isFilesRoute && fileInspector.supported ? selectedThreadRef : null); + // Opening a review or the files route reveals its inspector once per route and thread. + // Routes still on the stack remember their reveal, so going back to one keeps the user's choice. + const revealsInspector = + props.workspaceRouteName === "ThreadReview" || (isFilesRoute && filesCwd !== null); + const [revealedInspectors, setRevealedInspectors] = useState>( + () => new Map(), + ); + if ( + revealsInspector && + props.workspaceRouteKey !== undefined && + selectedThreadKey !== null && + revealedInspectors.get(props.workspaceRouteKey) !== selectedThreadKey + ) { + const next = new Map(); + for (const routeKey of props.workspaceRouteKeys) { + const revealedThreadKey = revealedInspectors.get(routeKey); + if (revealedThreadKey !== undefined) { + next.set(routeKey, revealedThreadKey); + } + } + next.set(props.workspaceRouteKey, selectedThreadKey); + setRevealedInspectors(next); + setFocusedAuxiliaryPaneRole("inspector"); + setFileInspectorPreferredVisible(true); + } const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; readonly active: boolean; @@ -478,7 +516,7 @@ function AdaptiveWorkspaceLayoutContent( tab, currentWorkspaceSelections(), { - columns: Platform.OS === "ios" && (options?.columns ?? layout.usesSplitView), + columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), check: workspaceSelectionCheck, overlays, }, @@ -491,10 +529,9 @@ function AdaptiveWorkspaceLayoutContent( }; const handleOpenSettings = () => openWorkspaceTab("settings"); - const deferredThreadSelection = useMemo(() => createDeferredNavigation(), []); /** The one way to show a page in the detail column; see `selectWorkspaceDetail`. */ function handleSelectDetail(detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) { - deferredThreadSelection.run(() => { + threadSelectionDeferral.run(() => { if (Platform.OS === "android") Keyboard.dismiss(); setFileInspectorPreferredVisible(false); if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false); @@ -524,24 +561,27 @@ function AdaptiveWorkspaceLayoutContent( : undefined; const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; - const thread = appAtomRegistry - .get(environmentThreadShells.navigationThreadShellsAtom) - .find( - (candidate) => - candidate.environmentId === first(params?.environmentId) && - candidate.id === first(params?.threadId), - ); - const draft = params?.draft; - const draftEnvironmentId = first(draft?.environmentId); - const draftProjectId = first(draft?.projectId); - const currentProject = thread - ? { environmentId: thread.environmentId, id: thread.projectId } - : projects.find( - (project) => - project.environmentId === draftEnvironmentId && project.id === draftProjectId, - ); + const environmentId = first(params?.environmentId); + const threadId = first(params?.threadId); + // The full index, not the sidebar list: subagent and archived threads are current threads too. + const projectId = + environmentId && threadId + ? appAtomRegistry + .get( + environmentThreadShells.environmentThreadIndexAtom(EnvironmentId.make(environmentId)), + ) + .get(ThreadId.make(threadId))?.projectId + : undefined; + const preferences = appAtomRegistry.get(mobilePreferencesAtom); + const saved = AsyncResult.isSuccess(preferences) ? preferences.value : undefined; handleSelectThreadRoute( - newTaskDestination(layout.usesSplitView, projects, currentProject, uuidv4()), + newTaskDestination({ + projects, + requestId: uuidv4(), + useLastProject: saved?.useLastProjectForNewThreads, + currentProject: environmentId && projectId ? { environmentId, projectId } : undefined, + lastProject: saved?.lastThreadProject, + }), ); }; @@ -586,8 +626,10 @@ function AdaptiveWorkspaceLayoutContent( workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible ? (panes.auxiliaryPaneWidth ?? 0) : 0; + // Android's split content is a rounded card: keep it off the screen edge like the list card. + const contentEndGap = Platform.OS === "android" && layout.usesSplitView ? 4 : 0; const contentTargetWidth = layout.usesSplitView - ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) + ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth - contentEndGap) : null; const [listPaneSizing, setListPaneSizing] = useState({ viewportWidth, @@ -679,6 +721,7 @@ function AdaptiveWorkspaceLayoutContent( ); const renderedRailWidth = useSharedValue(navigationRailWidth); const leavingWorkspacePage = useRef(false); + // 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; renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); @@ -723,7 +766,10 @@ function AdaptiveWorkspaceLayoutContent( })); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // Fading a UIKit glass ancestor can leave remounted header effects missing. - opacity: Platform.OS === "ios" ? 1 : Math.min(1, renderedSidebarWidth.value / 80), + opacity: + Platform.OS === "ios" || floatingTabsEnabled + ? 1 + : Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, })); @@ -770,7 +816,6 @@ function AdaptiveWorkspaceLayoutContent( fileInspector, primarySidebarSearchQuery, startNewTask: handleStartNewTask, - deferThreadSelectionUntil: deferredThreadSelection.waitFor, selectDetail: handleSelectDetail, activateAuxiliaryPaneRole, registerWorkspaceInspector, @@ -802,145 +847,186 @@ function AdaptiveWorkspaceLayoutContent( ); } - return ( - ( + - - - - - {shouldRenderNavigationRail ? ( - - ) : null} - {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( - + {shouldRenderNavigationRail ? ( + + ) : null} + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( + + + ( + + )} + > + - - ( - - )} - > - - - - - - - - + + + + + + {/* Keep the pill under the content throughout the sidebar's clipping animation. */} + {floatingTabsEnabled && !workspacePage ? ( + ) : null} - + + ) : null} + + + {/* Android holds the content still, so its floating controls keep its width. */} + + - - {/* Android holds the content still, so its floating controls keep its width. */} - - - {/* Compact Settings keeps the bottom navigation under all of its pages. */} - - {props.children} - - {compactAndroidSettings ? ( - - ) : null} - - - - - - {shouldRenderNavigationRail && navigationRailWidth > 0 ? ( - // The rail reserves its slot above and draws over the panes, so it can widen over - // the list in place. It takes the whole workspace only while it is open. - - + {children} + + + {floatingTabsEnabled && + (compactAndroidSettings || (layout.usesSplitView && workspacePage)) ? ( + - - ) : null} - + ) : compactAndroidSettings ? ( + + ) : null} + + + + + + {shouldRenderNavigationRail && navigationRailWidth > 0 ? ( + // The rail reserves its slot above and draws over the panes, so it can widen over + // the list in place. It takes the whole workspace only while it is open. + + + + ) : null} +
+ ); + + return ( + + + + {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 7cfeb3e76fbf4..a22459e6ecdb4 100644 --- a/apps/mobile/src/features/layout/adaptive-workspace-context.ts +++ b/apps/mobile/src/features/layout/adaptive-workspace-context.ts @@ -20,7 +20,6 @@ interface AdaptiveWorkspaceContextValue { readonly fileInspector: FileInspectorPaneLayout; readonly primarySidebarSearchQuery: string; readonly startNewTask: () => void; - readonly deferThreadSelectionUntil: (operation: Promise) => void; /** Use `useSelectWorkspaceDetail`, which also works outside the layout. */ readonly selectDetail?: (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => void; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; @@ -67,7 +66,6 @@ export const AdaptiveWorkspaceContext = createContext undefined, - deferThreadSelectionUntil: () => undefined, activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx new file mode 100644 index 0000000000000..765b23243798b --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx @@ -0,0 +1,152 @@ +import { + createNavigatorFactory, + StackRouter, + useNavigationBuilder, + type NavigatorTypeBagBase, + type ParamListBase, + type StackActionHelpers, + type StackNavigationState, + type StackRouterOptions, + type StaticConfig, + type TypedNavigator, +} from "@react-navigation/native"; +import { + NativeStackView, + type NativeStackNavigationEventMap, + type NativeStackNavigationOptions, + type NativeStackNavigatorProps, + type NativeStackTypeBag, +} from "@react-navigation/native-stack"; +import { use, type ComponentProps } from "react"; + +import { RoutedFormSheetContext } from "../../components/ui/RoutedFormSheetContext"; + +import { projectStackPresentation } from "../../native/workspace-stack-projection"; +import { + WorkspaceColumnsRendererContext, + WorkspacePresentationHostContext, +} from "./workspace-presentation-context"; + +type ViewProps = ComponentProps; +type Descriptor = ViewProps["descriptors"][string]; +type Route = ViewProps["state"]["routes"][number]; + +function WorkspaceStackView(props: ViewProps) { + const renderColumns = use(WorkspaceColumnsRendererContext); + if (!renderColumns) return ; + + const isOverlay = (route: Route) => { + const descriptor = props.descriptors[route.key] ?? props.describe(route, true); + const presentation = descriptor.options.presentation; + return presentation !== undefined && presentation !== "card"; + }; + const projection = projectStackPresentation(props.state, isOverlay); + const workspaceRoutes = projection.workspace; + const visibleRoute = workspaceRoutes.at(-1); + if (!visibleRoute) return ; + + const visibleDescriptor = props.descriptors[visibleRoute.key]!; + const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; + const baseDescriptor: Descriptor = { + ...visibleDescriptor, + options: { headerShown: false, freezeOnBlur: false }, + render: () => + renderColumns( + !isOverlay(route)), + }} + />, + ), + }; + const routes = [baseRoute, ...projection.overlays]; + return ( + + ); +} + +function WorkspaceStackNavigator({ + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + ...rest +}: NativeStackNavigatorProps) { + const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< + StackNavigationState, + StackRouterOptions, + StackActionHelpers, + NativeStackNavigationOptions, + NativeStackNavigationEventMap + >(StackRouter, { + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + }); + return ( + + + [ + key, + descriptor.options.presentation === "formSheet" + ? { + ...descriptor, + render: () => ( + {descriptor.render()} + ), + } + : descriptor, + ]), + )} + navigation={navigation} + /> + + + ); +} + +type WorkspaceTypeBag< + ParamList extends ParamListBase, + NavigatorID extends string | undefined, +> = Omit, "Navigator"> & { + Navigator: typeof WorkspaceStackNavigator; +}; + +export function createWorkspaceStackNavigator< + const ParamList extends ParamListBase, + const NavigatorID extends string | undefined = string | undefined, + const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag, + const Config extends StaticConfig = StaticConfig, +>(config?: Config): TypedNavigator { + return createNavigatorFactory(WorkspaceStackNavigator)(config); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 0b2efd9de240a..e2697f4e4d238 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -19,16 +19,7 @@ import { type NativeStackNavigatorProps, type NativeStackTypeBag, } from "@react-navigation/native-stack"; -import { - Fragment, - use, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ComponentProps, -} from "react"; +import { Fragment, use, useEffect, useRef, useState, type ComponentProps } from "react"; import { StyleSheet, useWindowDimensions, View } from "react-native"; import { Split, type SplitHostCommands } from "react-native-screens"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -106,17 +97,13 @@ function ColumnScreen(props: { descriptor.options, ); const [presented, setPresented] = useState(false); - const primaryColumn = useMemo( - () => - props.primary - ? { - presented, - selectedThreadKey: props.selectedThreadKey ?? null, - selectedPullRequestKey: props.selectedPullRequestKey ?? null, - } - : null, - [props.primary, props.selectedThreadKey, props.selectedPullRequestKey, presented], - ); + const primaryColumn = props.primary + ? { + presented, + selectedThreadKey: props.selectedThreadKey ?? null, + selectedPullRequestKey: props.selectedPullRequestKey ?? null, + } + : null; const key = descriptor.route.key; return ( { - setScreens((current) => ({ - ...current, - routes: current.routes.filter((route) => route.key !== key), - completed: new Set([...current.completed, key]), - })); - if (native) { - const state = props.navigation.getState(); - const action = nativeWorkspacePopAction(state, key); - if (action) props.navigation.dispatch(action); - } - }, - [props.navigation], - ); + const handleNativeDismiss = (key: string, native: boolean) => { + setScreens((current) => ({ + ...current, + routes: current.routes.filter((route) => route.key !== key), + completed: new Set([...current.completed, key]), + })); + if (native) { + const state = props.navigation.getState(); + const action = nativeWorkspacePopAction(state, key); + if (action) props.navigation.dispatch(action); + } + }; const followCollapsedSplit = ( type: "willAppear" | "didAppear" | "willDisappear", key: string, @@ -303,6 +287,7 @@ function WorkspaceColumns( if (transition.pop) props.navigation.dispatch(transition.pop); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the native split host which column to show while collapsed useEffect(() => { if (layout.usesSplitView || collapsedColumn.current.visible === compactColumn) return; collapsedColumn.current = { ...collapsedColumn.current, visible: compactColumn }; @@ -505,6 +490,14 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean const presentation = props.descriptors[route.key]?.options.presentation; return presentation !== undefined && presentation !== "card"; }); + // The root's willDisappear reaches JS frames into the push, so hide the dock in the commit that + // pushes; the native events still own every later change, including back gestures. + const topDetailKey = projection.detail.at(-1)?.key; + const [pushedDetailKey, setPushedDetailKey] = useState(topDetailKey); + if (pushedDetailKey !== topDetailKey) { + setPushedDetailKey(topDetailKey); + if (topDetailKey !== undefined && primaryVisible) setPrimaryVisible(false); + } // Detail-only histories must stay single-stack: placeholder descriptors cannot own sidebar actions or setOptions. const primaryRoute = projection.primary; if (!primaryRoute) return ; diff --git a/apps/mobile/src/features/layout/deferred-navigation.ts b/apps/mobile/src/features/layout/deferred-navigation.ts index 8da68b10b394c..579e1fed0a1cd 100644 --- a/apps/mobile/src/features/layout/deferred-navigation.ts +++ b/apps/mobile/src/features/layout/deferred-navigation.ts @@ -20,3 +20,9 @@ export function createDeferredNavigation() { }, }; } + +/** + * The app's one thread-selection gate: a new-task branch checkout holds + * split-view thread selection until the draft route can be left. + */ +export const threadSelectionDeferral = createDeferredNavigation(); diff --git a/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts b/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts index 6f7627ca677d0..75367dfe5985c 100644 --- a/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts +++ b/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts @@ -1,4 +1,3 @@ -import { useMemo } from "react"; import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; import { withNativeGlassHeaderItem } from "./native-glass-header-items"; @@ -11,29 +10,19 @@ export function useWorkspaceSidebarHeaderItem() { ? "Hide thread sidebar" : "Maximize content" : "Show thread sidebar"; - return useMemo( - () => - withNativeGlassHeaderItem({ - axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, - accessibilityLabel: label, - icon: { - name: - panes.primarySidebarVisible && !layout.primarySidebarOverlaysContent - ? "arrow.up.left.and.arrow.down.right" - : "sidebar.left", - type: "sfSymbol" as const, - }, - identifier: "thread-left-sidebar", - label, - onPress: togglePrimarySidebar, - type: "button" as const, - }), - [ - label, - layout.primarySidebarOverlaysContent, - panes.primarySidebarVisible, - togglePrimarySidebar, - usesNativeWorkspaceColumns, - ], - ); + return withNativeGlassHeaderItem({ + axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, + accessibilityLabel: label, + icon: { + name: + panes.primarySidebarVisible && !layout.primarySidebarOverlaysContent + ? "arrow.up.left.and.arrow.down.right" + : "sidebar.left", + type: "sfSymbol" as const, + }, + identifier: "thread-left-sidebar", + label, + onPress: togglePrimarySidebar, + type: "button" as const, + }); } diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index 68a57a2298099..4c003008d1d62 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -40,6 +40,7 @@ export function WorkspaceInspectorPane(props: { const reduceMotion = useReducedMotion(); const onClosed = props.onClosed; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated inspector progress and width shared values when visibility or width changes useEffect(() => { inspectorProgress.set( withTiming(inspectorVisible ? 1 : 0, WORKSPACE_PANE_TIMING, (finished) => { diff --git a/apps/mobile/src/features/layout/workspace-presentation-context.tsx b/apps/mobile/src/features/layout/workspace-presentation-context.tsx new file mode 100644 index 0000000000000..ec1544dce8213 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-presentation-context.tsx @@ -0,0 +1,7 @@ +import { createContext, type ReactElement, type ReactNode } from "react"; + +/** Android's native presentation stack surrounds all workspace columns. */ +export const WorkspacePresentationHostContext = createContext(false); +export const WorkspaceColumnsRendererContext = createContext< + ((children: ReactNode) => ReactElement) | null +>(null); diff --git a/apps/mobile/src/features/observability/observe.ts b/apps/mobile/src/features/observability/observe.ts new file mode 100644 index 0000000000000..fbbb5e22b8339 --- /dev/null +++ b/apps/mobile/src/features/observability/observe.ts @@ -0,0 +1,63 @@ +import Constants from "expo-constants"; +import { Observe } from "expo-observe"; + +// Importing expo-observe installs its global JS error handler; reports leave only through the relay. +const relayUrl: unknown = Constants.expoConfig?.extra?.relay?.url; +const appVariant: unknown = Constants.expoConfig?.extra?.appVariant; + +export const crashReportingAvailable = typeof relayUrl === "string" && relayUrl.trim() !== ""; + +interface PromiseRejectionTracker { + readonly enablePromiseRejectionTracker?: (options: { + readonly allRejections: boolean; + readonly onUnhandled: (id: number, rejection: unknown) => void; + }) => void; +} + +// React Native tracks unhandled rejections only in development, for LogBox; Observe has no hook. +if (!__DEV__) { + const hermes: PromiseRejectionTracker | undefined = Reflect.get(globalThis, "HermesInternal"); + hermes?.enablePromiseRejectionTracker?.({ + allRejections: true, + onUnhandled: (_id, rejection) => Observe.reportError(rejection), + }); +} + +let latestConfiguration = 0; + +// While sending is off, a dispatch pass drops what it finds, so one runs before turning it back on. +// Best effort: Observe skips the pass while it is backing off from a failed send. +export async function setCrashReportsEnabled(enabled: boolean) { + const configuration = ++latestConfiguration; + const dispatchingEnabled = crashReportingAvailable && enabled; + if (dispatchingEnabled) { + await Observe.dispatchEvents(); + if (configuration !== latestConfiguration) return; + } + Observe.configure({ + dispatchingEnabled, + ...(typeof appVariant === "string" ? { environment: appVariant } : {}), + }); +} + +if (!crashReportingAvailable) { + void setCrashReportsEnabled(false); +} else { + // Observe keeps its last configuration across launches; configuring it off while the setting + // loads would drop the last session's native crash. Imported lazily so the persistence graph + // never runs ahead of the imports `index.ts` orders first. + const changedSinceLoad = () => latestConfiguration !== 0; + void import("../../persistence/imperative") + .then(({ loadPreferences }) => loadPreferences()) + .then( + (preferences) => + changedSinceLoad() + ? undefined + : setCrashReportsEnabled(preferences.crashReportsEnabled !== false), + // Observe keeps the last choice it was given, which stands in for the unreadable one. + (error: unknown) => console.warn("[observe] could not read the crash report setting", error), + ) + .catch((error: unknown) => { + console.warn("[observe] could not apply the crash report setting", error); + }); +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index 3331f8e644084..b251310ef12f9 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -24,12 +24,10 @@ const state = vi.hoisted(() => ({ workspaceRoot: string; }[], createResult: null as Promise | null, - cloneResult: null as Promise | null, createNewResult: null as Promise | null, startCloneResult: null as Promise | null, connectionStates: {} as Record, newProjectsRoot: null as string | null, - supportsCloneTracking: false, waitForProject: vi.fn(), dispatch: vi.fn(), alert: vi.fn(), @@ -114,10 +112,7 @@ vi.mock("../../state/entities", () => ({ [ "environment", { - environment: { - platform: { os: "linux" }, - capabilities: { projectCloneTracking: state.supportsCloneTracking }, - }, + environment: { platform: { os: "linux" }, capabilities: {} }, newProjectsRoot: state.newProjectsRoot, settings: { addProjectBaseDirectory: state.baseDirectory }, }, @@ -148,11 +143,10 @@ vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { discovery: (input: unknown) => input, - startProjectClone: () => state.startCloneResult, - cloneRepository: async ({ input }: { input: { destinationPath: string } }) => { - if (state.cloneResult) return state.cloneResult; + startProjectClone: async ({ input }: { input: { destinationPath: string } }) => { + if (state.startCloneResult) return state.startCloneResult; NodeFS.mkdirSync(input.destinationPath); - return AsyncResult.success({ cwd: input.destinationPath }); + return AsyncResult.success(undefined); }, }, })); @@ -563,30 +557,6 @@ describe("local project navigation", () => { expect(state.dispatch).not.toHaveBeenCalled(); }); - it("does not redirect a reopened flow when an earlier legacy clone finishes", async () => { - const { created, projected } = setup(); - state.scopes.add(AuthSourceControlWriteScope); - const cloned = Promise.withResolvers(); - state.cloneResult = cloned.promise; - const navigation = mountProjectFlow(false, true, true, "AddProjectDestination"); - try { - const submission = cloneAction()(); - navigation.back(); - navigation.push("AddProjectDestination"); - const remaining = navigation.getState(); - cloned.resolve(AsyncResult.success({ cwd: "/tmp/t3-mobile-audit-fixture/repo" })); - created.resolve(AsyncResult.success(undefined)); - projected.resolve({ environmentId: "environment", id: "project" }); - await submission; - - expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture/repo"]); - expect(navigation.getState()).toEqual(remaining); - expect(state.dispatch).not.toHaveBeenCalled(); - } finally { - state.cloneResult = null; - } - }); - it("does not redirect a reopened flow when an earlier new project finishes", async () => { const { projected } = setup(); state.newProjectsRoot = "/projects"; @@ -686,3 +656,103 @@ describe("local project navigation", () => { }, ); }); + +describe("local folder path", () => { + function findPathInput( + node: ReactNode, + ): { value: string; onChangeText: (value: string) => void } | null { + if (Array.isArray(node)) { + for (const child of node) { + const input = findPathInput(child); + if (input) return input; + } + return null; + } + if ( + !isValidElement<{ + value?: string; + onChangeText?: (value: string) => void; + onSubmit?: () => void; + children?: ReactNode; + }>(node) + ) { + return null; + } + const { value, onChangeText, onSubmit } = node.props; + if (typeof value === "string" && onChangeText && onSubmit) return { value, onChangeText }; + return findPathInput(node.props.children); + } + + function mountLocalFolder() { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.baseDirectory = "/work"; + state.connectionStates = {}; + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + const renderedPaths: string[] = []; + let input: ReturnType = null; + function LocalFolderObserver(props: { environmentId?: string }) { + input = findPathInput(AddProjectLocalFolderScreen(props)); + if (input) renderedPaths.push(input.value); + return null; + } + return { + renderedPaths, + get path() { + return input?.value; + }, + type: (value: string) => act(() => input!.onChangeText(value)), + render: (environmentId?: string) => + act(() => root.render(createElement(LocalFolderObserver, { environmentId }))), + [Symbol.asyncDispose]: async () => { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + }, + }; + } + + it("shows the new host's default path on the first render after the host changes", async () => { + await using screen = mountLocalFolder(); + await screen.render("environment"); + await screen.type("/work/edited"); + screen.renderedPaths.length = 0; + + await screen.render("another-environment"); + + expect(screen.renderedPaths).not.toContain("/work/edited"); + expect(screen.path).toBe("~/"); + }); + + it("keeps each host's typed path when switching between hosts", async () => { + await using screen = mountLocalFolder(); + await screen.render("environment"); + await screen.type("/work/a"); + await screen.render("another-environment"); + await screen.type("~/b"); + + await screen.render("environment"); + expect(screen.path).toBe("/work/a"); + await screen.render("another-environment"); + expect(screen.path).toBe("~/b"); + }); + + it("keeps a typed path when the default host drops out and comes back", async () => { + await using screen = mountLocalFolder(); + await screen.render(); + expect(screen.path).toBe("~/"); + await screen.type("~/code/app"); + + state.connectionStates = { "another-environment": "offline" }; + await screen.render(); + expect(screen.path).toBe("/work/"); + + state.connectionStates = {}; + await screen.render(); + expect(screen.path).toBe("~/code/app"); + }); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 49de8d219ba2b..218d01041561d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -87,6 +87,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { ThemedSwitch } from "../../components/ui/ThemedSwitch"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; +import { suggestAppAccessForRemote } from "./suggestAppAccess"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { useRemoteConnectionStatus, @@ -110,7 +111,6 @@ interface EnvironmentOption { readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; - readonly supportsCloneTracking: boolean; } const environmentOptionOrder = Order.mapInput( @@ -292,23 +292,40 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto const environmentId = environment?.environmentId ?? null; const environmentBaseDirectory = environment?.baseDirectory ?? null; const clonePathCaseSensitive = !isWindowsPlatform(environment?.platform ?? ""); - const [pathInput, commitPathInput] = useState(() => - getCloneDestinationPath( - getAddProjectInitialQuery(environmentBaseDirectory), - pinnedDirectoryName, - ), + const seedPath = getCloneDestinationPath( + getAddProjectInitialQuery(environmentBaseDirectory), + pinnedDirectoryName, ); - const previousEnvironmentIdRef = useRef(environmentId); + // Each host keeps the path last typed or browsed for it, and shows its own default until then, + // so switching hosts and back loses nothing. With no host selected (one dropping out briefly), + // the field keeps the last path typed anywhere. + const [paths, setPaths] = useState<{ + readonly byHost: ReadonlyMap; + readonly last: string; + }>(() => ({ byHost: new Map(), last: seedPath })); + const pathInput = + environmentId === null ? paths.last : (paths.byHost.get(environmentId) ?? seedPath); + const commitPathInput = (path: string) => + setPaths((current) => { + const byHost = new Map(current.byHost); + if (environmentId !== null) { + byHost.set(environmentId, path); + } + return { byHost, last: path }; + }); const environmentRuntime = useRemoteEnvironmentRuntime(environmentId); const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { reportFailure: false, reportDefect: false, }); const [browseNavigation] = useState(createBrowseNavigationCoordinator); - const [isBrowseNavigating, setIsBrowseNavigating] = useState(false); + const [navigatingEnvironmentId, setNavigatingEnvironmentId] = useState< + EnvironmentId | null | undefined + >(undefined); + const isBrowseNavigating = navigatingEnvironmentId === environmentId; const setPathInput = (path: string) => { browseNavigation.invalidate(); - setIsBrowseNavigating(false); + setNavigatingEnvironmentId(undefined); commitPathInput(path); }; const navigateToBrowsePath = async (input: { @@ -327,7 +344,7 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto caseSensitive: clonePathCaseSensitive, }) : getCloneDestinationPath(selectedDirectoryPath, pinnedDirectoryName); - setIsBrowseNavigating(true); + setNavigatingEnvironmentId(environmentId); const committed = await browseNavigation.run( async () => { if ( @@ -344,23 +361,12 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto () => commitPathInput(nextPathInput), ); if (committed) { - setIsBrowseNavigating(false); + setNavigatingEnvironmentId(undefined); } return committed; }; - useEffect(() => { - if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { - previousEnvironmentIdRef.current = environmentId; - setPathInput( - getCloneDestinationPath( - getAddProjectInitialQuery(environmentBaseDirectory), - pinnedDirectoryName, - ), - ); - } - }, [environmentBaseDirectory, environmentId, pinnedDirectoryName, setPathInput]); - + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- invalidates the browse navigation coordinator on unmount so in-flight browses do not commit useEffect( () => () => { browseNavigation.invalidate(); @@ -395,7 +401,6 @@ function useEnvironmentOptions( connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, - supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true, }; }); return Arr.sort( @@ -993,6 +998,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s ); const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId); const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const checkPublishedAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, { + reportFailure: false, + reportDefect: false, + }); const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { reportFailure: false, }); @@ -1091,6 +1100,12 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s `Could not create the ${publishTarget.definition.label} repository`, errorMessage(Cause.squash(publishResult.cause)), ); + } else { + void suggestAppAccessForRemote( + checkPublishedAppAccess, + environment.environmentId, + publishResult.value.remoteUrl, + ); } }); } @@ -1308,11 +1323,12 @@ export function AddProjectDestinationScreen(props: { readonly prompt?: string | string[]; }) { const openNewTaskDraft = useOpenNewTaskDraft(); - const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { + const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { reportFailure: false, }); - const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { + const checkAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, { reportFailure: false, + reportDefect: false, }); const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); @@ -1325,7 +1341,6 @@ export function AddProjectDestinationScreen(props: { AuthOrchestrationOperateScope, ); const canCloneProject = canWriteSourceControl && canCreateProject; - const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); const repositoryTitle = stringParam(props.repositoryTitle); const prompt = stringParam(props.prompt); @@ -1361,65 +1376,47 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); - if (environment.supportsCloneTracking) { - const projectId = ProjectId.make(uuidv4()); - const title = inferProjectTitleFromPath(resolved.path); - const startResult = await startProjectClone({ - environmentId: environment.environmentId, - input: { - projectId, - title, - createdAt: new Date().toISOString(), - remoteUrl, - destinationPath: resolved.path, - }, - }); - if (AsyncResult.isFailure(startResult)) { - setError(errorMessage(Cause.squash(startResult.cause))); - } else { - // Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls. - const project = await waitForProject( - { environmentId: environment.environmentId, projectId }, - 15_000, - ); - if (project === null) { - setError( - "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", - ); - } else { - openNewTaskDraft( - { - environmentId: environment.environmentId, - projectId, - title, - cloning: "1", - ...(prompt - ? await draftWithPrompt(environment.environmentId, projectId, prompt).then( - (draftId) => ({ draftId }), - () => ({}), - ) - : {}), - }, - originRoute, - ); - } - } - setIsSubmitting(false); - return; - } - const cloneResult = await cloneRepository({ + const projectId = ProjectId.make(uuidv4()); + const title = inferProjectTitleFromPath(resolved.path); + const startResult = await startProjectClone({ environmentId: environment.environmentId, input: { + projectId, + title, + createdAt: new Date().toISOString(), remoteUrl, destinationPath: resolved.path, }, }); - if (AsyncResult.isFailure(cloneResult)) { - setError(errorMessage(Cause.squash(cloneResult.cause))); + if (AsyncResult.isFailure(startResult)) { + setError(errorMessage(Cause.squash(startResult.cause))); } else { - const createResult = await createProject(cloneResult.value.cwd, originRoute); - if (createResult && AsyncResult.isFailure(createResult)) { - setError(errorMessage(Cause.squash(createResult.cause))); + void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl); + // Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + } else { + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title, + cloning: "1", + ...(prompt + ? await draftWithPrompt(environment.environmentId, projectId, prompt).then( + (draftId) => ({ draftId }), + () => ({}), + ) + : {}), + }, + originRoute, + ); } } setIsSubmitting(false); diff --git a/apps/mobile/src/features/projects/suggestAppAccess.ts b/apps/mobile/src/features/projects/suggestAppAccess.ts new file mode 100644 index 0000000000000..9340d8dd836ad --- /dev/null +++ b/apps/mobile/src/features/projects/suggestAppAccess.ts @@ -0,0 +1,38 @@ +import type { + EnvironmentId, + SourceControlAppAccess, + SourceControlAppAccessInput, +} from "@t3tools/contracts"; +import { appSignInTargetForRemote } from "@t3tools/client-runtime/source-control-clients"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { Alert, Linking } from "react-native"; + +/** + * After a clone, suggests installing the forge app on the repository's owner when the server is + * signed in with it but the app does not reach the repository. + */ +export async function suggestAppAccessForRemote( + check: (value: { + readonly environmentId: EnvironmentId; + readonly input: SourceControlAppAccessInput; + }) => Promise>, + environmentId: EnvironmentId, + remoteUrl: string, +): Promise { + const target = appSignInTargetForRemote(remoteUrl); + if (target === null) return; + const result = await check({ environmentId, input: target.input }); + if (result._tag !== "Success" || result.value.status !== "not-covered") return; + const owner = target.input.repository.split("/")[0] ?? target.input.repository; + Alert.alert( + `Give ${target.appSignIn.appName} access to ${owner}`, + `It isn't installed there, so GitHub requests for ${target.input.repository} use your own login and its lower rate limit.`, + [ + { text: "Not now", style: "cancel" }, + { + text: "Install", + onPress: () => void Linking.openURL(target.appSignIn.installUrl).catch(() => undefined), + }, + ], + ); +} diff --git a/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts b/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts new file mode 100644 index 0000000000000..e59670cc64966 --- /dev/null +++ b/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts @@ -0,0 +1,419 @@ +import * as Dialog from "../../components/ui/Dialog"; +import { + StackActions, + type NavigationState, + type PartialState, + type StackActionType, +} from "@react-navigation/native"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; +import { + findPromptLinkProjects, + findPromptLinkPullRequestThread, + resolvePromptLinkModelSelection, +} from "@t3tools/client-runtime/prompt-links"; +import { + executeAtomQuery, + isAtomCommandInterrupted, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + parseNewThreadLink, + type LinkedPrompt, + type PromptLinkPullRequest, + type PromptLinkRepository, +} from "@t3tools/shared/promptLinks"; +import { Atom, type AsyncResult } from "effect/reactivity"; + +import { findLinkedRoute, findStackRoute, updateLinkedRoute } from "../../lib/appLinking"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentProjects, projectEnvironment } from "../../state/projects"; +import { composerPullRequests } from "../../state/pull-requests"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { environmentThreadShells } from "../../state/threads"; +import { + createNewTaskDraft, + setComposerDraftText, + updateComposerDraftSettings, + waitForComposerDraftsLoaded, +} from "../../state/use-composer-drafts"; +import { workspaceConnections, workspaceStateAtom } from "../../state/workspace"; +import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions"; +import { + latestThreadEnvironmentIds, + promptLinkNotice, + promptLinkRepositoryName, + promptLinkScratchEnvironmentId, + promptLinkSearchFromParams, + stepPendingPromptLink, + withoutPromptLinkParams, +} from "./prompt-link-model"; + +// Found at any depth, both in the parsed link and on the live stack, so the two never disagree on +// whether the link has landed. +const NEW_TASK_ROUTE = "NewTaskProject"; +const NEW_TASK_ROUTES = [NEW_TASK_ROUTE]; + +type LinkedDraftRequest = { + readonly prompt: LinkedPrompt | null; + readonly provider: string | null; + readonly model: string | null; + readonly pullRequest: PromptLinkPullRequest | null; +}; + +// A cold launch from a link has no projects or threads yet. Matching then would offer to clone a +// repository the user already has. +const shellsSettledAtom = Atom.make((get) => { + const workspace = get(workspaceStateAtom); + return !( + workspace.isLoadingConnections || + workspace.hasPendingShellSnapshot || + // A machine still connecting may hold the repository or thread. One that keeps failing to + // reconnect has settled offline, and must not hold every link. + workspace.connectingEnvironments.some( + (environment) => + environment.connectionState === "connecting" || environment.connectionError === null, + ) + ); +}).pipe(Atom.withLabel("mobile:prompt-link-shells-settled")); + +let pendingLink: string | null = null; +let stopWaitingForShells: (() => void) | null = null; + +/** + * `new?prompt=…&repo=…` drops its params as it arrives, so the screen never sees them and each + * opening resolves once. It resolves when its screen is on the navigation state that follows and + * the shells have settled; a newer link replaces one still waiting. + */ +export function takeNewTaskPromptLink( + state: PartialState | undefined, +): PartialState | undefined { + const route = findLinkedRoute(state, NEW_TASK_ROUTES); + const search = promptLinkSearchFromParams( + route?.params as Readonly> | undefined, + ); + if (search === null) return state; + pendingLink = search; + return updateLinkedRoute(state, NEW_TASK_ROUTES, (linked) => ({ + ...linked, + params: withoutPromptLinkParams(linked.params), + })); +} + +/** Wired to the navigation container's state changes. */ +export function resolvePendingNewTaskPromptLink(state: NavigationState | undefined): void { + if (pendingLink === null) return; + const step = stepPendingPromptLink(pendingLink, { + // A screen pushed above New task (such as Add project) drops the link, so it never lands + // over the wrong screen. + screenOnStack: findStackRoute(state, NEW_TASK_ROUTE)?.focused === true, + ready: appAtomRegistry.get(shellsSettledAtom), + }); + if (step.pending === null) dropPendingLink(); + else pendingLink = step.pending; + if (step.resolve !== null) resolveLink(step.resolve); + else if (step.pending !== null) { + stopWaitingForShells ??= appAtomRegistry.subscribe(shellsSettledAtom, (settled) => { + if (settled) resolvePendingNewTaskPromptLink(rootNavigationRef.getRootState()); + }); + } +} + +function dropPendingLink(): void { + pendingLink = null; + stopWaitingForShells?.(); + stopWaitingForShells = null; +} + +/** Dispatches as the new-task screen would, so `replace` swaps that screen out. */ +function dispatchFromNewTask(action: StackActionType): void { + const root = rootNavigationRef.getRootState(); + const screen = findStackRoute(root, NEW_TASK_ROUTE); + const target = screen?.navigatorKey ?? root?.key; + rootNavigationRef.dispatch({ + ...action, + ...(target ? { target } : {}), + ...(screen ? { source: screen.routeKey } : {}), + }); +} + +function runQuery(atom: Atom.Atom>) { + return executeAtomQuery(appAtomRegistry, atom, { + label: atom.label?.[0] ?? "atom query", + reportFailure: false, + }); +} + +function writeDraft( + draftKey: string, + environmentId: EnvironmentId, + request: LinkedDraftRequest, +): boolean { + if (request.prompt) setComposerDraftText(draftKey, request.prompt.text); + if (request.provider === null) return false; + const resolved = resolvePromptLinkModelSelection( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.providers ?? [], + { provider: request.provider, model: request.model }, + ); + if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection }); + return resolved?.exact !== true; +} + +function openThread( + thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId }, + prompt: LinkedDraftRequest["prompt"], +) { + dispatchFromNewTask( + StackActions.replace("Thread", { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }), + ); + if (prompt) { + void applyLinkedPromptToThreadDraft( + scopedThreadKey(thread.environmentId, thread.id), + prompt, + promptLinkNotice({ hiddenCharacters: prompt.hiddenCharacters }), + ); + } +} + +// A pull request's thread can be an older settled thread no shell holds, so ask every +// connected environment with the repository. A lookup on another one would wait forever. +async function findServerPullRequestThread( + projects: ReadonlyArray, + pullRequest: PromptLinkPullRequest, +) { + const connected = new Set( + appAtomRegistry + .get(workspaceConnections.environmentsAtom) + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); + const lookups = projects + .filter((project) => connected.has(project.environmentId)) + .map(async (project) => { + const linked = await runQuery( + composerPullRequests.linkedThreadsUncached({ + environmentId: project.environmentId, + input: { + projectId: project.id, + repository: promptLinkRepositoryName(pullRequest), + number: pullRequest.number, + }, + }), + ); + const thread = + linked._tag === "Success" + ? linked.value.threads.find((candidate) => candidate.archivedAt === null) + : undefined; + return thread && { environmentId: project.environmentId, id: thread.id }; + }); + // The most recent project wins without waiting for the lookups after it. + for (const lookup of lookups) { + const thread = await lookup; + if (thread) return thread; + } + return undefined; +} + +async function openProjectDraft(project: EnvironmentProject, request: LinkedDraftRequest) { + try { + await waitForComposerDraftsLoaded(); + } catch { + Dialog.alert("Could not open link", "Drafts could not be loaded, so the prompt was not added."); + return; + } + const draftKey = createNewTaskDraft({ + environmentId: project.environmentId, + projectId: project.id, + }); + let pullRequestUnavailable: number | null = null; + if (request.pullRequest) { + const detail = await runQuery( + composerPullRequests.detail({ + environmentId: project.environmentId, + input: { + projectId: project.id, + repository: promptLinkRepositoryName(request.pullRequest), + number: request.pullRequest.number, + }, + }), + ); + if (detail._tag === "Success") { + updateComposerDraftSettings(draftKey, { + workspaceSelection: { + mode: "worktree", + branch: detail.value.headBranch, + worktreePath: null, + startFromOrigin: false, + pullRequestReference: detail.value.url, + }, + }); + } else { + pullRequestUnavailable = request.pullRequest.number; + } + } + const modelUnavailable = writeDraft(draftKey, project.environmentId, request); + dispatchFromNewTask( + StackActions.push("Thread", { + draft: { + draftId: draftKey, + title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title, + }, + }), + ); + if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) { + showPromptLinkNotice( + promptLinkNotice({ + hiddenCharacters: request.prompt?.hiddenCharacters ?? 0, + modelUnavailable, + pullRequestUnavailable, + }), + ); + } +} + +async function openScratchDraft(request: LinkedDraftRequest) { + const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); + const environmentId = promptLinkScratchEnvironmentId( + latestThreadEnvironmentIds(appAtomRegistry.get(environmentThreadShells.threadShellsAtom)), + appAtomRegistry + .get(workspaceConnections.environmentsAtom) + .filter( + (environment) => + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, + ) + .map((environment) => environment.environmentId), + ); + if (environmentId === null) { + Dialog.alert( + "Could not start without a project", + "No connected environment offers threads without a project.", + ); + return; + } + const result = await runAtomCommand( + appAtomRegistry, + projectEnvironment.openScratch, + { environmentId, input: {} }, + { reportFailure: false }, + ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Could not start without a project", + error instanceof Error + ? error.message + : "The folder for threads without a project could not be created.", + ); + } + return; + } + await openProjectDraft(result.value, { ...request, pullRequest: null }); +} + +function cloneRepository(repository: PromptLinkRepository, request: LinkedDraftRequest) { + // The clone screen lets the user pick another environment before cloning. + const environmentId = + appAtomRegistry.get(environmentProjects.projectsAtom)[0]?.environmentId ?? + appAtomRegistry.get(workspaceConnections.environmentsAtom)[0]?.environmentId ?? + null; + if (environmentId === null) { + Dialog.alert("Could not clone", "Connect an environment first."); + return; + } + const prompt = + request.pullRequest === null && + appAtomRegistry.get(environmentServerConfigsAtom).has(environmentId) + ? request.prompt + : null; + const name = promptLinkRepositoryName(repository); + dispatchFromNewTask( + StackActions.push("AddProjectDestination", { + environmentId, + remoteUrl: `https://github.com/${name}.git`, + repositoryTitle: name, + repositoryName: repository.name, + ...(prompt ? { prompt: prompt.text } : {}), + }), + ); + if (!request.prompt) return; + showPromptLinkNotice( + [ + promptLinkNotice({ + hiddenCharacters: request.prompt.hiddenCharacters, + modelUnavailable: prompt !== null && request.provider !== null, + }), + prompt + ? "The prompt opens in a new task once the clone starts." + : "Open the link again after the clone to get the prompt.", + ].join("\n\n"), + ); +} + +function resolveLink(linkSearch: string) { + const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const parsed = parseNewThreadLink(linkSearch); + if (!parsed.ok) { + Dialog.alert("Could not open link", parsed.error); + return; + } + const link = parsed.value; + const request: LinkedDraftRequest = + link.kind === "new" + ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null } + : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest }; + + if (link.kind === "pull-request") { + const thread = findPromptLinkPullRequestThread(threads, link.pullRequest); + if (thread) { + openThread(thread, link.prompt); + return; + } + } + + const repository = link.kind === "new" ? link.repository : link.pullRequest; + if (repository === null) { + void openScratchDraft(request); + return; + } + const lastActivity = new Map(); + for (const thread of threads) { + const key = `${thread.environmentId}:${thread.projectId}`; + if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt); + } + const activityOf = (project: EnvironmentProject) => + lastActivity.get(`${project.environmentId}:${project.id}`) ?? ""; + const projects = findPromptLinkProjects( + [...appAtomRegistry.get(environmentProjects.projectsAtom)].sort((left, right) => + activityOf(right).localeCompare(activityOf(left)), + ), + repository, + ); + const project = projects[0]; + if (project) { + const { pullRequest } = request; + void (async () => { + const thread = pullRequest && (await findServerPullRequestThread(projects, pullRequest)); + if (thread) openThread(thread, request.prompt); + else await openProjectDraft(project, request); + })(); + return; + } + Dialog.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [ + { text: "Cancel", style: "cancel" }, + ...(request.prompt + ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }] + : []), + { text: "Clone", onPress: () => cloneRepository(repository, request) }, + ]); +} diff --git a/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts b/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts index 9bf74ef042ddc..2c24e9cf72fa5 100644 --- a/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts +++ b/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts @@ -1,7 +1,11 @@ import { parseNewThreadLink } from "@t3tools/shared/promptLinks"; import { describe, expect, it } from "vite-plus/test"; -import { promptLinkSearchFromParams } from "./prompt-link-model"; +import { + promptLinkSearchFromParams, + stepPendingPromptLink, + withoutPromptLinkParams, +} from "./prompt-link-model"; describe("promptLinkSearchFromParams", () => { it("round-trips decoded route params into the shared parser", () => { @@ -24,3 +28,40 @@ describe("promptLinkSearchFromParams", () => { }); }); }); + +describe("stepPendingPromptLink", () => { + const waiting = "repo=t3tools%2Ft3code"; + + it("drops a link whose screen is missing from the first state after it was parsed", () => { + expect(stepPendingPromptLink(waiting, { screenOnStack: false, ready: true })).toEqual({ + pending: null, + resolve: null, + }); + }); + + it("holds a link on its screen until its target is ready, then resolves it once", () => { + expect(stepPendingPromptLink(waiting, { screenOnStack: true, ready: false })).toEqual({ + pending: waiting, + resolve: null, + }); + expect(stepPendingPromptLink(waiting, { screenOnStack: true, ready: true })).toEqual({ + pending: null, + resolve: waiting, + }); + }); + + it("drops a link whose screen was left before its target was ready", () => { + expect(stepPendingPromptLink(waiting, { screenOnStack: false, ready: false })).toEqual({ + pending: null, + resolve: null, + }); + }); +}); + +describe("withoutPromptLinkParams", () => { + it("keeps the route's other params", () => { + expect(withoutPromptLinkParams({ prompt: "hi", repo: "a/b", incomingShareId: "1" })).toEqual({ + incomingShareId: "1", + }); + }); +}); diff --git a/apps/mobile/src/features/prompt-links/prompt-link-model.ts b/apps/mobile/src/features/prompt-links/prompt-link-model.ts index 5872b8c9f7260..eccb5ab935bca 100644 --- a/apps/mobile/src/features/prompt-links/prompt-link-model.ts +++ b/apps/mobile/src/features/prompt-links/prompt-link-model.ts @@ -1,6 +1,6 @@ import type { PromptLinkRepository } from "@t3tools/shared/promptLinks"; -export const PROMPT_LINK_PARAM_KEYS = ["prompt", "repo", "pr", "provider", "model"] as const; +const PROMPT_LINK_PARAM_KEYS = ["prompt", "repo", "pr", "provider", "model"] as const; type RouteParams = Readonly> | undefined; @@ -17,8 +17,12 @@ export function promptLinkSearchFromParams( return search.size > 0 ? search.toString() : null; } -export function clearedPromptLinkParams(keys: ReadonlyArray = PROMPT_LINK_PARAM_KEYS) { - return Object.fromEntries(keys.map((key) => [key, undefined])); +export function withoutPromptLinkParams(params: object | undefined): Record { + return Object.fromEntries( + Object.entries(params ?? {}).filter( + ([key]) => !(PROMPT_LINK_PARAM_KEYS as ReadonlyArray).includes(key), + ), + ); } export function promptLinkNotice(input: { @@ -73,3 +77,17 @@ export function latestThreadEnvironmentIds( export const promptLinkRepositoryName = (repository: PromptLinkRepository) => `${repository.owner}/${repository.name}`; + +/** + * Advances a parsed link at each navigation state; `resolve` is the link to apply. The first + * state after the parse must hold the link's screen. A link that never lands there, or whose + * screen is left before its target is ready, is dropped so it cannot apply on a later visit. + */ +export function stepPendingPromptLink( + link: Link, + input: { readonly screenOnStack: boolean; readonly ready: boolean }, +): { readonly pending: Link | null; readonly resolve: Link | null } { + if (!input.screenOnStack) return { pending: null, resolve: null }; + if (input.ready) return { pending: null, resolve: link }; + return { pending: link, resolve: null }; +} diff --git a/apps/mobile/src/features/prompt-links/threadPromptLink.test.ts b/apps/mobile/src/features/prompt-links/threadPromptLink.test.ts new file mode 100644 index 0000000000000..3ad3bca31ebf6 --- /dev/null +++ b/apps/mobile/src/features/prompt-links/threadPromptLink.test.ts @@ -0,0 +1,144 @@ +import type { NavigationState, PartialState } from "@react-navigation/native"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const fixture = vi.hoisted(() => ({ + applied: [] as { draftKey: string; text: string }[], + root: undefined as NavigationState | undefined, + detailOpens: 0, +})); + +vi.mock("../../components/ui/Dialog", () => ({ alert: () => {} })); +vi.mock("./prompt-link-actions", () => ({ + applyLinkedPromptToThreadDraft: async (draftKey: string, prompt: { text: string }) => { + fixture.applied.push({ draftKey, text: prompt.text }); + }, +})); +vi.mock("../../lib/rootNavigation", () => ({ + rootNavigationRef: { getRootState: () => fixture.root }, +})); +vi.mock("../../state/atom-registry", async () => { + const { AtomRegistry } = await import("effect/reactivity"); + return { appAtomRegistry: AtomRegistry.make() }; +}); +vi.mock("../../state/threads", async () => { + const { AsyncResult, Atom } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + const shell = Atom.make(null); + const detailSource = Atom.make(AsyncResult.success({ data: Option.none() })); + // Computing the real detail atom opens the thread's server stream. + const detail = Atom.make((get) => { + fixture.detailOpens += 1; + return get(detailSource); + }); + return { + environmentThreadShells: { threadShellAtom: () => shell }, + environmentThreads: { stateAtom: () => detail }, + fixtureShell: shell, + fixtureDetail: detail, + fixtureDetailSource: detailSource, + }; +}); + +const { appAtomRegistry } = await import("../../state/atom-registry"); +const { fixtureDetail, fixtureDetailSource, fixtureShell } = + (await import("../../state/threads")) as unknown as { + fixtureShell: Atom.Writable; + fixtureDetail: Atom.Atom; + fixtureDetailSource: Atom.Writable }>>; + }; +const { resolvePendingThreadPromptLink, takeThreadPromptLink } = await import("./threadPromptLink"); + +function setup() { + fixture.applied.length = 0; + fixture.root = undefined; + appAtomRegistry.reset(); + appAtomRegistry.set(fixtureShell, null); + appAtomRegistry.set(fixtureDetailSource, AsyncResult.success({ data: Option.none() })); + fixture.detailOpens = 0; +} + +const loadedDetail = AsyncResult.success({ data: Option.some({}) }); + +const threadParams = { environmentId: "env-1", threadId: "thread-1" }; + +function linkedState(prompt = "Fix the bug"): PartialState { + return { routes: [{ name: "Thread", params: { ...threadParams, prompt } }] }; +} + +function stack(routes: NavigationState["routes"]): NavigationState { + return { + key: "root", + index: routes.length - 1, + routeNames: routes.map((route) => route.name), + routes, + stale: false, + type: "stack", + }; +} + +const threadRoute = stack([{ key: "thread", name: "Thread", params: threadParams }]); + +describe("takeThreadPromptLink", () => { + it("writes no draft for a thread that never shows", () => { + setup(); + takeThreadPromptLink(linkedState()); + expect(fixture.applied).toEqual([]); + + resolvePendingThreadPromptLink(threadRoute); + // An unknown thread's screen replaces itself with a new-thread draft. + resolvePendingThreadPromptLink( + stack([{ key: "draft", name: "Thread", params: { draft: { draftId: "d" } } }]), + ); + appAtomRegistry.set(fixtureShell, { id: "thread-1" }); + expect(fixture.applied).toEqual([]); + }); + + it("fills the draft once the linked thread shows on its screen", () => { + setup(); + takeThreadPromptLink(linkedState()); + fixture.root = threadRoute; + resolvePendingThreadPromptLink(threadRoute); + expect(fixture.applied).toEqual([]); + + appAtomRegistry.set(fixtureShell, { id: "thread-1" }); + expect(fixture.applied).toEqual([{ draftKey: expect.any(String), text: "Fix the bug" }]); + appAtomRegistry.set(fixtureShell, { id: "thread-1", title: "renamed" }); + expect(fixture.applied).toHaveLength(1); + }); + + it("fills the draft of an archived thread its screen shows from detail", () => { + setup(); + takeThreadPromptLink(linkedState()); + fixture.root = threadRoute; + // Archived threads have no shell; the open screen subscribes to their detail instead. + using _screen = { [Symbol.dispose]: appAtomRegistry.mount(fixtureDetail) }; + resolvePendingThreadPromptLink(threadRoute); + expect(fixture.applied).toEqual([]); + + appAtomRegistry.set(fixtureDetailSource, loadedDetail); + expect(fixture.applied).toEqual([{ draftKey: expect.any(String), text: "Fix the bug" }]); + }); + + it("opens no detail for a thread no screen has loaded", () => { + setup(); + takeThreadPromptLink(linkedState()); + fixture.root = threadRoute; + resolvePendingThreadPromptLink(threadRoute); + appAtomRegistry.set(fixtureDetailSource, loadedDetail); + expect(fixture.detailOpens).toBe(0); + expect(fixture.applied).toEqual([]); + resolvePendingThreadPromptLink(stack([{ key: "home", name: "Home" }])); + }); + + it("drops an earlier link to the thread when a newer one is invalid", () => { + setup(); + takeThreadPromptLink(linkedState()); + takeThreadPromptLink(linkedState("\u200b")); + fixture.root = threadRoute; + resolvePendingThreadPromptLink(threadRoute); + appAtomRegistry.set(fixtureShell, { id: "thread-1" }); + expect(fixture.applied).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/prompt-links/threadPromptLink.ts b/apps/mobile/src/features/prompt-links/threadPromptLink.ts new file mode 100644 index 0000000000000..bb8795fffc70a --- /dev/null +++ b/apps/mobile/src/features/prompt-links/threadPromptLink.ts @@ -0,0 +1,128 @@ +import * as Dialog from "../../components/ui/Dialog"; +import type { NavigationState, PartialState } from "@react-navigation/native"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { parseThreadLinkPrompt, type LinkedPrompt } from "@t3tools/shared/promptLinks"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; + +import { findStackRoute } from "../../lib/appLinking"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentThreadShells, environmentThreads } from "../../state/threads"; +import { applyLinkedPromptToThreadDraft } from "./prompt-link-actions"; +import { + promptLinkNotice, + promptLinkSearchFromParams, + stepPendingPromptLink, +} from "./prompt-link-model"; + +const THREAD_LINK_KEYS = ["prompt"]; + +type PendingThreadLink = { readonly ref: ScopedThreadRef; readonly prompt: LinkedPrompt }; + +function firstString(value: unknown): string | null { + const first = Array.isArray(value) ? value[0] : value; + return typeof first === "string" && first.length > 0 ? first : null; +} + +let pendingLink: PendingThreadLink | null = null; +let stopWaitingForShell: (() => void) | null = null; +let stopWaitingForDetail: (() => void) | null = null; + +/** + * `threads//?prompt=…` fills that thread's composer draft. The param is + * dropped as the link arrives, so the thread screen never sees it and a later visit cannot apply + * it again. The draft is written only once the thread's screen shows a thread that exists: a + * screen for an unknown thread replaces itself with a new-thread draft, which drops the link. + */ +export function takeThreadPromptLink( + state: PartialState | undefined, +): PartialState | undefined { + if (!state) return state; + const index = state.routes.findIndex((route) => route.name === "Thread"); + const route = state.routes[index]; + const params = route?.params as Readonly> | undefined; + const search = promptLinkSearchFromParams(params, THREAD_LINK_KEYS); + if (!route || !params || search === null) return state; + // A newer link replaces a waiting one even when it turns out to be invalid. + dropPendingLink(); + const { prompt: _prompt, ...rest } = params; + const routes = state.routes.slice(); + routes[index] = { ...route, params: rest }; + const nextState = { ...state, routes }; + + const environmentId = firstString(params.environmentId); + const threadId = firstString(params.threadId); + if (environmentId === null || threadId === null) return nextState; + const parsed = parseThreadLinkPrompt(search); + if (!parsed.ok) { + Dialog.alert("Could not open link", parsed.error); + return nextState; + } + if (parsed.value !== null) { + pendingLink = { + ref: { environmentId: EnvironmentId.make(environmentId), threadId: ThreadId.make(threadId) }, + prompt: parsed.value, + }; + } + return nextState; +} + +/** Wired to the navigation container's state changes. */ +export function resolvePendingThreadPromptLink(state: NavigationState | undefined): void { + if (pendingLink === null) return; + const { ref } = pendingLink; + // A subagent thread the environment's list leaves out gets a shell once the screen mounts its + // family. An archived thread never does; the screen shows it from its detail. + const shellAtom = environmentThreadShells.threadShellAtom(ref); + const detailAtom = environmentThreads.stateAtom(ref.environmentId, ref.threadId); + // Read detail only while the screen holds it: opening it here for an unknown thread would keep + // retrying with no screen to close it. + const screenHoldsDetail = appAtomRegistry.getNodes().has(detailAtom); + const step = stepPendingPromptLink(pendingLink, { + screenOnStack: + findStackRoute( + state, + "Thread", + (params) => + firstString(params?.environmentId) === ref.environmentId && + firstString(params?.threadId) === ref.threadId, + ) !== undefined, + ready: + appAtomRegistry.get(shellAtom) !== null || + (screenHoldsDetail && detailLoaded(appAtomRegistry.get(detailAtom))), + }); + if (step.pending === null) dropPendingLink(); + if (step.resolve !== null) { + const { prompt } = step.resolve; + void applyLinkedPromptToThreadDraft( + scopedThreadKey(ref.environmentId, ref.threadId), + prompt, + promptLinkNotice({ hiddenCharacters: prompt.hiddenCharacters }), + ); + } else if (step.pending !== null) { + stopWaitingForShell ??= appAtomRegistry.subscribe(shellAtom, (shell) => { + if (shell !== null) resolvePendingThreadPromptLink(rootNavigationRef.getRootState()); + }); + if (screenHoldsDetail) { + stopWaitingForDetail ??= appAtomRegistry.subscribe(detailAtom, (detail) => { + if (detailLoaded(detail)) resolvePendingThreadPromptLink(rootNavigationRef.getRootState()); + }); + } + } +} + +function detailLoaded( + result: AsyncResult.AsyncResult<{ readonly data: Option.Option }, unknown>, +): boolean { + return Option.isSome(Option.flatMap(AsyncResult.value(result), (state) => state.data)); +} + +function dropPendingLink(): void { + pendingLink = null; + stopWaitingForShell?.(); + stopWaitingForShell = null; + stopWaitingForDetail?.(); + stopWaitingForDetail = null; +} diff --git a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts deleted file mode 100644 index 4b31055ae2c94..0000000000000 --- a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts +++ /dev/null @@ -1,348 +0,0 @@ -import * as Dialog from "../../components/ui/Dialog"; -import { StackActions, useNavigation } from "@react-navigation/native"; -import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; -import { - findPromptLinkProjects, - findPromptLinkPullRequestThread, - resolvePromptLinkModelSelection, -} from "@t3tools/client-runtime/prompt-links"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { - parseNewThreadLink, - type LinkedPrompt, - type PromptLinkPullRequest, - type PromptLinkRepository, -} from "@t3tools/shared/promptLinks"; -import { useEffect, useEffectEvent, useRef } from "react"; - -import { scopedThreadKey } from "../../lib/scopedEntities"; -import { useServerConfigs, useThreadShells } from "../../state/entities"; -import { projectEnvironment } from "../../state/projects"; -import { composerPullRequests } from "../../state/pull-requests"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; -import { - createNewTaskDraft, - setComposerDraftText, - updateComposerDraftSettings, - waitForComposerDraftsLoaded, -} from "../../state/use-composer-drafts"; -import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; -import { useWorkspaceState } from "../../state/workspace"; -import { useNewTaskFlow } from "../threads/new-task-flow-provider"; -import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions"; -import { - clearedPromptLinkParams, - promptLinkNotice, - promptLinkRepositoryName, - latestThreadEnvironmentIds, - promptLinkScratchEnvironmentId, - promptLinkSearchFromParams, -} from "./prompt-link-model"; - -type LinkedDraftRequest = { - readonly prompt: LinkedPrompt | null; - readonly provider: string | null; - readonly model: string | null; - readonly pullRequest: PromptLinkPullRequest | null; -}; - -export function usePromptLinkResolver(params: Readonly> | undefined) { - const navigation = useNavigation(); - const flow = useNewTaskFlow(); - const { state: catalogState } = useWorkspaceState(); - const threads = useThreadShells(); - const serverConfigs = useServerConfigs(); - const { connectedEnvironments } = useRemoteConnectionStatus(); - const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); - const loadPullRequest = useAtomQueryRunner(composerPullRequests.detail, { - reportFailure: false, - }); - const loadLinkedThreads = useAtomQueryRunner(composerPullRequests.linkedThreadsUncached, { - reportFailure: false, - }); - const search = promptLinkSearchFromParams(params); - // Wait for cold-launch shells or existing repositories may be offered for cloning. - const waitingForShells = - catalogState.isLoadingConnections || - catalogState.hasPendingShellSnapshot || - // Connecting machines may hold the target; persistently offline ones must not block every link. - catalogState.connectingEnvironments.some( - (environment) => - environment.connectionState === "connecting" || environment.connectionError === null, - ); - - const writeDraft = ( - draftKey: string, - environmentId: EnvironmentId, - request: LinkedDraftRequest, - ): boolean => { - if (request.prompt) setComposerDraftText(draftKey, request.prompt.text); - if (request.provider === null) return false; - const resolved = resolvePromptLinkModelSelection( - serverConfigs.get(environmentId)?.providers ?? [], - { provider: request.provider, model: request.model }, - ); - if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection }); - return resolved?.exact !== true; - }; - - const openThread = ( - thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId }, - prompt: LinkedDraftRequest["prompt"], - ) => { - (navigation.getParent() ?? navigation).dispatch( - StackActions.replace("Thread", { - environmentId: String(thread.environmentId), - threadId: String(thread.id), - }), - ); - if (prompt) { - void applyLinkedPromptToThreadDraft( - scopedThreadKey(thread.environmentId, thread.id), - prompt, - promptLinkNotice({ hiddenCharacters: prompt.hiddenCharacters }), - ); - } - }; - - // A pull request's thread can be an older settled thread no shell holds, so ask every - // connected environment with the repository. A lookup on another one would wait forever. - const findServerPullRequestThread = async ( - projects: ReadonlyArray, - pullRequest: PromptLinkPullRequest, - ) => { - const connected = new Set( - connectedEnvironments - .filter((environment) => environment.connectionState === "connected") - .map((environment) => environment.environmentId), - ); - const lookups = projects - .filter((project) => connected.has(project.environmentId)) - .map(async (project) => { - const linked = await loadLinkedThreads({ - environmentId: project.environmentId, - input: { - projectId: project.id, - repository: promptLinkRepositoryName(pullRequest), - number: pullRequest.number, - }, - }); - const thread = - linked._tag === "Success" - ? linked.value.threads.find((candidate) => candidate.archivedAt === null) - : undefined; - return thread && { environmentId: project.environmentId, id: thread.id }; - }); - // The most recent project wins without waiting for the lookups after it. - for (const lookup of lookups) { - const thread = await lookup; - if (thread) return thread; - } - return undefined; - }; - - const openProjectDraft = async (project: EnvironmentProject, request: LinkedDraftRequest) => { - try { - await waitForComposerDraftsLoaded(); - } catch { - Dialog.alert( - "Could not open link", - "Drafts could not be loaded, so the prompt was not added.", - ); - return; - } - const draftKey = createNewTaskDraft({ - environmentId: project.environmentId, - projectId: project.id, - }); - let pullRequestUnavailable: number | null = null; - if (request.pullRequest) { - const detail = await loadPullRequest({ - environmentId: project.environmentId, - input: { - projectId: project.id, - repository: promptLinkRepositoryName(request.pullRequest), - number: request.pullRequest.number, - }, - }); - if (detail._tag === "Success") { - updateComposerDraftSettings(draftKey, { - workspaceSelection: { - mode: "worktree", - branch: detail.value.headBranch, - worktreePath: null, - startFromOrigin: false, - pullRequestReference: detail.value.url, - }, - }); - } else { - pullRequestUnavailable = request.pullRequest.number; - } - } - const modelUnavailable = writeDraft(draftKey, project.environmentId, request); - navigation.dispatch( - StackActions.push("Thread", { - draft: { - draftId: draftKey, - title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title, - }, - }), - ); - if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) { - showPromptLinkNotice( - promptLinkNotice({ - hiddenCharacters: request.prompt?.hiddenCharacters ?? 0, - modelUnavailable, - pullRequestUnavailable, - }), - ); - } - }; - - const openScratchDraft = async (request: LinkedDraftRequest) => { - const environmentId = promptLinkScratchEnvironmentId( - latestThreadEnvironmentIds(threads), - connectedEnvironments - .filter( - (environment) => - availableScratchWorkspaceRoot( - environment.connectionState, - serverConfigs.get(environment.environmentId), - ) !== null, - ) - .map((environment) => environment.environmentId), - ); - if (environmentId === null) { - Dialog.alert( - "Could not start without a project", - "No connected environment offers threads without a project.", - ); - return; - } - const result = await openScratch({ environmentId, input: {} }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Dialog.alert( - "Could not start without a project", - error instanceof Error - ? error.message - : "The folder for threads without a project could not be created.", - ); - } - return; - } - await openProjectDraft(result.value, { ...request, pullRequest: null }); - }; - - const cloneRepository = (repository: PromptLinkRepository, request: LinkedDraftRequest) => { - const environmentId = - flow.selectedEnvironmentId ?? connectedEnvironments[0]?.environmentId ?? null; - if (environmentId === null) { - Dialog.alert("Could not clone", "Connect an environment first."); - return; - } - const prompt = - request.pullRequest === null && - serverConfigs.get(environmentId)?.environment.capabilities.projectCloneTracking === true - ? request.prompt - : null; - const name = promptLinkRepositoryName(repository); - navigation.dispatch( - StackActions.push("AddProjectDestination", { - environmentId, - remoteUrl: `https://github.com/${name}.git`, - repositoryTitle: name, - repositoryName: repository.name, - ...(prompt ? { prompt: prompt.text } : {}), - }), - ); - if (!request.prompt) return; - showPromptLinkNotice( - [ - promptLinkNotice({ - hiddenCharacters: request.prompt.hiddenCharacters, - modelUnavailable: prompt !== null && request.provider !== null, - }), - prompt - ? "The prompt opens in a new task once the clone starts." - : "Open the link again after the clone to get the prompt.", - ].join("\n\n"), - ); - }; - - const resolveLink = useEffectEvent((linkSearch: string) => { - const parsed = parseNewThreadLink(linkSearch); - if (!parsed.ok) { - Dialog.alert("Could not open link", parsed.error); - return; - } - const link = parsed.value; - const request: LinkedDraftRequest = - link.kind === "new" - ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null } - : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest }; - - if (link.kind === "pull-request") { - const thread = findPromptLinkPullRequestThread(threads, link.pullRequest); - if (thread) { - openThread(thread, link.prompt); - return; - } - } - - const repository = link.kind === "new" ? link.repository : link.pullRequest; - if (repository === null) { - void openScratchDraft(request); - return; - } - const lastActivity = new Map(); - for (const thread of threads) { - const key = `${thread.environmentId}:${thread.projectId}`; - if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt); - } - const activityOf = (project: EnvironmentProject) => - lastActivity.get(`${project.environmentId}:${project.id}`) ?? ""; - const projects = findPromptLinkProjects( - flow.projectScopes - .flatMap((scope) => scope.projects) - .sort((left, right) => activityOf(right).localeCompare(activityOf(left))), - repository, - ); - const project = projects[0]; - if (project) { - const { pullRequest } = request; - void (async () => { - const thread = pullRequest && (await findServerPullRequestThread(projects, pullRequest)); - if (thread) openThread(thread, request.prompt); - else await openProjectDraft(project, request); - })(); - return; - } - Dialog.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [ - { text: "Cancel", style: "cancel" }, - ...(request.prompt - ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }] - : []), - { text: "Clone", onPress: () => cloneRepository(repository, request) }, - ]); - }); - - const handledSearchRef = useRef(null); - useEffect(() => { - if (search === null) { - handledSearchRef.current = null; - return; - } - if (waitingForShells || handledSearchRef.current === search) return; - handledSearchRef.current = search; - navigation.setParams(clearedPromptLinkParams()); - resolveLink(search); - }, [navigation, search, waitingForShells]); -} diff --git a/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts b/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts deleted file mode 100644 index f01f5c39643a3..0000000000000 --- a/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as Dialog from "../../components/ui/Dialog"; -import { useNavigation } from "@react-navigation/native"; -import { parseThreadLinkPrompt } from "@t3tools/shared/promptLinks"; -import { useEffect, useRef } from "react"; - -import { applyLinkedPromptToThreadDraft } from "./prompt-link-actions"; -import { - clearedPromptLinkParams, - promptLinkNotice, - promptLinkSearchFromParams, -} from "./prompt-link-model"; - -const THREAD_LINK_KEYS = ["prompt"]; - -export function useThreadPromptLink( - params: Readonly> | undefined, - threadDraftKey: string | null, -) { - const navigation = useNavigation(); - const search = promptLinkSearchFromParams(params, THREAD_LINK_KEYS); - const handledSearchRef = useRef(null); - useEffect(() => { - if (search === null) { - handledSearchRef.current = null; - return; - } - if (threadDraftKey === null || handledSearchRef.current === search) return; - handledSearchRef.current = search; - navigation.setParams(clearedPromptLinkParams(THREAD_LINK_KEYS)); - const parsed = parseThreadLinkPrompt(search); - if (!parsed.ok) { - Dialog.alert("Could not open link", parsed.error); - return; - } - if (parsed.value === null) return; - void applyLinkedPromptToThreadDraft( - threadDraftKey, - parsed.value, - promptLinkNotice({ hiddenCharacters: parsed.value.hiddenCharacters }), - ); - }, [navigation, search, threadDraftKey]); -} diff --git a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx index 9443eb0ac5a73..1403ee8e8d4c2 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx @@ -40,18 +40,14 @@ export function PullRequestDescription(props: { attachments: content.attachments.filter((attachment) => !isBotBadgeImage(attachment.uri)), }; }, [props.markdown]); - const gallery = useMemo( - () => - content.attachments - .filter((attachment) => attachment.kind === "image") - .map((attachment) => { - return { - ...pullRequestImageSource(attachment.uri, mediaContext), - name: new URL(attachment.uri).pathname.split("/").pop() || "Image", - }; - }), - [content, mediaContext], - ); + const gallery: FilePreviewItem[] = content.attachments + .filter((attachment) => attachment.kind === "image") + .map((attachment) => { + return { + ...pullRequestImageSource(attachment.uri, mediaContext), + name: new URL(attachment.uri).pathname.split("/").pop() || "Image", + }; + }); const canCollapse = bodyHeight > COLLAPSED_HEIGHT; const collapsed = canCollapse && !expanded; return ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestFilterMenuContext.tsx b/apps/mobile/src/features/pull-requests/PullRequestFilterMenuContext.tsx deleted file mode 100644 index 68a423f80aa0d..0000000000000 --- a/apps/mobile/src/features/pull-requests/PullRequestFilterMenuContext.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { MenuAction } from "@react-native-menu/menu"; -import { - createContext, - use, - useLayoutEffect, - useState, - type Dispatch, - type ReactNode, - type SetStateAction, -} from "react"; - -export interface PullRequestFilterMenu { - readonly actions: readonly MenuAction[]; - readonly onSelect: (id: string) => void; - /** Whether any filter, the environment, or a non-default sort is active. */ - readonly customized: boolean; -} - -const MenuContext = createContext(null); -const PublishContext = createContext> | null>( - null, -); - -/** - * The Android PR header opens the full filter menu, but the facets (authors, labels) and the - * controls live in the list below it. The list publishes its menu here; only the header reads - * it, so the list's own renders don't fan out. - */ -export function PullRequestFilterMenuProvider(props: { readonly children: ReactNode }) { - const [menu, setMenu] = useState(null); - return ( - - {props.children} - - ); -} - -export function usePullRequestFilterMenu() { - return use(MenuContext); -} - -export function usePublishPullRequestFilterMenu(menu: PullRequestFilterMenu | null) { - const publish = use(PublishContext); - // A layout effect, so the header's filter button lands in the same frame as the list. There is - // no cleanup: the list lives exactly as long as this provider, and a hidden tab runs its - // cleanups, which would drop the button for a few frames every time the tab came back. - useLayoutEffect(() => { - if (publish && menu) publish(menu); - }, [publish, menu]); -} diff --git a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index d7359680d3f1a..38ac98f3b2a84 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -1,10 +1,12 @@ +import { useAtomValue } from "@effect/atom-react"; import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; -import { useMemo } from "react"; +import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; import { Platform, ScrollView } from "react-native"; import type { MenuProps } from "../../components/ui/Menu.types"; import { ListFilterMenu } from "../workspace/ListFilterMenu"; -import { usePublishPullRequestFilterMenu } from "./PullRequestFilterMenuContext"; -import { useProjects, useThreadShells } from "../../state/entities"; +import { environmentProjects } from "../../state/projects"; +import { environmentThreadShells } from "../../state/threads"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { buildHomeProjectScopes, sortHomeProjectScopes } from "../home/homeThreadList"; import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList"; @@ -39,32 +41,58 @@ function choices(field: string, options: Readonly>, selec })); } +type ProjectOrder = readonly { readonly memberProjectRefs: readonly ScopedProjectRef[] }[]; +const sameProjectOrder = (previous: ProjectOrder, next: ProjectOrder) => + previous.length === next.length && + previous.every((group, index) => { + const members = next[index]!.memberProjectRefs; + return ( + group.memberProjectRefs.length === members.length && + group.memberProjectRefs.every( + (ref, member) => + ref.environmentId === members[member]!.environmentId && + ref.projectId === members[member]!.projectId, + ) + ); + }); + +// Thread shells change on every agent tick, but the sidebar order they decide rarely does. The +// equality check keeps the list screen, which builds these filters, from re-rendering on each tick. +const sidebarProjectOrderFamily = Atom.family((mode: SidebarProjectGroupingMode) => + Atom.make((get) => + sortHomeProjectScopes({ + scopes: buildHomeProjectScopes({ + projects: get(environmentProjects.projectsAtom), + environmentId: null, + projectGroupingMode: mode, + }), + threads: get(environmentThreadShells.threadShellsAtom), + pendingTasks: [], + projectSortOrder: "updated_at", + }).map((scope) => ({ memberProjectRefs: scope.projectRefs })), + ).pipe(Atom.withEquality(sameProjectOrder)), +); + function useProjectsInSidebarOrder( projects: readonly Project[], ) { - const allProjects = useProjects(); - const threads = useThreadShells(); const { sidebarProjectGroupingMode } = useMobileProjectGroupingSettings(); - const scopes = useMemo( - () => - sortHomeProjectScopes({ - scopes: buildHomeProjectScopes({ - projects: allProjects, - environmentId: null, - projectGroupingMode: sidebarProjectGroupingMode, - }), - threads, - pendingTasks: [], - projectSortOrder: "updated_at", - }).map((scope) => ({ memberProjectRefs: scope.projectRefs })), - [allProjects, sidebarProjectGroupingMode, threads], - ); - return useMemo(() => sortProjectsByGroupOrder(projects, scopes), [projects, scopes]); + const scopes = useAtomValue(sidebarProjectOrderFamily(sidebarProjectGroupingMode)); + return sortProjectsByGroupOrder(projects, scopes); } -export function PullRequestListFilters(props: { - /** Android sidebar rows sit further in than full-width rows; the pills follow them. */ - readonly sidebar?: boolean; +export interface PullRequestFilterMenu { + readonly actions: MenuProps["actions"]; + readonly onSelect: (id: string) => void; + /** Whether any filter, the environment, or a non-default sort is active. */ + readonly customized: boolean; +} + +/** + * Builds the filter pills and, on Android, the header's full filter menu. The list screen owns + * the call so both the pills and the header it frames read the same menu. + */ +export function usePullRequestListFilters(props: { readonly environments: readonly { id: string; label: string }[]; readonly environmentId: string | null; readonly onEnvironmentChange: (id: string | null) => void; @@ -280,7 +308,7 @@ export function PullRequestListFilters(props: { ]; const customized = count > 0 || value.sort !== "updated" || props.environmentId !== null; // The Android header's filter button opens every filter. - const headerMenu = + const headerMenu: PullRequestFilterMenu | null = Platform.OS === "android" ? { actions: [ @@ -296,7 +324,35 @@ export function PullRequestListFilters(props: { customized, } : null; - usePublishPullRequestFilterMenu(headerMenu); + return { + headerMenu, + select, + count, + more, + reset, + environmentLabel: + props.environments.length > 1 + ? (props.environments.find((environment) => environment.id === props.environmentId) + ?.label ?? "All environments") + : null, + environmentActions, + state: value.state, + stateActions, + involvement: value.involvement, + involvementActions, + selectedLabelCount: selectedLabels.length, + labelActions, + author, + authorActions, + }; +} + +export function PullRequestListFilters(props: { + /** Android sidebar rows sit further in than full-width rows; the pills follow them. */ + readonly sidebar?: boolean; + readonly filters: ReturnType; +}) { + const { count, more, reset, select, author, selectedLabelCount } = props.filters; return ( )} - {props.environments.length > 1 ? ( + {props.filters.environmentLabel !== null ? ( environment.id === props.environmentId) - ?.label ?? "All environments" - } + label={props.filters.environmentLabel} accessibilityLabel="Choose pull request environment" - actions={environmentActions} + actions={props.filters.environmentActions} onSelect={select} /> ) : null} 0} + label={selectedLabelCount ? `Labels ${selectedLabelCount}` : "Labels"} + active={selectedLabelCount > 0} accessibilityLabel="Filter by label" - actions={labelActions} + actions={props.filters.labelActions} onSelect={select} /> diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index 93615a47a7deb..63946016d591f 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -9,7 +9,7 @@ import { usePullRequestMediaUrl, } from "./PullRequestMedia"; import { videoMimeType } from "@t3tools/shared/video"; -import { createContext, useContext, useCallback, useMemo, useState } from "react"; +import { createContext, useContext, useMemo, useState } from "react"; import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; import { linkReferenceAutolinks } from "./pullRequestAutolinks"; @@ -40,23 +40,17 @@ export function PullRequestMarkdownRepository(props: { }) { const provider = props.changeRequest?.provider; const url = props.changeRequest?.url; - const autolinks = useMemo( - () => - provider === undefined || url === undefined - ? null - : changeRequestReferenceAutolinker({ provider, url }), - [provider, url], - ); + const autolinks = + provider === undefined || url === undefined + ? null + : changeRequestReferenceAutolinker({ provider, url }); const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, projectId: target.input.projectId, }); const cwd = project?.workspaceRoot; - const media = useMemo( - () => (cwd ? { environmentId: target.environmentId, cwd } : null), - [target.environmentId, cwd], - ); + const media = cwd ? { environmentId: target.environmentId, cwd } : null; return ( {props.children} @@ -119,32 +113,23 @@ function PullRequestMarkdownContent(props: { }) { const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); - const markdown = useMemo( - () => linkReferenceAutolinks(props.markdown, autolinks), - [props.markdown, autolinks], - ); + const markdown = linkReferenceAutolinks(props.markdown, autolinks); const openPullRequest = useOpenPullRequest(); - const openLink = useCallback( - (href: string) => { - void openPullRequest(href); - }, - [openPullRequest], - ); - const gallery = useMemo( - () => - markdownImageSources(props.imageGroupMarkdown ?? props.markdown) - .filter( - (uri) => - /^https?:\/\//i.test(uri) && - videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null, - ) - .map((uri) => pullRequestImageSource(uri, mediaContext)), - [props.markdown, props.imageGroupMarkdown, mediaContext], - ); + const openLink = (href: string) => { + void openPullRequest(href); + }; + const gallery: FilePreviewItem[] = markdownImageSources( + props.imageGroupMarkdown ?? props.markdown, + ) + .filter( + (uri) => + /^https?:\/\//i.test(uri) && + videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null, + ) + .map((uri) => pullRequestImageSource(uri, mediaContext)); const [preview, setPreview] = useState(null); - const renderImage = useCallback( - (image) => , - [], + const renderImage: MarkdownImageRenderer = (image) => ( + ); const styles = useMarkdownPreviewStyles(); return ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx index 58590229fbf67..0665f045b8465 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useMemo, useState } from "react"; +import { createContext, useContext, useState } from "react"; import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; @@ -32,13 +32,10 @@ export const PullRequestMediaContext = createContext<{ export function usePullRequestMediaUrl(url: string) { const context = useContext(PullRequestMediaContext); const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl; - const resource = useMemo( - () => - context && fetchUrl - ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } - : null, - [context, fetchUrl], - ); + const resource: AssetResource | null = + context && fetchUrl + ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } + : null; const asset = useAssetUrlState(context?.environmentId ?? null, resource); const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource); return { diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx index 1fa5cd83b874f..829a62d4b4b7f 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx @@ -7,7 +7,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import type { PullRequestReviewerCandidate } from "@t3tools/contracts"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { FlatList, Platform, @@ -86,13 +86,14 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps Number(b.isRequested) - Number(a.isRequested) || a.login.localeCompare(b.login), ); })(); - useEffect(() => { - if (candidates.length === 0) return; + // A new search shows its best matches from the top. + const search = (next: string) => { + setQuery(next); listRef.current?.scrollToOffset({ offset: Platform.OS === "ios" ? -insets.top : 0, animated: false, }); - }, [candidates, insets.top]); + }; const update = async ( candidate: Pick, ) => { @@ -118,7 +119,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps setQuery(event.nativeEvent.text), - onCancelButtonPress: () => setQuery(""), + onChangeText: (event: { nativeEvent: { text: string } }) => search(event.nativeEvent.text), + onCancelButtonPress: () => search(""), hideNavigationBar: false, hideWhenScrolling: false, obscureBackground: false, @@ -150,7 +151,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps @@ -287,7 +288,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps({ tab: "overview", - offset: -insets.top, + offset: -barInsets.top, }); const bodyY = useRef(0); const [headerHeight, setHeaderHeight] = useState(0); @@ -386,14 +389,15 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { scrollViews.current.overview?.scrollToOffset({ - offset: bodyY.current + filesY.current - insets.top, + offset: bodyY.current + filesY.current - barInsets.top, animated: true, }); pendingFilesJump.current = false; @@ -675,7 +679,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps {tab === "overview" && identity ? ( + {pr ? ( subscribePullRequestFileJump(jumpScope, (path) => jumpToFile(path)), [jumpScope]); const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences(); @@ -1029,6 +1042,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { canHighlight: true, }); const [NativeDiffView] = useState(() => resolveNativeReviewDiffView()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- requests the next page of the paged diff query until the jump target file has loaded useEffect(() => { if (!targetFile) loadMore(); }, [targetFile, loadMore]); diff --git a/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx b/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx index f2c3f205ad50b..6ba565239dcb2 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx @@ -1,5 +1,5 @@ -import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { useCallback, useEffect, useRef } from "react"; +import { useNavigation } from "@react-navigation/native"; +import { useEffect, useRef } from "react"; import { BackHandler, Keyboard, View, type TextInputInstance } from "react-native"; import { AndroidHeaderIconButton } from "../../components/ui/AndroidScreenHeader"; import { AndroidAnchoredMenu } from "../../components/ui/AndroidAnchoredMenu"; @@ -8,22 +8,23 @@ import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { WorkspaceListToolbarRow } from "../workspace/WorkspaceListToolbarRow"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { usePullRequestFilterMenu } from "./PullRequestFilterMenuContext"; +import type { PullRequestFilterMenu } from "./PullRequestListFilters"; import { openSettings } from "../settings/settings-navigation"; /** PR search shares the thread list's Android header row. */ -export function PullRequestToolbar(props: { readonly onRequestVisibility?: () => void }) { +export function PullRequestToolbar(props: { + readonly filters: PullRequestFilterMenu | null; + readonly onRequestVisibility?: () => void; +}) { const navigation = useNavigation(); - const filters = usePullRequestFilterMenu(); + const { filters } = props; const workspace = useWorkspaceTabs()!; const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace; - const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home"); - const listFocused = homeFocused && workspace.state.selectedTab === "pullRequests"; const { onRequestVisibility } = props; const tab = workspace.state.tabs.pullRequests; const input = useRef(null); const searching = tab.searchMode !== "resting"; - const openSearch = useCallback(() => { + const openSearch = () => { onRequestVisibility?.(); dispatch({ type: "scroll", @@ -33,17 +34,19 @@ export function PullRequestToolbar(props: { readonly onRequestVisibility?: () => dispatch({ type: "search", tab: "pullRequests" }); input.current?.focus(); return true; - }, [dispatch, getScrollOffset, onRequestVisibility]); + }; useHardwareKeyboardCommand("focusSearch", openSearch); - const closeSearch = useCallback(() => { + const closeSearch = () => { Keyboard.dismiss(); recordScrollOffset("pullRequests", tab.searchEntryOffset); dispatch({ type: "cancel", tab: "pullRequests" }); - }, [dispatch, recordScrollOffset, tab.searchEntryOffset]); + }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to BackHandler while searching to close the PR search on Android back useEffect(() => { - if (!searching || !listFocused) return; + if (!searching) return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - // Frozen Home screens can keep effects alive after a detail route opens. + // Frozen Home screens stay mounted under a detail route or another tab, + // so back only applies while the PR list is in front. const state = navigation.getState(); if (!state || state.routes[state.index]?.name !== "Home" || !isTabActive("pullRequests")) return false; @@ -52,7 +55,7 @@ export function PullRequestToolbar(props: { readonly onRequestVisibility?: () => return true; }); return () => subscription.remove(); - }, [searching, closeSearch, listFocused, navigation, isTabActive]); + }, [searching, closeSearch, navigation, isTabActive]); const { paddingTop, paddingBottom } = useToolbarLayout(); return ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index 1554c184d3906..b5fb14b198ab4 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -6,7 +6,7 @@ import { import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { useNavigation } from "@react-navigation/native"; -import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { use, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useAtomValue } from "@effect/atom-react"; import { Keyboard, @@ -22,11 +22,16 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { useWorkspaceState } from "../../state/workspace"; import { useProjects } from "../../state/entities"; import { environmentServerConfigsAtom } from "../../state/server"; import { PullRequestListRow } from "./PullRequestListRow"; -import { PullRequestListFilters } from "./PullRequestListFilters"; +import { + PullRequestListFilters, + usePullRequestListFilters, + type PullRequestFilterMenu, +} from "./PullRequestListFilters"; import { PullRequestCardRow } from "./PullRequestControls"; import { usePullRequestList } from "./usePullRequestList"; import { usePullRequestListControls } from "./usePullRequestListControls"; @@ -79,6 +84,11 @@ export function PullRequestsScreen( props: { readonly sidebar?: boolean; readonly selectedPullRequestKey?: string | null; + /** + * Places the list under a header that opens the full filter menu (the Android toolbar). The + * filters live here, so the header gets them in the same render as the list. + */ + readonly renderFrame?: (filters: PullRequestFilterMenu | null, list: ReactNode) => ReactNode; } = {}, ) { const navigation = useNavigation(); @@ -87,16 +97,32 @@ export function PullRequestsScreen( const { environments } = useWorkspaceState(); const configs = useAtomValue(environmentServerConfigsAtom); const insets = useSafeAreaInsets(); - const [initialOffset] = useState(() => workspace.getScrollOffset("pullRequests")); + // UIKit insets the list by its column's safe area, which this context can report as 0. + const columnSafeBottom = useNativeColumnLayoutMetrics()?.safeArea.bottom; + const bottomSafeArea = + Platform.OS === "ios" ? (columnSafeBottom ?? insets.bottom) : insets.bottom; const listRef = useRef(null); const tab = workspace.state.tabs.pullRequests; - const maintainVisiblePosition = useMemo( - () => ({ - data: tab.searchMode === "resting", - shouldRestorePosition: () => workspace.getScrollOffset("pullRequests") > 0, - }), - [tab.searchMode, workspace], - ); + const [initialScrollOffset] = useState(() => workspace.getScrollOffset("pullRequests")); + // Cancelling search restores the offset recorded when search opened. The + // toolbar that cancels has no handle on this list, so the list scrolls itself + // once it sees the tab come back to resting. + const previousSearchMode = useRef(tab.searchMode); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the list to the offset saved when search opened once the tab returns to resting + useEffect(() => { + const restoringSearch = + previousSearchMode.current !== "resting" && tab.searchMode === "resting"; + previousSearchMode.current = tab.searchMode; + if (restoringSearch) + void listRef.current?.scrollToOffset({ + offset: tab.scrollOffset - (Platform.OS === "ios" ? insets.top : 0), + animated: false, + }); + }, [tab.searchMode, tab.scrollOffset, insets.top]); + const maintainVisiblePosition = { + data: tab.searchMode === "resting", + shouldRestorePosition: () => workspace.getScrollOffset("pullRequests") > 0, + }; const debouncedQuery = useDebouncedValue(tab.query.trim(), 180); const query = tab.searchMode === "resting" ? "" : debouncedQuery; const typedQuery = tab.searchMode === "resting" ? "" : tab.query; @@ -107,21 +133,18 @@ export function PullRequestsScreen( ? selectedEnvironmentId : null; const selectDetail = useSelectWorkspaceDetail(); - const selectPullRequest = useCallback( - (item: EnvironmentPullRequestListEntry) => { - Keyboard.dismiss(); - const params = { - environmentId: String(item.environmentId), - projectId: String(item.projectId), - repository: item.repository, - number: item.number, - host: item.host, - initialSummary: item, - }; - selectDetail({ name: "PullRequest", params }); - }, - [selectDetail], - ); + const selectPullRequest = (item: EnvironmentPullRequestListEntry) => { + Keyboard.dismiss(); + const params = { + environmentId: String(item.environmentId), + projectId: String(item.projectId), + repository: item.repository, + number: item.number, + host: item.host, + initialSummary: item, + }; + selectDetail({ name: "PullRequest", params }); + }; const allProjects = useProjects(); const savedControls = listControls.controlsFor(environmentId); const controls = useMemo( @@ -133,8 +156,7 @@ export function PullRequestsScreen( environments .filter( (environment) => - configs.get(environment.environmentId)?.environment.capabilities.pullRequests === - true && + configs.get(environment.environmentId) !== undefined && (environmentId === null || environment.environmentId === environmentId) && (!controls.projectEnvironmentId || environment.environmentId === controls.projectEnvironmentId), @@ -173,7 +195,7 @@ export function PullRequestsScreen( ), [result.entries, controls, query, result.data, connectedKey], ); - const facets = useMemo(() => pullRequestListFacets(result.entries), [result.entries]); + const facets = pullRequestListFacets(result.entries); const [knownFacets, setKnownFacets] = useState({ environmentId, source: facets, ...facets }); if (knownFacets.environmentId !== environmentId || knownFacets.source !== facets) { setKnownFacets((previous) => ({ @@ -203,33 +225,51 @@ export function PullRequestsScreen( (project) => !project.isScratch && (environmentId === null || project.environmentId === environmentId), ); - const renderPullRequest = useCallback( - ({ item }: { readonly item: EnvironmentPullRequestListEntry }) => ( - - ), - [selectPullRequest, selectedKey, actions, select, busyKey, sidebar], - ); - const previousSearchMode = useRef(tab.searchMode); - useEffect(() => { - const restoringSearch = - previousSearchMode.current !== "resting" && tab.searchMode === "resting"; - previousSearchMode.current = tab.searchMode; - if (restoringSearch) + const filters = usePullRequestListFilters({ + environments: environments.map((item) => ({ + id: String(item.environmentId), + label: item.environmentLabel, + })), + environmentId, + onEnvironmentChange: listControls.setEnvironmentId, + value: typedDraft + ? { ...controls, filters: { ...controls.filters, draft: typedDraft } } + : controls, + onChange: (next) => { + const update = applyPullRequestDraftFilterChange(typedQuery, controls.filters, next.filters); + listControls.setControls(environmentId, { ...next, filters: update.filters }); + if (update.query !== typedQuery) + workspace.dispatch({ type: "query", tab: "pullRequests", query: update.query }); void listRef.current?.scrollToOffset({ - offset: tab.scrollOffset - (Platform.OS === "ios" ? insets.top : 0), + offset: Platform.OS === "ios" ? -insets.top : 0, animated: false, }); - }, [tab.searchMode, tab.scrollOffset, insets.top]); - - return ( + }, + authors: knownFacets.environmentId === environmentId ? knownFacets.authors : facets.authors, + labels: knownFacets.environmentId === environmentId ? knownFacets.labels : facets.labels, + labelColors, + projects, + onSearch: (kind) => + navigation.navigate("PullRequestFilterSearch", { + kind, + environmentId, + environmentIds: targets.map((target) => String(target.environmentId)), + suggestions: knownFacets.environmentId === environmentId ? knownFacets[kind] : facets[kind], + labelColors, + }), + }); + const renderPullRequest = ({ item }: { readonly item: EnvironmentPullRequestListEntry }) => ( + + ); + const list = ( 0 ? initialOffset - (Platform.OS === "ios" ? insets.top : 0) : undefined + initialScrollOffset > 0 + ? initialScrollOffset - (Platform.OS === "ios" ? insets.top : 0) + : undefined } contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} contentContainerStyle={{ paddingBottom: - Math.max(insets.bottom, workspace.bottomClearance) - - (Platform.OS === "ios" ? insets.bottom : 0), + Math.max(bottomSafeArea, workspace.bottomClearance) - + (Platform.OS === "ios" ? bottomSafeArea : 0), }} onScroll={(event) => { workspace.recordScrollOffset( @@ -285,52 +327,7 @@ export function PullRequestsScreen( renderItem={renderPullRequest} ListHeaderComponent={ <> - ({ - id: String(item.environmentId), - label: item.environmentLabel, - }))} - environmentId={environmentId} - onEnvironmentChange={listControls.setEnvironmentId} - value={ - typedDraft - ? { ...controls, filters: { ...controls.filters, draft: typedDraft } } - : controls - } - onChange={(next) => { - const update = applyPullRequestDraftFilterChange( - typedQuery, - controls.filters, - next.filters, - ); - listControls.setControls(environmentId, { ...next, filters: update.filters }); - if (update.query !== typedQuery) - workspace.dispatch({ type: "query", tab: "pullRequests", query: update.query }); - void listRef.current?.scrollToOffset({ - offset: Platform.OS === "ios" ? -insets.top : 0, - animated: false, - }); - }} - authors={ - knownFacets.environmentId === environmentId ? knownFacets.authors : facets.authors - } - labels={ - knownFacets.environmentId === environmentId ? knownFacets.labels : facets.labels - } - labelColors={labelColors} - projects={projects} - onSearch={(kind) => - navigation.navigate("PullRequestFilterSearch", { - kind, - environmentId, - environmentIds: targets.map((target) => String(target.environmentId)), - suggestions: - knownFacets.environmentId === environmentId ? knownFacets[kind] : facets[kind], - labelColors, - }) - } - /> + {result.data?.errors.length ? ( ); + return props.renderFrame ? props.renderFrame(filters.headerMenu, list) : list; } diff --git a/apps/mobile/src/features/pull-requests/SourceControlAppAccessNotice.tsx b/apps/mobile/src/features/pull-requests/SourceControlAppAccessNotice.tsx new file mode 100644 index 0000000000000..d8b0da765cb1a --- /dev/null +++ b/apps/mobile/src/features/pull-requests/SourceControlAppAccessNotice.tsx @@ -0,0 +1,59 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { + readHostCredentialSettings, + sourceControlClients, +} from "@t3tools/client-runtime/source-control-clients"; +import { Linking, Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/ui/AppText"; +import { useEnvironmentPresentation } from "../../state/presentation"; +import { useEnvironmentQuery } from "../../state/query"; +import { sourceControlEnvironment } from "../../state/sourceControl"; + +/** + * Says when the server's forge app sign-in cannot reach `repository`, so requests there fall back + * to the user's own login. Renders nothing for servers that do not use the app. + */ +export function SourceControlAppAccessNotice(props: { + readonly environmentId: string; + readonly provider: string | undefined; + readonly host: string | undefined; + /** `owner/name`. */ + readonly repository: string; +}) { + const environmentId = EnvironmentId.make(props.environmentId); + const { presentation } = useEnvironmentPresentation(environmentId); + const definition = props.provider ? sourceControlClients.find(props.provider) : undefined; + const appSignIn = definition?.credentials?.appSignIn; + const host = (props.host ?? definition?.publicHost ?? "").toLowerCase(); + const settings = presentation?.serverConfig?.settings; + const signedIn = + definition !== undefined && + settings !== undefined && + (readHostCredentialSettings(settings, definition.kind).signIns[host] ?? "").length > 0; + const access = useEnvironmentQuery( + definition !== undefined && appSignIn?.hosts.includes(host) && signedIn + ? sourceControlEnvironment.appAccess({ + environmentId, + input: { provider: definition.kind, host, repository: props.repository }, + }) + : null, + ); + if (appSignIn === undefined || access.data?.status !== "not-covered") return null; + const owner = props.repository.split("/")[0] ?? props.repository; + + return ( + + + {appSignIn.appName} isn't installed on {owner}, so requests here use your own GitHub login + and its lower rate limit. + + void Linking.openURL(appSignIn.installUrl).catch(() => undefined)} + > + Install on {owner} + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx index fe5247b51d4aa..d198ea5b09e07 100644 --- a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx +++ b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx @@ -113,6 +113,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps !loadedPaths.has(path)); const { error: diffError, loadMore } = diff; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- requests the next page of the paged diff query until the stop's files have loaded useEffect(() => { if (state.enabled && missing && !diffError) loadMore(); }, [state.enabled, missing, diffError, loadMore]); diff --git a/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts b/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts index c219513a36689..2234a31081802 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts @@ -3,15 +3,29 @@ import { describe, expect, it } from "vite-plus/test"; import { allowedMergeMethods, canPerformPullRequestAction, - mergeSummary, mergeUnavailableReason, pullRequestActionLabel, - ALL_PR_ACTIONS, } from "./pullRequestActions"; const GITHUB = sourceControlClients.get("github").changeRequest; const GITLAB = sourceControlClients.get("gitlab").changeRequest; +import type { PullRequestAction } from "@t3tools/contracts"; + +const ALL_PR_ACTIONS: readonly PullRequestAction[] = [ + "merge", + "ready", + "draft", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + "approve-workflows", + "close", + "reopen", + "revert", + "delete-branch", +]; + type Detail = Parameters[0]; function detail(overrides: Partial = {}): Detail { @@ -49,7 +63,6 @@ describe("mobile pull request actions", () => { const pr = detail({ mergeability: "conflicting" }); expect(canPerformPullRequestAction(pr, "merge")).toBe(false); expect(mergeUnavailableReason(pr, true, GITHUB)).toContain("conflicts"); - expect(mergeSummary(pr, true, GITHUB)).toBe("Has conflicts"); }); it("names the change request with its host's term", () => { diff --git a/apps/mobile/src/features/pull-requests/pullRequestActions.ts b/apps/mobile/src/features/pull-requests/pullRequestActions.ts index f6d27d5c29316..6983266602d6a 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActions.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActions.ts @@ -79,20 +79,6 @@ export const PR_ACTION_ICONS = { "approve-workflows": "checkmark", } as const satisfies Record; -export const ALL_PR_ACTIONS: readonly PullRequestAction[] = [ - "merge", - "ready", - "draft", - "update-branch", - "enable-auto-merge", - "disable-auto-merge", - "approve-workflows", - "close", - "reopen", - "revert", - "delete-branch", -]; - export function allowedMergeMethods(pr: ActionDetail) { return pr.capabilities.mergeMethods.filter((method) => pr.mergeCapabilities[method]); } @@ -151,23 +137,5 @@ export function mergeUnavailableReason( return null; } -export function mergeSummary( - pr: ActionDetail & Pick, - connectionAllowsWrites: boolean, - terminology: ChangeRequestTerminology, -) { - if (pr.state === "merged") return "Merged"; - if (pr.state === "closed") return "Closed"; - if (pr.isDraft) return "Draft"; - if (pr.mergeability === "conflicting") return "Has conflicts"; - if (!connectionAllowsWrites) return "Read-only connection"; - const allowed = allowedMergeMethods(pr); - const method = pr.autoMergeMethod ?? (allowed.includes("squash") ? "squash" : allowed[0]); - const methodLabel = method ? `${method[0]?.toUpperCase()}${method.slice(1)}` : "Merge"; - if (pr.autoMergeEnabled) return `${methodLabel} · auto-merge on`; - if (mergeUnavailableReason(pr, connectionAllowsWrites, terminology)) return "Unavailable"; - return `${methodLabel}${pr.autoMergeEnabled === false ? " · auto-merge off" : ""}`; -} - export const openOnProviderLabel = (provider: SourceControlProviderKind) => `Open on ${sourceControlClients.get(provider).label}`; diff --git a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.test.ts b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.test.ts index af19f3aa9c3fd..aef8ebc1b3585 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.test.ts @@ -57,4 +57,19 @@ describe("mobile activity filters", () => { .activity.reviewThreads, ).toEqual([]); }); + + it("keeps bot review verdicts when bot comments are hidden", () => { + const result = filterPullRequestActivity( + { + ...activity, + comments: [ + comment("approved", bot, "APPROVED"), + comment("changes", bot, "CHANGES_REQUESTED"), + comment("commented", bot, "COMMENTED"), + ], + }, + DEFAULT_ACTIVITY_FILTER, + ); + expect(result.activity.comments.map((entry) => entry.id)).toEqual(["approved", "changes"]); + }); }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts index 85b50b2fcf63c..9567934b25a7f 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts @@ -18,6 +18,12 @@ function isBot(actor: PullRequestActor | null) { return actor?.isBot === true; } +// A bot's approval or change request is a verdict, not chatter. +function isVerdict(reviewState: string | null) { + const state = reviewState?.trim().toUpperCase().replaceAll("-", "_"); + return state === "APPROVED" || state === "CHANGES_REQUESTED"; +} + function eventCount(activity: PullRequestActivity) { const discussionIds = new Set( activity.reviewThreads.flatMap((thread) => thread.comments.map((comment) => comment.id)), @@ -50,7 +56,7 @@ export function filterPullRequestActivity(activity: PullRequestActivity, filter: : activity.comments.filter( (comment) => !allDiscussionIds.has(comment.id) && - (filter.bots || !isBot(comment.author)) && + (filter.bots || !isBot(comment.author) || isVerdict(comment.reviewState)) && (filter.resolved || comment.reviewState?.trim().toLowerCase() !== "dismissed"), ); const filtered = { diff --git a/apps/mobile/src/features/pull-requests/pullRequestFilesViewed.test.ts b/apps/mobile/src/features/pull-requests/pullRequestFilesViewed.test.ts new file mode 100644 index 0000000000000..ee438e6187215 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestFilesViewed.test.ts @@ -0,0 +1,24 @@ +import type { PullRequestFilesViewedResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { viewedOverlay } from "./pullRequestFilesViewed"; + +// The overlay compares reads by identity, so each call is a distinct read. +const read = (): PullRequestFilesViewedResult => ({ files: [], truncated: false }); + +describe("viewedOverlay", () => { + it("keeps a press while its write is in flight", () => { + const before = read(); + expect( + viewedOverlay({ "a.ts": { viewed: true, version: 1, awaitingReadAfter: undefined } }, before), + ).toEqual({ "a.ts": true }); + }); + + it("keeps a landed press until a read newer than the one current at the write", () => { + const before = read(); + const presses = { + "a.ts": { viewed: true, version: 1, awaitingReadAfter: { read: before } }, + }; + expect(viewedOverlay(presses, before)).toEqual({ "a.ts": true }); + expect(viewedOverlay(presses, read())).toEqual({}); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestFilesViewed.ts b/apps/mobile/src/features/pull-requests/pullRequestFilesViewed.ts new file mode 100644 index 0000000000000..b76d3193c9c8e --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestFilesViewed.ts @@ -0,0 +1,28 @@ +import type { PullRequestFilesViewedResult } from "@t3tools/contracts"; + +export type FilesViewedRead = PullRequestFilesViewedResult | null; + +export interface ViewedPress { + readonly viewed: boolean; + readonly version: number; + /** Set once the write lands: the press yields to the server once a read newer than this lands. */ + readonly awaitingReadAfter: { readonly read: FilesViewedRead } | undefined; +} + +export interface ViewedState { + readonly key: string; + readonly presses: Readonly>; + readonly error: string | null; +} + +/** The pressed states the server's read has not caught up with yet. */ +export function viewedOverlay( + presses: Readonly>, + read: FilesViewedRead, +): Record { + const overlay: Record = {}; + for (const [path, press] of Object.entries(presses)) + if (press.awaitingReadAfter === undefined || press.awaitingReadAfter.read === read) + overlay[path] = press.viewed; + return overlay; +} diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts index 7ac4f7f1643a4..f7b1a7a1bf832 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts @@ -8,6 +8,7 @@ import { import { appendPullRequestListPage, DEFAULT_PR_LIST_CONTROLS, + EMPTY_PULL_REQUEST_STATS_CACHE, filterPullRequestList, mergePullRequestListEntries, mergeEnvironmentPullRequestPages, @@ -21,6 +22,8 @@ import { scopedPullRequestListKey, shownPullRequestListPages, pullRequestListRowsAreEqual, + withAnsweredPullRequestStats, + withListedPullRequests, withShownPullRequestStats, withoutScratchProjectScope, } from "./pullRequestList"; @@ -435,3 +438,37 @@ describe("shown PR diff stats", () => { expect(filled[1]).toBe(rows[1]); }); }); + +describe("cached PR diff stats", () => { + const stat = (number: number, additions: number) => ({ + projectId: "project" as PullRequestListEntry["projectId"], + repository: "org/repo", + number, + additions, + deletions: 0, + }); + + it("drops a row's stat and refreshes its stats query when its updatedAt moves", () => { + const query = { + refs: [1, 2].map((number) => ({ + projectId: stat(number, 0).projectId, + repository: "org/repo", + number, + })), + }; + const listed = withListedPullRequests(EMPTY_PULL_REQUEST_STATS_CACHE, [entry(1), entry(2)]); + expect(listed.refresh).toEqual([]); + const answered = withAnsweredPullRequestStats(listed.cache, query, [stat(1, 5), stat(2, 7)]); + expect(withListedPullRequests(answered, [entry(1), entry(2)])).toEqual({ + cache: answered, + refresh: [], + }); + + const pushed = withListedPullRequests(answered, [ + entry(1, { updatedAt: "2026-10-20T00:00:00Z" }), + entry(2), + ]); + expect([...pushed.cache.stats.values()]).toEqual([stat(2, 7)]); + expect(pushed.refresh).toEqual([query]); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index 0b0f7cfd4e005..fcb41ac8ec861 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -7,6 +7,7 @@ import { type PullRequestListEntry, type PullRequestListInput, type PullRequestListResult, + type PullRequestListStatsInput, } from "@t3tools/contracts"; import type { PullRequestListControls } from "../../persistence/mobile-preferences"; @@ -50,6 +51,71 @@ export const pullRequestStatKey = ( row: Pick, ) => `${row.projectId}:${row.repository}:${row.number}`; +/** + * Diff stats an environment has answered, the stats query that answered each, and each row's + * `updatedAt` as its list last answered it. + */ +export interface PullRequestStatsCache { + readonly stats: ReadonlyMap; + readonly answeredBy: ReadonlyMap; + readonly updatedAt: ReadonlyMap; +} + +export const EMPTY_PULL_REQUEST_STATS_CACHE: PullRequestStatsCache = { + stats: new Map(), + answeredBy: new Map(), + updatedAt: new Map(), +}; + +/** + * Records the rows a list answered. Stats carry no head, so a row whose `updatedAt` moved (a push, + * among other changes) drops its cached stat, and the stats queries that answered it are returned + * to refresh: one can still be fresh and would otherwise answer the row's next read with the old + * counts. + */ +export function withListedPullRequests( + cache: PullRequestStatsCache, + entries: readonly Pick< + PullRequestListEntry, + "projectId" | "repository" | "number" | "updatedAt" + >[], +): { + readonly cache: PullRequestStatsCache; + readonly refresh: readonly PullRequestListStatsInput[]; +} { + const moved = entries.filter( + (entry) => cache.updatedAt.get(pullRequestStatKey(entry)) !== entry.updatedAt, + ); + if (!moved.length) return { cache, refresh: [] }; + const stats = new Map(cache.stats); + const answeredBy = new Map(cache.answeredBy); + const updatedAt = new Map(cache.updatedAt); + const refresh = new Set(); + for (const entry of moved) { + const key = pullRequestStatKey(entry); + const query = answeredBy.get(key); + if (query) refresh.add(query); + stats.delete(key); + answeredBy.delete(key); + updatedAt.set(key, entry.updatedAt); + } + return { cache: { stats, answeredBy, updatedAt }, refresh: [...refresh] }; +} + +export function withAnsweredPullRequestStats( + cache: PullRequestStatsCache, + input: PullRequestListStatsInput, + answered: readonly PullRequestDiffStat[], +): PullRequestStatsCache { + const stats = new Map(cache.stats); + const answeredBy = new Map(cache.answeredBy); + for (const row of answered) { + stats.set(pullRequestStatKey(row), row); + answeredBy.set(pullRequestStatKey(row), input); + } + return { ...cache, stats, answeredBy }; +} + /** * Fills rows that have no diff stat yet from the stats answers on screen. Returns `entries` * itself when no row changes, so the list keeps its identity across renders. diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index ab07c99b6f0d6..8a97deffaf06a 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -19,13 +19,12 @@ const projects = [ project("forgejo", "forgejo.test/org/repo", "forgejo", "http://forgejo.test:3000/org/repo.git"), ]; -const hostReads = () => ({ pullRequests: true, threadPullRequests: true }); const nativePullRequestDestination = ( url: string, linkContext: typeof context | null, linkProjects: typeof projects, - capabilities: Parameters[3] = hostReads, -) => destination(url, linkContext, linkProjects, capabilities); + configLoaded: Parameters[3] = () => true, +) => destination(url, linkContext, linkProjects, configLoaded); describe("native PR entry points", () => { it("keeps a thread link in its own environment and handles renamed host path shapes", () => { @@ -57,18 +56,7 @@ describe("native PR entry points", () => { it("leaves links to the external opener when the environment cannot show them", () => { const url = "https://github.com/org/repo/pull/12"; - expect(nativePullRequestDestination(url, context, projects, () => undefined)).toBeNull(); - expect( - nativePullRequestDestination( - "https://github.com/someone/else/pull/12", - context, - projects, - () => ({ - pullRequests: true, - threadPullRequests: false, - }), - ), - ).toBeNull(); + expect(nativePullRequestDestination(url, context, projects, () => false)).toBeNull(); }); it("leaves issues, unknown sites, and unscoped links to the external opener", () => { diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index c3c25b859193a..92e36f4486944 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -1,7 +1,7 @@ import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; import { EnvironmentId, ProjectId, type PullRequestListEntry } from "@t3tools/contracts"; import { useAtomRefresh } from "@effect/atom-react"; -import { useCallback, useLayoutEffect, useMemo, useState } from "react"; +import { useLayoutEffect, useMemo, useState } from "react"; import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; @@ -44,6 +44,7 @@ export function usePullRequestDiffEntry(navigation: { ) => () => void; }) { const [ready, setReady] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to navigator transition events and frames to defer the diff until the opening transition ends useLayoutEffect(() => { if (ready) return; let opening = false; @@ -116,23 +117,23 @@ export function usePullRequestDiff( const nextCursor = pages.at(-1)?.result.nextCursor ?? null; const canLoadMore = nextCursor !== null && nextCursor !== cursor && !query.isPending && query.error === null; - const loadMore = useCallback(() => { + const loadMore = () => { if (!canLoadMore) return; setState({ key, cursor: nextCursor, pages }); - }, [canLoadMore, key, nextCursor, pages, setState]); + }; const { error, refresh: refreshPage } = query; - const refresh = useCallback(() => { + const refresh = () => { if (cursor !== null && error !== null) { refreshPage(); return; } setState({ key, cursor: null, pages: NO_PAGES }); refreshFirstPage(); - }, [cursor, error, refreshPage, key, refreshFirstPage, setState]); - const onPullRefresh = useCallback(() => { + }; + const onPullRefresh = () => { setState({ key, cursor: null, pages: NO_PAGES }); onRefresh(); - }, [key, onRefresh, setState]); + }; return { ...query, refresh, diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts index ddbc8520dcd14..598f15a7e8e2b 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts @@ -37,7 +37,7 @@ export function usePullRequestDiffHighlightFiles( reportFailure: false, }); const { environmentId, projectId, repository, number, host, commit } = params; - const cache = useMemo(() => createPullRequestDiffHighlightCache(), []); + const cache = createPullRequestDiffHighlightCache(); return useMemo(() => { if (process.env.EXPO_PUBLIC_T3_DIFF_TRACE === "1") { const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts index 11a3e667fb32b..71b3304f7e2d2 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts @@ -1,5 +1,5 @@ import { useNavigation } from "@react-navigation/native"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import { useProjects } from "../../state/entities"; import { useConnectionsReady, useWorkspaceEnvironments } from "../../state/workspace"; @@ -13,10 +13,8 @@ export function usePullRequestEnvironmentFallback(params: PullRequestEnvironment const projects = useProjects(); const isReady = useConnectionsReady(); const environments = useWorkspaceEnvironments(); - const fallback = useMemo( - () => (isReady ? pullRequestFallbackEnvironment(params, projects, environments) : null), - [params, projects, environments, isReady], - ); + const fallback = isReady ? pullRequestFallbackEnvironment(params, projects, environments) : null; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- rewrites the route params through the navigator to the fallback environment once connections are ready useEffect(() => { if (fallback) navigation.setParams(fallback); }, [fallback, navigation]); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts b/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts index cf882ac0daee2..7467c73fbe403 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts @@ -1,15 +1,18 @@ import { usePullRequestConnected } from "./usePullRequestConnected"; -import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { PullRequestDetail } from "@t3tools/contracts"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { AsyncResult } from "effect/reactivity"; +import { use, useState } from "react"; import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; import { useAtomCommand } from "../../state/use-atom-command"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; +import { viewedOverlay, type ViewedState } from "./pullRequestFilesViewed"; export function usePullRequestFilesViewed(params: PullRequestParams, pr: PullRequestDetail | null) { + const registry = use(RegistryContext); const target = pullRequestTarget(params); const scopeKey = pullRequestReviewKey(params); const supported = Boolean(pr?.capabilities.viewedFiles); @@ -21,90 +24,54 @@ export function usePullRequestFilesViewed(params: PullRequestParams, pr: PullReq const query = useEnvironmentQuery(supported ? composerPullRequests.filesViewed(target) : null); const refresh = useAtomRefresh(composerPullRequests.filesViewed(target)); const write = useAtomCommand(composerPullRequests.setFilesViewed, { reportFailure: false }); - const [state, setState] = useState<{ - key: string; - overlay: Record; - error: string | null; - }>({ key: scopeKey, overlay: {}, error: null }); - const overlay = state.key === scopeKey ? state.overlay : {}; + const [state, setState] = useState({ key: scopeKey, presses: {}, error: null }); + const presses = state.key === scopeKey ? state.presses : {}; const error = state.key === scopeKey ? state.error : null; - const setOverlay = useCallback( - (overlay: Record) => - setState((current) => ({ - key: scopeKey, - overlay, - error: current.key === scopeKey ? current.error : null, - })), - [scopeKey], - ); - const setError = (error: string | null) => - setState((current) => ({ - key: scopeKey, - error, - overlay: current.key === scopeKey ? current.overlay : {}, - })); - const versions = useRef(new Map()); - const pressed = useRef>({}); - const reads = useRef(query.data); - useEffect(() => { - reads.current = query.data; - }, [query.data]); - const awaitingRead = useRef(new Map()); - const scope = useRef(scopeKey); - useEffect(() => { - scope.current = scopeKey; - versions.current.clear(); - awaitingRead.current.clear(); - pressed.current = {}; - }, [scopeKey]); - useEffect(() => { - if (supported && pr?.headSha) refresh(); - }, [supported, pr?.headSha, refresh]); - useEffect(() => { - let changed = false; - const next = { ...pressed.current }; - for (const [path, from] of awaitingRead.current) { - if (from === query.data) continue; - delete next[path]; - awaitingRead.current.delete(path); - changed = true; - } - if (changed) { - pressed.current = next; - setOverlay(next); - } - }, [query.data, setOverlay]); - const isViewed = (path: string) => - overlay[path] ?? - query.data?.files.some((file) => file.path === path && file.state === "viewed") ?? - false; + const overlay = viewedOverlay(presses, query.data); + const update = (change: (current: ViewedState) => ViewedState) => + setState((current) => + change(current.key === scopeKey ? current : { key: scopeKey, presses: {}, error: null }), + ); + const serverViewed = (path: string) => + query.data?.files.some((file) => file.path === path && file.state === "viewed") ?? false; + const isViewed = (path: string) => overlay[path] ?? serverViewed(path); const toggle = async (path: string) => { if (!supported || !allowed || !query.data) return; - const viewed = !( - pressed.current[path] ?? - query.data.files.some((file) => file.path === path && file.state === "viewed") - ); - const version = (versions.current.get(path) ?? 0) + 1; - versions.current.set(path, version); - awaitingRead.current.delete(path); - pressed.current = { ...pressed.current, [path]: viewed }; - setOverlay(pressed.current); - setError(null); + const viewed = !isViewed(path); + const version = (presses[path]?.version ?? 0) + 1; + update((current) => ({ + ...current, + error: null, + presses: { ...current.presses, [path]: { viewed, version, awaitingReadAfter: undefined } }, + })); const response = await write({ ...target, input: { ...target.input, files: [{ path, viewed }] }, }); - if (scope.current !== scopeKey) return; - if (response._tag === "Failure" && versions.current.get(path) === version) { - const next = { ...pressed.current }; - delete next[path]; - pressed.current = next; - setOverlay(next); + // The read that is current once the write lands still predates it; a newer one carries it. + const readAtWrite = AsyncResult.getOrElse( + registry.get(composerPullRequests.filesViewed(target)), + () => null, + ); + setState((current) => { + if (current.key !== scopeKey || current.presses[path]?.version !== version) return current; + if (response._tag === "Success") + return { + ...current, + presses: { + ...current.presses, + [path]: { viewed, version, awaitingReadAfter: { read: readAtWrite } }, + }, + }; + const rest = { ...current.presses }; + delete rest[path]; const cause = squashAtomCommandFailure(response); - setError(cause instanceof Error ? cause.message : "Could not update viewed files."); - } - if (response._tag === "Success" && versions.current.get(path) === version) - awaitingRead.current.set(path, reads.current); + return { + ...current, + presses: rest, + error: cause instanceof Error ? cause.message : "Could not update viewed files.", + }; + }); refresh(); }; return { diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index 73dad15cafd2f..5bc3cf730b520 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -1,11 +1,11 @@ -import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { RegistryContext, useAtomMount, useAtomSubscribe, useAtomValue } from "@effect/atom-react"; import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, PullRequestListInput } from "@t3tools/contracts"; +import type { EnvironmentId, PullRequestDiffStat, PullRequestListInput } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; -import { use, useCallback, useMemo, useState } from "react"; +import { use, useMemo, useState } from "react"; +import { environmentCatalog } from "../../connection/catalog"; import { composerPullRequests, pullRequestListPagesFamily, @@ -31,7 +31,7 @@ type Read = (atom: Atom.Atom) => A; /** The head the list query holds now, cached or answered. */ function currentHead(read: Read, target: ListTarget) { - return Option.getOrNull(AsyncResult.value(read(composerPullRequests.list(target)))); + return AsyncResult.getOrElse(read(composerPullRequests.list(target)), () => null); } function currentPages(read: Read, target: ListTarget): PullRequestListPages | null { @@ -142,6 +142,15 @@ const refreshLoadFamily = Atom.family((scopeKey: string) => ), ); +// Changes when any of the scope's environments brings up a new session. +const sessionsFamily = Atom.family((scopeKey: string) => + Atom.make((get) => + (JSON.parse(scopeKey) as readonly ListTarget[]) + .map(({ environmentId }) => get(environmentCatalog.sessionGenerationAtom(environmentId))) + .join(), + ), +); + const settledError = (result: AsyncResult.AsyncResult) => AsyncResult.isSuccess(result) && !result.waiting ? result.value : null; @@ -156,15 +165,33 @@ export function usePullRequestList(targets: readonly ListTarget[]) { // Starting one kind of load clears the other, so at most one holds an error. const loadError = settledError(refresh) ?? settledError(page); + // A page or refresh asked for while an environment was unreachable fails at once, and its + // error stops loading on scroll. Once it is back, ask for the page again; a refresh's error is + // stale by then because the list queries re-run on reconnect. + // A listener alone does not compute the atom, so nothing would follow the connections. + useAtomMount(sessionsFamily(scopeKey)); + useAtomSubscribe(sessionsFamily(scopeKey), () => { + const pageResult = registry.get(pageAtom); + const refreshResult = registry.get(refreshAtom); + if (settledError(refreshResult) !== null) registry.set(refreshAtom, Atom.Reset); + else if (settledError(pageResult) !== null && !refreshResult.waiting) + registry.set(pageAtom, true); + }); + + // Read from the registry, not the render: a second onEndReached before React re-renders must + // see the load the first one started. const loadMore = (retry = false) => { - if (page.waiting || refresh.waiting || (!retry && loadError)) return; + const pageResult = registry.get(pageAtom); + const refreshResult = registry.get(refreshAtom); + if (pageResult.waiting || refreshResult.waiting) return; + if (!retry && (settledError(refreshResult) ?? settledError(pageResult))) return; if (!continuations(registry, targets).length) return; registry.set(refreshAtom, Atom.Reset); registry.set(pageAtom, retry); }; const onRefresh = () => { - if (!targets.length || refresh.waiting) return; + if (!targets.length || registry.get(refreshAtom).waiting) return; registry.set(pageAtom, Atom.Reset); registry.set(refreshAtom, undefined); }; @@ -180,7 +207,7 @@ export function usePullRequestList(targets: readonly ListTarget[]) { pages: { ...pages, entries: pages.entries.map((entry) => { - const stat = stats.get(statKey(entry)); + const stat = stats.stats.get(statKey(entry)); return stat ? { ...entry, additions: stat.additions, deletions: stat.deletions } : entry; @@ -197,23 +224,20 @@ export function usePullRequestList(targets: readonly ListTarget[]) { key: string; rows: readonly EnvironmentPullRequestListEntry[]; }>({ key: scopeKey, rows: [] }); - const onViewableItemsChanged = useCallback( - ({ - viewableItems, - }: { - viewableItems: readonly { item: EnvironmentPullRequestListEntry; isViewable: boolean }[]; - }) => { - const rows = viewableItems.filter((token) => token.isViewable).map((token) => token.item); - setVisible((previous) => - previous.key === scopeKey && - previous.rows.map(scopedPullRequestListKey).join() === - rows.map(scopedPullRequestListKey).join() - ? previous - : { key: scopeKey, rows }, - ); - }, - [scopeKey], - ); + const onViewableItemsChanged = ({ + viewableItems, + }: { + viewableItems: readonly { item: EnvironmentPullRequestListEntry; isViewable: boolean }[]; + }) => { + const rows = viewableItems.filter((token) => token.isViewable).map((token) => token.item); + setVisible((previous) => + previous.key === scopeKey && + previous.rows.map(scopedPullRequestListKey).join() === + rows.map(scopedPullRequestListKey).join() + ? previous + : { key: scopeKey, rows }, + ); + }; const visibleRows = visible.key === scopeKey && visible.rows.length ? visible.rows : data.entries.slice(0, 8); // Visible rows carry the cached-answer overlay below. Decide which still need stats on the rows @@ -249,36 +273,33 @@ export function usePullRequestList(targets: readonly ListTarget[]) { }, [statsKey]); const counts = useAtomValue(statsAtom); // Server answers land in pullRequestListStatsFamily; these cover a cached answer, which is - // all an offline environment has. + // all an offline environment has. Scrolling makes a new stats query, which has no answer until + // its cached rows load, so each environment keeps the answer it showed until the new one lands. + const [shown, setShown] = useState({ counts, answers: answeredStats(counts, []) }); + if (shown.counts !== counts) setShown({ counts, answers: answeredStats(counts, shown.answers) }); const entries = useMemo( - () => - withShownPullRequestStats( - data.entries, - counts.flatMap(({ environmentId, result }) => - AsyncResult.isSuccess(result) ? [{ environmentId, stats: result.value.stats }] : [], - ), - ), - [data.entries, counts], + () => withShownPullRequestStats(data.entries, shown.answers), + [data.entries, shown.answers], ); - const updateEntry = useCallback( - (entry: EnvironmentPullRequestListEntry, patch: Partial) => { - const target = targets.find((target) => target.environmentId === entry.environmentId); - if (!target) return; - const pagesAtom = pullRequestListPagesFamily(pullRequestListPagesKey(target)); - const read = (atom: Atom.Atom) => registry.get(atom); - const patched = patchPullRequestListEntry( - read(pagesAtom), - currentHead(read, target), - entry, - patch, - Date.now(), - ); - if (patched) registry.set(pagesAtom, patched); - registry.refresh(composerPullRequests.list(target)); - }, - [registry, targets], - ); + const updateEntry = ( + entry: EnvironmentPullRequestListEntry, + patch: Partial, + ) => { + const target = targets.find((target) => target.environmentId === entry.environmentId); + if (!target) return; + const pagesAtom = pullRequestListPagesFamily(pullRequestListPagesKey(target)); + const read = (atom: Atom.Atom) => registry.get(atom); + const patched = patchPullRequestListEntry( + read(pagesAtom), + currentHead(read, target), + entry, + patch, + Date.now(), + ); + if (patched) registry.set(pagesAtom, patched); + registry.refresh(composerPullRequests.list(target)); + }; const errors = useMemo( () => sources.flatMap(({ target, result }) => { @@ -332,3 +353,32 @@ function requestErrors(results: readonly PromiseSettledResult[]) { .join("\n") || null ); } + +type StatsAnswer = { + readonly environmentId: EnvironmentId; + readonly stats: readonly PullRequestDiffStat[]; +}; + +/** Each environment's newest stats answer, or the one it had while its query has none. */ +function answeredStats( + counts: readonly { + readonly environmentId: EnvironmentId; + readonly result: AsyncResult.AsyncResult< + { readonly stats: readonly PullRequestDiffStat[] }, + unknown + >; + }[], + previous: readonly StatsAnswer[], +): readonly StatsAnswer[] { + const next = counts.flatMap(({ environmentId, result }) => { + const kept = previous.find((answer) => answer.environmentId === environmentId); + if (!AsyncResult.isSuccess(result)) return kept ? [kept] : []; + return kept?.stats === result.value.stats + ? [kept] + : [{ environmentId, stats: result.value.stats }]; + }); + return next.length === previous.length && + next.every((answer, index) => answer === previous[index]) + ? previous + : next; +} diff --git a/apps/mobile/src/features/pull-requests/usePullRequestReviewDraft.ts b/apps/mobile/src/features/pull-requests/usePullRequestReviewDraft.ts index 008031f7ebd00..0de1ae5c6388f 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestReviewDraft.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestReviewDraft.ts @@ -75,6 +75,7 @@ async function persist() { export function usePullRequestReviewDraft(params: PullRequestParams) { const snapshot = useAtomValue(state); const key = pullRequestReviewKey(params); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads review drafts from disk and persists them when AppState leaves active and on unmount useEffect(() => { void load(); const listener = AppState.addEventListener("change", (phase) => { diff --git a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts index 50a3d11f0d3ec..74a19737ac85d 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts @@ -42,10 +42,7 @@ export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDe ...query, stack, isFresh: supported && query.isSuccess && !query.isPending, - supportsStackActions: - supported && - pr?.capabilities.stackActions === true && - config?.environment.capabilities.pullRequestStackActions === true, + supportsStackActions: supported && pr?.capabilities.stackActions === true, }; } diff --git a/apps/mobile/src/features/review/ReviewCommentCard.tsx b/apps/mobile/src/features/review/ReviewCommentCard.tsx index ef1eff43b4547..a65bd83e9a66d 100644 --- a/apps/mobile/src/features/review/ReviewCommentCard.tsx +++ b/apps/mobile/src/features/review/ReviewCommentCard.tsx @@ -32,17 +32,14 @@ export interface ReviewCommentColors { export function useReviewCommentColors(): ReviewCommentColors { const theme = useUniwindTheme(); - return useMemo( - () => ({ - background: theme["--color-card"], - border: theme["--color-border"], - mutedBackground: theme["--color-subtle"], - text: theme["--color-foreground"], - mutedText: theme["--color-foreground-muted"], - codeBackground: theme["--color-md-code-bg"], - }), - [theme], - ); + return { + background: theme["--color-card"], + border: theme["--color-border"], + mutedBackground: theme["--color-subtle"], + text: theme["--color-foreground"], + mutedText: theme["--color-foreground-muted"], + codeBackground: theme["--color-md-code-bg"], + }; } export const ReviewCommentCard = memo(function ReviewCommentCard(props: { @@ -113,10 +110,7 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { () => JSON.stringify(nativeReviewDiffTheme), [nativeReviewDiffTheme], ); - const nativeStyleJson = useMemo( - () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }), - [nativeReviewDiffStyle], - ); + const nativeStyleJson = JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }); const nativeDiffHeight = useMemo( () => Math.min( diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index 5288929ed8986..a98d88a152e6d 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -1,7 +1,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { TextInputWrapper } from "expo-paste-input"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -82,10 +82,10 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp codeSurface.rowHeight, ); const previewViewportWidth = Math.max(width - 40, 280); - const dismissComposer = useCallback(() => { + const dismissComposer = () => { clearReviewCommentTarget(); navigation.goBack(); - }, [navigation]); + }; const handleNativePaste = useNativePaste((uris) => { void (async () => { try { @@ -112,7 +112,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp } } - const handleSubmit = useCallback(() => { + const handleSubmit = () => { if (!target || !environmentId || !threadId || commentText.trim().length === 0) { return; } @@ -125,7 +125,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp }); setAttachments([]); dismissComposer(); - }, [attachments, commentText, dismissComposer, environmentId, target, threadId]); + }; return ( diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 55e4398ec24b6..18908302d577e 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -10,14 +10,7 @@ import type { AppBarMenuItem } from "../../components/ui/AppBar.types"; import type { ReviewSectionItem } from "./reviewModel"; import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation"; import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; -import { - type Ref, - type ReactElement, - useEffect, - useImperativeHandle, - useRef, - useState, -} from "react"; +import { type Ref, type ReactElement, useImperativeHandle, useRef, useState } from "react"; import { FlatList, Platform, @@ -432,7 +425,7 @@ type ReviewSheetProps = StaticScreenProps<{ export function ReviewSheet(props: ReviewSheetProps) { const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); useAdaptiveWorkspacePaneRole("inspector"); - const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { panes } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const selectDetail = useSelectWorkspaceDetail(); const insets = useSafeAreaInsets(); @@ -451,10 +444,6 @@ export function ReviewSheet(props: ReviewSheetProps) { const bottomContentInset = Platform.OS === "ios" ? safeArea.bottom + 8 : Math.max(insets.bottom, 18) + 18; - useEffect(() => { - showAuxiliaryPane("inspector"); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening review for another thread reselects its inspector pane. - }, [environmentId, showAuxiliaryPane, threadId]); const { error, reviewSections, diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 59a3cc5b1db6e..4e3b22407f5a0 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -178,7 +178,7 @@ export function getReviewUnifiedLineNumber(line: ReviewRenderableLineRow): numbe return line.newLineNumber ?? line.oldLineNumber; } -export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string { +function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string { if (change === "add") return "+"; if (change === "delete") return "-"; return " "; diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index 4b2c66a2af972..99b589e47cf74 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -12,7 +12,7 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ default: "monospace", }); -export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; +const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export function renderVisibleWhitespace(value: string): string { const expandedTabs = value.replace(/\t/g, " "); @@ -25,7 +25,7 @@ export function changeTone(change: ReviewRenderableLineRow["change"]): string { return "bg-card"; } -export function changeBarTone(change: ReviewRenderableLineRow["change"]): string { +function changeBarTone(change: ReviewRenderableLineRow["change"]): string { if (change === "add") return "bg-emerald-400"; if (change === "delete") return "bg-rose-400"; return "bg-border/50"; diff --git a/apps/mobile/src/features/review/reviewFileVisibility.ts b/apps/mobile/src/features/review/reviewFileVisibility.ts index fbdfcf2302255..5d01de5700eda 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.ts @@ -9,7 +9,7 @@ function getDefaultReviewExpandedFileIds( return files.map((file) => file.id); } -export function getValidReviewFileIds( +function getValidReviewFileIds( files: ReadonlyArray, fileIds: ReadonlyArray | undefined, ): ReadonlyArray { @@ -21,7 +21,7 @@ export function getValidReviewFileIds( return fileIds.filter((id) => fileIdSet.has(id)); } -export function getValidExplicitReviewFileIds( +function getValidExplicitReviewFileIds( files: ReadonlyArray, fileIds: ReadonlyArray | undefined, ): ReadonlyArray { @@ -32,17 +32,11 @@ export function getValidExplicitReviewFileIds( return getValidReviewFileIds(files, fileIds); } -export function toggleReviewFileId( - fileIds: ReadonlyArray, - fileId: string, -): ReadonlyArray { +function toggleReviewFileId(fileIds: ReadonlyArray, fileId: string): ReadonlyArray { return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : [...fileIds, fileId]; } -export function removeReviewFileId( - fileIds: ReadonlyArray, - fileId: string, -): ReadonlyArray { +function removeReviewFileId(fileIds: ReadonlyArray, fileId: string): ReadonlyArray { return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : fileIds; } diff --git a/apps/mobile/src/features/review/reviewPerf.ts b/apps/mobile/src/features/review/reviewPerf.ts deleted file mode 100644 index 2613ff0c3dbbc..0000000000000 --- a/apps/mobile/src/features/review/reviewPerf.ts +++ /dev/null @@ -1,56 +0,0 @@ -interface ReviewPerformanceLike { - readonly now?: () => number; - readonly mark?: (name: string) => void; - readonly measure?: (name: string, startMark: string, endMark: string) => void; - readonly clearMarks?: (name?: string) => void; - readonly clearMeasures?: (name?: string) => void; -} - -const REVIEW_PERF_PREFIX = "t3.review"; -let reviewPerfSequence = 0; - -function getPerformance(): ReviewPerformanceLike | null { - const candidate = (globalThis as { readonly performance?: ReviewPerformanceLike }).performance; - return candidate ?? null; -} - -export function isReviewPerfEnabled(): boolean { - return typeof __DEV__ !== "undefined" ? __DEV__ : false; -} - -export function measureReviewWork(name: string, callback: () => T): T { - if (!isReviewPerfEnabled()) { - return callback(); - } - - const perf = getPerformance(); - const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`; - const startMark = `${marker}.start`; - const endMark = `${marker}.end`; - const startedAt = perf?.now?.() ?? Date.now(); - - perf?.mark?.(startMark); - try { - return callback(); - } finally { - const durationMs = (perf?.now?.() ?? Date.now()) - startedAt; - perf?.mark?.(endMark); - perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark); - perf?.clearMarks?.(startMark); - perf?.clearMarks?.(endMark); - console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) }); - } -} - -export function markReviewEvent(name: string, details?: Record): void { - if (!isReviewPerfEnabled()) { - return; - } - - getPerformance()?.mark?.(`${REVIEW_PERF_PREFIX}.${name}`); - if (details) { - console.log(`[review-perf] ${name}`, details); - return; - } - console.log(`[review-perf] ${name}`); -} diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index 2ae7c77e14d96..8e5adca1ce598 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { NativeSyntheticEvent } from "react-native"; import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter"; @@ -8,8 +8,6 @@ import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighti import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -export { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; - export function useNativeReviewDiffBridge(input: { readonly threadKey: string | null; readonly sectionId: string | null; @@ -53,7 +51,7 @@ export function useNativeReviewDiffBridge(input: { [collapsedCommentIds], ); const themeJson = useMemo(() => JSON.stringify(theme), [theme]); - const styleJson = useMemo(() => JSON.stringify(nativeReviewDiffStyle), [nativeReviewDiffStyle]); + const styleJson = JSON.stringify(nativeReviewDiffStyle); const tokensResetKey = useMemo( () => `${buildNativeReviewTokensResetKey({ @@ -76,50 +74,44 @@ export function useNativeReviewDiffBridge(input: { enabled: canHighlight, }); - const onDebug = useCallback( - (event: NativeSyntheticEvent>) => { - const payload = event.nativeEvent; - const message = payload.message; - if (message === "surface-content-mounted") { - // The initial content already has a fresh hook; replacements lose accepted tokens. - if (hasMountedContentRef.current) setSurfaceGeneration((generation) => generation + 1); - hasMountedContentRef.current = true; - } - if (message === "tokens-patch-delivered") acknowledgeTokenPatch(payload); - if (message === "tokens-patch-failed") rejectTokenPatch(payload); - if ( - (message === "draw-metrics" || message === "visible-range") && - typeof payload.firstRowIndex === "number" && - typeof payload.lastRowIndex === "number" - ) { - updateVisibleRange({ - firstRowIndex: payload.firstRowIndex, - lastRowIndex: payload.lastRowIndex, - }); - } - }, - [acknowledgeTokenPatch, rejectTokenPatch, updateVisibleRange], - ); + const onDebug = (event: NativeSyntheticEvent>) => { + const payload = event.nativeEvent; + const message = payload.message; + if (message === "surface-content-mounted") { + // The initial content already has a fresh hook; replacements lose accepted tokens. + if (hasMountedContentRef.current) setSurfaceGeneration((generation) => generation + 1); + hasMountedContentRef.current = true; + } + if (message === "tokens-patch-delivered") acknowledgeTokenPatch(payload); + if (message === "tokens-patch-failed") rejectTokenPatch(payload); + if ( + (message === "draw-metrics" || message === "visible-range") && + typeof payload.firstRowIndex === "number" && + typeof payload.lastRowIndex === "number" + ) { + updateVisibleRange({ + firstRowIndex: payload.firstRowIndex, + lastRowIndex: payload.lastRowIndex, + }); + } + }; - const onToggleComment = useCallback( - (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => { - const { commentId } = event.nativeEvent; - if (!commentId) { - return; - } + const onToggleComment = (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => { + const { commentId } = event.nativeEvent; + if (!commentId) { + return; + } - setCollapsedCommentIds((current) => { - const next = new Set(current); - if (next.has(commentId)) { - next.delete(commentId); - } else { - next.add(commentId); - } - return next; - }); - }, - [], - ); + setCollapsedCommentIds((current) => { + const next = new Set(current); + if (next.has(commentId)) { + next.delete(commentId); + } else { + next.add(commentId); + } + return next; + }); + }; return { themeId, diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index b398867ea2d29..d22e07607c8db 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -2,7 +2,7 @@ import type { HighlightScheme } from "../../lib/highlights"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { highlightNativeReviewDiffVisibleRows, @@ -50,6 +50,7 @@ export function useNativeReviewDiffHighlighting(input: { // Effect on purpose: the native highlighter's token stream and the scheduler's timers must // reset with the diff the view shows, and be cancelled when it unmounts. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resets the native highlighter's scheduler and token stream with the diff, cancelling its timers on cleanup useEffect(() => { traceDiffHighlight("viewport-reset", { reset: diffHighlightTraceId(resetKey), @@ -69,6 +70,7 @@ export function useNativeReviewDiffHighlighting(input: { }, [enabled, resetKey, rows.length, scheduler, viewportScope]); // Effect on purpose: abortable async highlighting tied to the visible range's lifetime. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- runs abortable native highlighting for the visible range and aborts it when the range or diff changes useEffect(() => { if (!enabled || rows.length === 0 || !viewport.range || viewport.scope !== viewportScope) { return; @@ -180,7 +182,7 @@ export function useNativeReviewDiffHighlighting(input: { }; }, [collapsedFileIds, enabled, files, resetKey, rows, scheme, viewport, viewportScope]); - const acknowledgeTokenPatch = useCallback((payload: Readonly>) => { + const acknowledgeTokenPatch = (payload: Readonly>) => { const pending = pendingPatchesRef.current; const head = pending[0]; traceDiffHighlight("patch-ack", { @@ -194,25 +196,22 @@ export function useNativeReviewDiffHighlighting(input: { return; pending.shift(); setTokensPatchJson(pending[0]?.json ?? createEmptyTokenPatch(head.resetKey)); - }, []); + }; - const rejectTokenPatch = useCallback( - (payload: Readonly>) => { - const head = pendingPatchesRef.current[0]; - traceDiffHighlight("patch-rejected", { - reset: diffHighlightTraceId(payload.resetKey), - chunk: typeof payload.chunkIndex === "number" ? payload.chunkIndex : -1, - matches: - !!head && payload.resetKey === head.resetKey && payload.chunkIndex === head.chunkIndex, - }); - if (!head || payload.resetKey !== head.resetKey || payload.chunkIndex !== head.chunkIndex) - return; - for (const rowId of head.rowIds) highlightedRowIdsRef.current.delete(rowId); - // Retry these rows only on a later viewport request, not in a failure loop. - acknowledgeTokenPatch(payload); - }, - [acknowledgeTokenPatch], - ); + const rejectTokenPatch = (payload: Readonly>) => { + const head = pendingPatchesRef.current[0]; + traceDiffHighlight("patch-rejected", { + reset: diffHighlightTraceId(payload.resetKey), + chunk: typeof payload.chunkIndex === "number" ? payload.chunkIndex : -1, + matches: + !!head && payload.resetKey === head.resetKey && payload.chunkIndex === head.chunkIndex, + }); + if (!head || payload.resetKey !== head.resetKey || payload.chunkIndex !== head.chunkIndex) + return; + for (const rowId of head.rowIds) highlightedRowIdsRef.current.delete(rowId); + // Retry these rows only on a later viewport request, not in a failure loop. + acknowledgeTokenPatch(payload); + }; return { rejectTokenPatch, diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index 21cec36eca523..6ccc5ae3f495c 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -3,7 +3,6 @@ import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; -import { markReviewEvent, measureReviewWork } from "./reviewPerf"; import { getCachedReviewParsedDiff } from "./reviewState"; import { applyReviewDiffMetadata, @@ -101,7 +100,7 @@ function logReviewDiffDiagnostic(message: string, details?: Record( - () => - lazySource - ? { kind: "empty" } - : measureReviewWork("parse-diff", () => - getCachedReviewParsedDiff({ - threadKey, - sectionId: selectedSection?.id ?? null, - diff: selectedSection?.diff, - }), - ), - [lazySource, selectedSection?.diff, selectedSection?.id, threadKey], - ); + const previewDiff: ReviewParsedDiff = lazySource + ? { kind: "empty" } + : getCachedReviewParsedDiff({ + threadKey, + sectionId: selectedSection?.id ?? null, + diff: selectedSection?.diff, + }); const registry = useContext(RegistryContext); const { environmentId, cwd } = input; const scope = JSON.stringify([ @@ -237,49 +230,31 @@ export function useReviewDiffData(input: { () => formatHeaderDiffSummary(parsedDiff, selectedSection?.files), [parsedDiff, selectedSection?.files], ); - const inlineReviewComments = useMemo( - () => parseReviewInlineComments(draftMessage), - [draftMessage], - ); + const inlineReviewComments = parseReviewInlineComments(draftMessage); const selectedSectionInlineComments = useMemo(() => { if (!selectedSectionId || inlineReviewComments.length === 0) { return EMPTY_INLINE_REVIEW_COMMENTS; } return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId); }, [inlineReviewComments, selectedSectionId]); - const nativeReviewDiffData = useMemo( - () => - measureReviewWork("build-native-diff-data", () => - getCachedNativeReviewDiffData({ - parsedDiff, - comments: selectedSectionInlineComments, - }), - ), - [parsedDiff, selectedSectionInlineComments], - ); - const pendingReviewCommentCount = useMemo( - () => countReviewCommentContexts(draftMessage), - [draftMessage], - ); + const nativeReviewDiffData = getCachedNativeReviewDiffData({ + parsedDiff, + comments: selectedSectionInlineComments, + }); + const pendingReviewCommentCount = countReviewCommentContexts(draftMessage); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records review perf marks and diagnostic logs once a parsed diff has committed useEffect(() => { if (parsedDiff.kind !== "files") { return; } - markReviewEvent("parsed-diff-ready", { - sectionId: selectedSection?.id ?? null, - fileCount: parsedDiff.fileCount, - additions: parsedDiff.additions, - deletions: parsedDiff.deletions, - renderedItems: nativeReviewDiffData.rows.length, - }); logReviewDiffDiagnostic("parsed diff files", { selectedSectionId: selectedSection?.id ?? null, fileCount: parsedDiff.fileCount, renderableFileCount: parsedDiff.files.length, }); - }, [nativeReviewDiffData.rows.length, parsedDiff, selectedSection?.id]); + }, [parsedDiff, selectedSection?.id]); return { parsedDiff, diff --git a/apps/mobile/src/features/review/useReviewDiffPrewarming.ts b/apps/mobile/src/features/review/useReviewDiffPrewarming.ts index 66a7c636d659e..b60b19b56aa45 100644 --- a/apps/mobile/src/features/review/useReviewDiffPrewarming.ts +++ b/apps/mobile/src/features/review/useReviewDiffPrewarming.ts @@ -35,7 +35,7 @@ function cancelIdle(handle: number): void { clearTimeout(handle); } -export function prewarmReviewDiffSection(input: { +function prewarmReviewDiffSection(input: { readonly threadKey: string; readonly section: ReviewSectionItem; }): void { @@ -52,7 +52,7 @@ export function prewarmReviewDiffSection(input: { getCachedNativeReviewDiffData({ parsedDiff, comments: [] }); } -export function getReviewDiffPrewarmSections(input: { +function getReviewDiffPrewarmSections(input: { readonly threadKey: string; readonly sections: ReadonlyArray; readonly selectedSectionId: string | null; @@ -105,6 +105,7 @@ export function useReviewDiffPrewarming(input: { }): void { const { sections, selectedSectionId, threadKey } = input; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- prewarms pending review diff sections in idle callbacks and cancels the idle handle on cleanup useEffect(() => { if (!threadKey) { return; diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index ce6a565f0ab9e..012d00737d1df 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -27,7 +27,7 @@ import { cn } from "../../lib/cn"; import { environmentSession } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { connectionTone } from "../connection/connectionTone"; -import { SettingsActionRow } from "./components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { SettingsDragHandle } from "./components/SettingsDragHandle"; import { SettingsSection } from "./components/SettingsSection"; @@ -56,7 +56,7 @@ export function EnvironmentRoutesSection({ const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId); const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)); const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId)); - const session = Option.getOrNull(AsyncResult.value(sessionResult)); + const session = AsyncResult.getOrElse(sessionResult, () => null); const [permissionsOpen, setPermissionsOpen] = useState(false); const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); diff --git a/apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx b/apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx index f421e81bc2002..71e12d09e0b17 100644 --- a/apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx +++ b/apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx @@ -1,8 +1,8 @@ -import { useAtomValue } from "@effect/atom-react"; -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { useAtom, useAtomValue } from "@effect/atom-react"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; @@ -13,9 +13,13 @@ import { updateOutdatedServer, } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import { SettingsActionRow } from "./components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { SettingsSection } from "./components/SettingsSection"; -import { findEnvironmentUpdate } from "./environment-maintenance"; +import { + environmentReleaseCheckAtom, + releaseCheckError, + releaseCheckValue, +} from "./environmentUpdateCheck"; export function OutdatedServerUpdateSection({ environmentId, @@ -27,37 +31,22 @@ export function OutdatedServerUpdateSection({ const describe = useAtomCommand(describeOutdatedServer, { reportFailure: false }); const update = useAtomCommand(updateOutdatedServer, { reportFailure: false }); const state = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); - const [release, setRelease] = useState<{ - fromVersion: string; - targetVersion: string | null; - } | null>(null); - const [checking, setChecking] = useState(false); - const [error, setError] = useState(null); - const controller = useRef(null); - useEffect(() => () => controller.current?.abort(), []); + const [checkResult, startCheck] = useAtom(environmentReleaseCheckAtom(environmentId)); + const release = releaseCheckValue(checkResult); + const checking = checkResult.waiting; + // Starting an update replaces a failed check's message with the update's own state. + const [checkErrorDismissed, setCheckErrorDismissed] = useState(false); + const error = checkErrorDismissed ? null : releaseCheckError(checkResult); const busy = checking || state.status === "running"; - async function check() { - if (busy || controller.current !== null) return; - const request = new AbortController(); - controller.current = request; - setChecking(true); - setError(null); - const timeout = setTimeout(() => request.abort(), 20_000); - const result = await settlePromise(async () => { + function check() { + if (busy) return; + setCheckErrorDismissed(false); + startCheck(async () => { const description = await describe(environmentId); if (AsyncResult.isFailure(description)) throw squashAtomCommandFailure(description); - const fromVersion = description.value.serverVersion; - const targetVersion = await findEnvironmentUpdate(fromVersion, request.signal); - if (!request.signal.aborted) setRelease({ fromVersion, targetVersion }); + return description.value.serverVersion; }); - clearTimeout(timeout); - controller.current = null; - setChecking(false); - if (AsyncResult.isFailure(result)) { - const failure = squashAtomCommandFailure(result); - setError(failure instanceof Error ? failure.message : "Could not check for updates."); - } } function requestUpdate() { @@ -71,7 +60,7 @@ export function OutdatedServerUpdateSection({ { text: "Update", onPress: () => { - setError(null); + setCheckErrorDismissed(true); void update({ environmentId, input: { targetVersion }, @@ -113,7 +102,7 @@ export function OutdatedServerUpdateSection({ label="Check for updates" disabled={busy} loading={checking} - onPress={() => void check()} + onPress={check} /> {release?.targetVersion ? ( + + {Platform.OS === "android" ? : null} diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index 433a353e99c36..276f51fcb3891 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -36,6 +36,7 @@ function ConfiguredSettingsAuthRouteScreen() { // Clerk reports sign-out only through its auth store (the native profile view // and the T3 Connect page can both sign out), so this follows that store. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pops the navigator when Clerk's auth store reports a sign-out from the profile view or Connect page useEffect(() => { if (hasBeenSignedIn && isLoaded && isSignedIn === false) { navigation.goBack(); diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index 9c6909f79d32f..48dc425badb6d 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -18,7 +18,7 @@ import { } from "../../state/client-cache-state"; import { useServerConfigs } from "../../state/entities"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; -import { SettingsActionRow } from "./components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsScreen } from "./components/SettingsScreen"; diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index 95592a3b81066..cd9d8e03c9c1f 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -1,5 +1,5 @@ import * as Dialog from "../../components/ui/Dialog"; -import { useAtomValue } from "@effect/atom-react"; +import { useAtom, useAtomValue } from "@effect/atom-react"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AuthProvidersManageScope, @@ -8,8 +8,10 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; import { AsyncResult } from "effect/reactivity"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -25,15 +27,15 @@ import { useRemoteConnections } from "../../state/use-remote-environment-registr import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection"; import { OutdatedServerUpdateSection } from "./OutdatedServerUpdateSection"; -import { SettingsActionRow } from "./components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; import { canMaintainEnvironment, canUpdateEnvironmentProvider, - findEnvironmentUpdate, supportsEnvironmentUpdate, } from "./environment-maintenance"; +import { environmentReleaseCheckAtom, releaseCheckValue } from "./environmentUpdateCheck"; export function SettingsEnvironmentDetailRouteScreen({ route, @@ -70,12 +72,10 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm const pendingRef = useRef(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); - const [release, setRelease] = useState<{ - fromVersion: string; - targetVersion: string | null; - } | null>(null); - const checkController = useRef(null); - useEffect(() => () => checkController.current?.abort(), []); + const [releaseCheck, checkRelease] = useAtom(environmentReleaseCheckAtom(environmentId), { + mode: "promiseExit", + }); + const release = releaseCheckValue(releaseCheck); const connected = environment?.isEnabled === true && environment.connectionState === "connected"; const allowed = @@ -136,16 +136,9 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm return; const result = await updateServer({ environmentId, - input: { - targetVersion, - ...(capabilities.serverUpdateThreadContinuation && - config?.settings.continueThreadsAfterServerUpdate - ? { continueRunningThreads: true } - : {}), - }, + input: { targetVersion }, }); if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); - setRelease(null); setNotice(`Updated to ${result.value.targetVersion}.`); }), }, @@ -267,19 +260,11 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm loading={pending === "check"} onPress={() => { if (disabled) return; + const fromVersion = config.environment.serverVersion; void run("check", async () => { - const controller = new AbortController(); - checkController.current = controller; - const timeout = setTimeout(() => controller.abort(), 20_000); - const fromVersion = config.environment.serverVersion; - const result = await settlePromise(() => - findEnvironmentUpdate(fromVersion, controller.signal), - ); - clearTimeout(timeout); - checkController.current = null; + const exit = await checkRelease(async () => fromVersion); // `run` owns the error message, so a failed lookup rethrows to it. - if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); - setRelease({ fromVersion, targetVersion: result.value }); + if (Exit.isFailure(exit)) throw Cause.squash(exit.cause); }); }} /> diff --git a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx index dac2f93c80186..29b47a9ddd462 100644 --- a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx @@ -1,17 +1,15 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { useNavigation } from "@react-navigation/native"; import { AsyncResult } from "effect/reactivity"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; import { AppText as Text } from "../../components/ui/AppText"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; import { DEFAULT_FOLLOW_UP_BEHAVIOR, type FollowUpBehavior } from "../../lib/followUpBehavior"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; +import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; +import { SettingsScreen } from "./components/SettingsScreen"; const FOLLOW_UP_OPTIONS: ReadonlyArray<{ readonly behavior: FollowUpBehavior; @@ -31,7 +29,6 @@ const FOLLOW_UP_OPTIONS: ReadonlyArray<{ ]; export function SettingsFollowUpRouteScreen() { - const navigation = useNavigation(); const insets = useSafeAreaInsets(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -40,39 +37,39 @@ export function SettingsFollowUpRouteScreen() { ? (preferencesResult.value.followUpBehavior ?? DEFAULT_FOLLOW_UP_BEHAVIOR) : null; - return ( + const content = ( + + + {FOLLOW_UP_OPTIONS.map((option, index) => ( + 0} + disabled={!preferencesReady} + onPress={() => savePreferences({ followUpBehavior: option.behavior })} + /> + ))} + + + Long-press the send button to use the other option for a single message. With a hardware + keyboard, hold Command while sending. + + + ); + + return Platform.OS === "android" ? ( + {content} + ) : ( - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} - - - {FOLLOW_UP_OPTIONS.map((option, index) => ( - 0} - disabled={!preferencesReady} - onPress={() => savePreferences({ followUpBehavior: option.behavior })} - /> - ))} - - - Long-press the send button to use the other option for a single message. With a hardware - keyboard, hold Command while sending. - - + {content} ); } diff --git a/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx index 7081605bbdebd..1d8e5fb9a5689 100644 --- a/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsKeyboardRouteScreen.tsx @@ -13,7 +13,7 @@ import { type ComposerEnterBehavior, } from "../../lib/composerEnterBehavior"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; +import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; const ENTER_BEHAVIOR_OPTIONS: ReadonlyArray<{ diff --git a/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx index 77154bb071d89..0af6eacd30c2c 100644 --- a/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx @@ -1,16 +1,16 @@ -import { Button } from "../../components/ui/Button"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from "expo-audio"; -import { useCallback, useEffect, useState } from "react"; -import { AppState, Linking, Pressable, View } from "react-native"; +import { useState } from "react"; +import { Linking, useWindowDimensions, View } from "react-native"; import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { type AppSymbolName, SymbolView } from "../../components/ui/AppSymbol"; +import { type AppSymbolName } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { cn } from "../../lib/cn"; +import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom"; import { isMicrophoneDevice, type MicrophoneEntry, @@ -19,36 +19,37 @@ import { microphoneOrder, } from "../../lib/microphonePriority"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { SettingsActionRow } from "./components/SettingsActionRow"; +import { ListRowLayout } from "../../components/ui/ListRowLayout"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { SettingsDragHandle } from "./components/SettingsDragHandle"; import { SettingsSection } from "./components/SettingsSection"; -const REMOVE_SIZE = 20; - type MicrophonePermission = "checking" | "granted" | "ask" | "denied"; +function toMicrophonePermission(response: { + readonly granted: boolean; + readonly canAskAgain: boolean; +}): MicrophonePermission { + return response.granted ? "granted" : response.canAskAgain ? "ask" : "denied"; +} + +const readMicrophonePermission = () => + getRecordingPermissionsAsync().then(toMicrophonePermission, (): MicrophonePermission => "ask"); + +const microphonePermissionAtom = makePermissionStatusAtom({ + label: "mobile:microphone-permission", + initial: "checking", + read: readMicrophonePermission, +}); + function useMicrophonePermission() { - const [permission, setPermission] = useState("checking"); - const apply = useCallback( - (response: { readonly granted: boolean; readonly canAskAgain: boolean }) => - setPermission(response.granted ? "granted" : response.canAskAgain ? "ask" : "denied"), - [], - ); - const refresh = useCallback( - () => void getRecordingPermissionsAsync().then(apply, () => setPermission("ask")), - [apply], - ); - useEffect(() => { - refresh(); - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") refresh(); - }); - return () => subscription.remove(); - }, [refresh]); - const request = useCallback( - () => void requestRecordingPermissionsAsync().then(apply, refresh), - [apply, refresh], - ); + const permission = useAtomValue(microphonePermissionAtom); + const setPermission = useAtomSet(microphonePermissionAtom); + const request = () => + void requestRecordingPermissionsAsync() + .then(toMicrophonePermission, readMicrophonePermission) + .then(setPermission); return { permission, request }; } @@ -95,9 +96,7 @@ export function SettingsMicrophoneRouteScreen() { const entries = microphoneOrder( AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.microphones : undefined, ); - const hasDevices = entries.some(isMicrophoneDevice); const { permission, request: requestPermission } = useMicrophonePermission(); - const [editing, setEditing] = useState(false); const [drag, setDrag] = useState<{ readonly key: string; readonly translation: number } | null>( null, ); @@ -163,18 +162,7 @@ export function SettingsMicrophoneRouteScreen() { ) : null} - setEditing((value) => !value)} - /> - ) : undefined - } - > + {entries.map((entry, index) => { const key = microphoneEntryKey(entry); const shift = @@ -192,7 +180,6 @@ export function SettingsMicrophoneRouteScreen() { position={index + 1} count={entries.length} reorderable={ready} - editing={editing && hasDevices} offset={index === dragFrom ? (drag?.translation ?? 0) : shift} lifted={index === dragFrom} onHeight={setRowHeight} @@ -212,7 +199,7 @@ export function SettingsMicrophoneRouteScreen() { Voice input records from the first connected microphone in this list. Wired and Bluetooth devices are added by name after you use voice input with them connected; until then, the - Other row ranks them. + Other row ranks them. Swipe a device left to forget it. @@ -224,7 +211,6 @@ function MicrophoneRow(props: { readonly position: number; readonly count: number; readonly reorderable: boolean; - readonly editing: boolean; readonly offset: number; readonly lifted: boolean; readonly onHeight: (height: number) => void; @@ -234,9 +220,10 @@ function MicrophoneRow(props: { readonly onStep: (direction: "up" | "down") => void; readonly onForget: (() => void) | undefined; }) { - const { lifted, offset, entry } = props; + const { lifted, offset, entry, onForget } = props; const kind = KINDS[entry.kind]; const title = isMicrophoneDevice(entry) ? entry.name : kind.row; + const { width: windowWidth } = useWindowDimensions(); const style = useAnimatedStyle(() => ({ transform: [ { @@ -247,55 +234,23 @@ function MicrophoneRow(props: { ], zIndex: lifted ? 1 : 0, })); - return ( - props.onHeight(event.nativeEvent.layout.height)} - className={cn( - "flex-row items-center gap-4 pl-4", - props.position > 1 && !lifted && "border-t border-border-subtle", - lifted && "bg-grouped-card shadow-md", - !props.reorderable && "pr-4", - )} - > - {props.editing && props.onForget ? ( - - - - ) : props.editing ? ( - - ) : null} + const row = ( + // Opaque so the swipe actions stay hidden until the row slides over them. + { + if (nativeEvent.actionName === "forget") onForget?.(); + }} + className="min-w-0 flex-1" > - - - - {title} - - - {isMicrophoneDevice(entry) ? kind.label : kind.rowDescription} - - {props.reorderable ? ( ) : null} + + ); + return ( + props.onHeight(event.nativeEvent.layout.height)} + className={cn( + props.position > 1 && !lifted && "border-t border-border-subtle", + lifted && "bg-grouped-card shadow-md", + )} + > + {onForget ? ( + + {() => row} + + ) : ( + row + )} ); } diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index 701403f3a4b9f..4aa5cc4c70be8 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -8,8 +8,8 @@ import * as Notifications from "expo-notifications"; import { useNavigation } from "@react-navigation/native"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/reactivity"; -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { AppState, Linking, Platform } from "react-native"; +import { useRef, useState, useSyncExternalStore } from "react"; +import { Linking, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; @@ -34,6 +34,7 @@ import { } from "../agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; +import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom"; import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -46,6 +47,38 @@ import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; +const notificationStatusAtom = makePermissionStatusAtom({ + label: "mobile:notification-permission", + initial: "checking", + read: async () => { + if (Platform.OS !== "ios" && Platform.OS !== "android") return "unsupported"; + const result = await settlePromise(() => Notifications.getPermissionsAsync()); + if (result._tag === "Failure") { + reportAtomCommandResult(result, { label: "notification permission refresh" }); + return "disabled"; + } + return result.value.granted ? "enabled" : "disabled"; + }, +}); + +type LiveActivityBasis = { + readonly isLoaded: boolean; + readonly isSignedIn: boolean | undefined; + readonly preferencesResult: unknown; +}; + +function liveActivityStatusFrom( + isLoaded: boolean, + isSignedIn: boolean | undefined, + preferencesResult: AsyncResult.AsyncResult<{ readonly liveActivitiesEnabled?: boolean }, unknown>, +): LiveActivityStatus { + if (!isLoaded) return "checking"; + if (!isSignedIn) return "signed-out"; + if (AsyncResult.isFailure(preferencesResult)) return "enabled"; + if (!AsyncResult.isSuccess(preferencesResult)) return "checking"; + return preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled"; +} + function useDeviceRegistered(): boolean { const status = useSyncExternalStore( subscribeAgentAwarenessRegistrationStatus, @@ -87,8 +120,24 @@ function ConfiguredSettingsNotificationsRouteScreen() { const navigation = useNavigation(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { savedConnectionsById } = useSavedRemoteConnections(); - const [notificationStatus, setNotificationStatus] = useState("checking"); - const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); + const notificationStatus = useAtomValue(notificationStatusAtom); + const setNotificationStatus = useAtomSet(notificationStatusAtom); + // A write in progress or a failed one shows its own status until the auth or + // preference state it was based on changes. + const [liveActivityOverride, setLiveActivityOverride] = useState<{ + readonly status: LiveActivityStatus; + readonly basis: LiveActivityBasis; + } | null>(null); + const liveActivityBasis: LiveActivityBasis = { isLoaded, isSignedIn, preferencesResult }; + const liveActivityStatus = + liveActivityOverride !== null && + liveActivityOverride.basis.isLoaded === isLoaded && + liveActivityOverride.basis.isSignedIn === isSignedIn && + liveActivityOverride.basis.preferencesResult === preferencesResult + ? liveActivityOverride.status + : liveActivityStatusFrom(isLoaded, isSignedIn, preferencesResult); + const setLiveActivityStatus = (status: LiveActivityStatus) => + setLiveActivityOverride({ status, basis: liveActivityBasis }); const liveActivityWriteInFlight = useRef(false); const deviceRegistered = useDeviceRegistered(); const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) @@ -101,53 +150,6 @@ function ConfiguredSettingsNotificationsRouteScreen() { const connections = Object.values(savedConnectionsById); const environmentCount = connections.length; - const refreshNotifications = async () => { - if (Platform.OS !== "ios" && Platform.OS !== "android") { - setNotificationStatus("unsupported"); - return; - } - const result = await settlePromise(() => Notifications.getPermissionsAsync()); - if (result._tag === "Failure") { - reportAtomCommandResult(result, { label: "notification permission refresh" }); - setNotificationStatus("disabled"); - return; - } - setNotificationStatus(result.value.granted ? "enabled" : "disabled"); - }; - - useEffect(() => { - // oxlint-disable-next-line react/set-state-in-effect -- Reads native notification permission on mount and on every foreground. - void refreshNotifications(); - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") void refreshNotifications(); - }); - return () => subscription.remove(); - }, [refreshNotifications]); - - useEffect(() => { - if (!isLoaded) { - // oxlint-disable-next-line react/set-state-in-effect -- Live Activity status shows checking until stored preferences load. - setLiveActivityStatus("checking"); - return; - } - if (!isSignedIn) { - setLiveActivityStatus("signed-out"); - return; - } - if (!AsyncResult.isSuccess(preferencesResult)) { - if (AsyncResult.isFailure(preferencesResult)) { - reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); - setLiveActivityStatus("enabled"); - } else { - setLiveActivityStatus("checking"); - } - return; - } - setLiveActivityStatus( - preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", - ); - }, [isLoaded, isSignedIn, preferencesResult]); - const requestNotifications = async () => { const result = await settleAsyncResult(() => runtime.runPromiseExit( diff --git a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx index e9774d37d4d14..7c16fc16be2c4 100644 --- a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx @@ -11,7 +11,7 @@ import { thirdPartyLicenseEntryKey, type ThirdPartyLicenseEntry, } from "@t3tools/shared/thirdPartyLicenses"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Linking, Platform, Pressable, type TextInputInstance, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -61,22 +61,16 @@ export function SettingsOpenSourceLicensesRouteScreen() { const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const manifest = useMobileThirdPartyLicenses(); const entries = manifest?.entries ?? []; - const filteredEntries = useMemo( - () => filterThirdPartyLicenseEntries(entries, query), - [entries, query], - ); - const renderItem = useCallback( - ({ item }: { readonly item: ThirdPartyLicenseEntry }) => ( - - navigation.navigate("SettingsOpenSourceLicense", { - entryKey: thirdPartyLicenseEntryKey(item), - }) - } - /> - ), - [navigation], + const filteredEntries = filterThirdPartyLicenseEntries(entries, query); + const renderItem = ({ item }: { readonly item: ThirdPartyLicenseEntry }) => ( + + navigation.navigate("SettingsOpenSourceLicense", { + entryKey: thirdPartyLicenseEntryKey(item), + }) + } + /> ); if (!manifest) { diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index 50ea1e2a9dfd2..9d49650e1a67a 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -10,8 +10,9 @@ import { resolveMobileProjectGroupingSettings, } from "../../state/project-grouping"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; +import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; +import { NewThreadSettingsSection, WorkingSectionSettings } from "./ThreadSettingsSections"; const GROUPING_OPTIONS: ReadonlyArray<{ readonly mode: SidebarProjectGroupingMode; @@ -45,7 +46,7 @@ export function SettingsProjectGroupingRouteScreen() { : null; return ( - + ))} + + ); diff --git a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx index c252ddd81b398..5eecd33e3837d 100644 --- a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx @@ -33,7 +33,7 @@ import { import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; -import { SettingsActionRow } from "./components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { AndroidSettingsEnvironmentFilter, SettingsEnvironmentFilterHeader, @@ -83,7 +83,7 @@ export function SettingsProjectOverviewRouteScreen() { return ( <> - }> + }> - {members.length === 0 ? ( + {selectedProjectKey === null ? ( + + Choose a project with the filter above to manage its name, checkouts and removal. + + ) : members.length === 0 ? ( This project has no checkout on the selected connected environments. Change the filter above. diff --git a/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx index 25cd506a55366..6dfb8745ed967 100644 --- a/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx @@ -11,17 +11,22 @@ import { } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useRef, useState } from "react"; -import { Linking, Pressable, ScrollView, TextInput, View } from "react-native"; +import { Linking, Pressable, ScrollView, TextInput, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { MenuAction } from "@react-native-menu/menu"; + +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppText as Text } from "../../components/ui/AppText"; +import { ControlPill } from "../../components/ui/ControlPill"; +import { Menu } from "../../components/ui/Menu"; import { ScreenScrollView } from "../../components/ui/ScreenScrollView"; import { environmentPresentations } from "../../state/presentation"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { useEnvironmentsWithScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; -import { SettingsActionRow } from "./components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { AndroidSettingsEnvironmentFilter, SettingsEnvironmentFilterHeader, @@ -38,7 +43,7 @@ export function SettingsProviderAccountsRouteScreen() { return ( <> - }> + }> {(providersByEnvironment.get(environment.environmentId) ?? []) - .filter( - (provider) => - provider.setup?.canAuthenticate || - (provider.driver === "acpRegistry" && provider.installed), - ) - .map((provider) => ( + .filter(canManageProvider) + .map((provider, index) => ( 0} /> ))} {!(providersByEnvironment.get(environment.environmentId) ?? []).some( - (provider) => - provider.setup?.canAuthenticate || - (provider.driver === "acpRegistry" && provider.installed), + canManageProvider, ) ? ( Configure a provider with in-app sign-in in web or desktop Settings. @@ -92,12 +92,20 @@ export function SettingsProviderAccountsRouteScreen() { ); } +function canManageProvider(provider: ServerProvider) { + return Boolean( + provider.setup?.canAuthenticate || (provider.driver === "acpRegistry" && provider.installed), + ); +} + function ProviderAccount({ environment, provider, + divider, }: { readonly environment: SettingsTarget; readonly provider: ServerProvider; + readonly divider: boolean; }) { const environmentId = environment.environmentId; const instanceId = provider.instanceId; @@ -109,8 +117,9 @@ function ProviderAccount({ const complete = useAtomCommand(serverEnvironment.completeProviderAuth, options); const cancel = useAtomCommand(serverEnvironment.cancelProviderAuth, options); const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, options); + // Matches ListRowLayout: large text stacks the action below instead of squeezing the status. + const stacked = useWindowDimensions().fontScale >= 1.5; const [pending, setPending] = useState(false); - const [choosingMethod, setChoosingMethod] = useState(false); const pendingRef = useRef(false); const [error, setError] = useState(null); const [draft, setDraft] = useState({ id: "", values: {} as Record }); @@ -180,120 +189,238 @@ function ProviderAccount({ /> ); } - function chooseMethod() { - const methods = state?.methods ?? []; - if (methods.length <= 1) { - void run(() => start(target)); - return; - } - setChoosingMethod(true); + const name = provider.displayName ?? provider.driver; + const email = signedIn && !active && provider.enabled ? provider.auth.email?.trim() : undefined; + const methods = state?.methods ?? []; + // The status line says why a provider that is missing or turned off can't sign in. + const canSignIn = + !active && + !needsExternalSetup && + provider.setup?.canAuthenticate !== false && + provider.enabled && + provider.installed; + const signInDisabled = disabled || state === null; + const canSignOut = + !active && signedIn && (provider.auth.canLogout ?? provider.setup?.canAuthenticate); + const showCallback = + Boolean(url) && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction); + const status = + active || state?.phase === "failed" || state?.phase === "cancelled" + ? state.message + : !provider.enabled + ? "Turned off" + : signedIn + ? "Signed in" + : isDiscovering + ? "Discovering sign-in methods…" + : needsExternalSetup + ? "No in-app sign-in advertised. Follow the provider's docs to finish setup." + : !provider.installed + ? "Not installed" + : "Not signed in"; + // A provider with several sign-in methods picks one from a menu on the same control. + const methodActions: MenuAction[] = methods.map((method) => ({ + id: `method:${method.id}`, + title: method.name, + })); + function startSignIn(methodId?: string) { + void run(() => start(methodId ? { environmentId, input: { instanceId, methodId } } : target)); + } + function signOut() { + Dialog.alert( + "Sign out?", + `Running threads sharing this sign-in on ${environment.label} will stop. Thread history is kept.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Sign out", + style: "destructive", + onPress: () => { + void run(() => logout(target)); + }, + }, + ], + ); } + function onMenuAction(id: string) { + if (id === "signOut") signOut(); + // iOS keeps a disabled submenu's children selectable, so sign-in guards here too. + else if (signInDisabled) return; + else if (id.startsWith("method:")) startSignIn(id.slice("method:".length)); + else if (id === "change") startSignIn(); + } + const accountActions: MenuAction[] = [ + ...(canSignIn + ? [ + { + id: "change", + title: "Change account", + image: "person.crop.circle", + attributes: { disabled: signInDisabled }, + ...(methodActions.length > 1 + ? { + subactions: methodActions.map((action) => ({ + ...action, + attributes: { disabled: signInDisabled }, + })), + } + : {}), + }, + ] + : []), + ...(canSignOut + ? [ + { + id: "signOut", + title: "Sign out", + image: "rectangle.portrait.and.arrow.right", + attributes: { destructive: true, disabled: disabled || state === null }, + }, + ] + : []), + ]; + const trailing = pending ? ( + + + + ) : needsExternalSetup && provider.setup?.documentationUrl ? ( + { + void Linking.openURL(provider.setup!.documentationUrl!).catch(() => + setError("Could not open the provider docs."), + ); + }} + /> + ) : active && state?.flowId ? ( + { + void run(() => cancel({ environmentId, input: { instanceId, flowId: state.flowId! } })); + }} + /> + ) : signedIn && !active && accountActions.length > 0 ? ( + onMenuAction(nativeEvent.event)} + > + + + ) : canSignIn && !signedIn && methodActions.length > 1 && !signInDisabled ? ( + onMenuAction(nativeEvent.event)} + > + + + ) : canSignIn && !signedIn ? ( + startSignIn()} + /> + ) : null; return ( - - - - {provider.displayName ?? provider.driver} - - - {active || state?.phase === "failed" || state?.phase === "cancelled" - ? state.message - : signedIn - ? "Signed in." - : isDiscovering - ? "Discovering sign-in methods…" - : needsExternalSetup - ? "No in-app sign-in advertised. Follow the provider's docs to finish setup." - : "Connect this provider."} - - {signedIn && !active && provider.auth.email?.trim() ? ( - - ) : null} - {interaction?.type === "deviceCode" ? ( - - Enter code {interaction.userCode} on the sign-in page. - - ) : null} - {interaction?.type === "terminal" ? ( - <> - - - {interaction.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")} - - - {field("input", "Terminal response", true)} - { - void send({ type: "terminal", data: `${values.input ?? ""}\r` }).then((sent) => { - if (sent) setDraft({ id: "", values: {} }); - }); - }} - /> - - ) : null} - {interaction?.type === "credentials" ? ( - <> - {interaction.fields.map((entry) => ( - {field(entry.name, entry.label, entry.secret)} - ))} - { - void send({ type: "credentials", values }).then((sent) => { - if (sent) setDraft({ id: "", values: {} }); - }); - }} - /> - - ) : null} - {url && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction) ? ( - <> - {field("callback", "Final localhost URL", false)} - { - if (!state?.flowId) return; - void run(() => - complete({ - environmentId, - input: { instanceId, flowId: state.flowId!, callbackUrl: values.callback! }, - }), - ).then((sent) => { - if (sent) setDraft({ id: "", values: {} }); - }); - }} - /> - - ) : null} - {error || auth.error ? ( - - {error ?? auth.error} - - ) : null} + + + + {trailing} - {choosingMethod && !active ? ( - - {state?.methods?.map((method) => ( - { - setChoosingMethod(false); - void run(() => - start({ environmentId, input: { instanceId, methodId: method.id } }), - ); - }} - /> - ))} - setChoosingMethod(false)} /> + {interaction?.type === "deviceCode" || + interaction?.type === "terminal" || + interaction?.type === "credentials" || + showCallback || + error || + auth.error ? ( + + {interaction?.type === "deviceCode" ? ( + + Enter code {interaction.userCode} on the sign-in page. + + ) : null} + {interaction?.type === "terminal" ? ( + <> + + + {interaction.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")} + + + {field("input", "Terminal response", true)} + { + void send({ type: "terminal", data: `${values.input ?? ""}\r` }).then((sent) => { + if (sent) setDraft({ id: "", values: {} }); + }); + }} + /> + + ) : null} + {interaction?.type === "credentials" ? ( + <> + {interaction.fields.map((entry) => ( + {field(entry.name, entry.label, entry.secret)} + ))} + { + void send({ type: "credentials", values }).then((sent) => { + if (sent) setDraft({ id: "", values: {} }); + }); + }} + /> + + ) : null} + {showCallback ? ( + <> + {field("callback", "Final localhost URL", false)} + { + if (!state?.flowId) return; + void run(() => + complete({ + environmentId, + input: { instanceId, flowId: state.flowId!, callbackUrl: values.callback! }, + }), + ).then((sent) => { + if (sent) setDraft({ id: "", values: {} }); + }); + }} + /> + + ) : null} + {error || auth.error ? ( + + {error ?? auth.error} + + ) : null} ) : null} {url ? ( @@ -314,72 +441,50 @@ function ProviderAccount({ }} /> ) : null} - {needsExternalSetup && provider.setup?.documentationUrl ? ( - { - void Linking.openURL(provider.setup!.documentationUrl!).catch(() => - setError("Could not open the provider docs."), - ); - }} - /> - ) : active && state?.flowId ? ( - { - void run(() => cancel({ environmentId, input: { instanceId, flowId: state.flowId! } })); - }} - /> - ) : !active && !needsExternalSetup && provider.setup?.canAuthenticate !== false ? ( - - ) : null} - {!active && signedIn && (provider.auth.canLogout ?? provider.setup?.canAuthenticate) ? ( - - Dialog.alert( - "Sign out?", - `Running threads sharing this sign-in on ${environment.label} will stop. Thread history is kept.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Sign out", - style: "destructive", - onPress: () => { - void run(() => logout(target)); - }, - }, - ], - ) - } - /> - ) : null} ); } -function ProviderAccountEmail({ email }: { readonly email: string }) { +function ProviderAccountSummary({ + name, + status, + email, +}: { + readonly name: string; + readonly status: string | null | undefined; + readonly email: string | undefined; +}) { const [revealed, setRevealed] = useState(false); + const content = ( + <> + + {name} + + + {email ? `${status} · ${revealed ? email : "••••••@••••••"}` : status} + + + ); + if (!email) { + return ( + + {content} + + ); + } return ( setRevealed((value) => !value)} - className="min-h-[44px] justify-center" + className="min-w-0 shrink grow gap-0.5" > - {revealed ? email : "••••••@••••••"} + {content} ); } diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts deleted file mode 100644 index e976a44b6ba3b..0000000000000 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { resolveAutoSettleReferenceEnvironmentId } from "./SettingsRouteScreen.logic"; - -describe("resolveAutoSettleReferenceEnvironmentId", () => { - const firstId = EnvironmentId.make("first"); - const secondId = EnvironmentId.make("second"); - - it("waits for an earlier grant before exposing a later writable reference", () => { - const second = { environmentId: secondId, canWriteSettings: true }; - expect( - resolveAutoSettleReferenceEnvironmentId([ - { environmentId: firstId, canWriteSettings: null }, - second, - ]), - ).toBeNull(); - expect( - resolveAutoSettleReferenceEnvironmentId([ - { environmentId: firstId, canWriteSettings: false }, - second, - ]), - ).toBe(secondId); - expect( - resolveAutoSettleReferenceEnvironmentId([ - { environmentId: firstId, canWriteSettings: true }, - second, - ]), - ).toBe(firstId); - }); - - it("waits before showing a read-only fallback until all grants are resolved", () => { - expect( - resolveAutoSettleReferenceEnvironmentId([ - { environmentId: firstId, canWriteSettings: false }, - { environmentId: secondId, canWriteSettings: null }, - ]), - ).toBeNull(); - expect( - resolveAutoSettleReferenceEnvironmentId([ - { environmentId: firstId, canWriteSettings: false }, - { environmentId: secondId, canWriteSettings: false }, - ]), - ).toBe(firstId); - }); -}); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts index ede951861cdbe..6cdf8a3e1f863 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts @@ -1,18 +1,3 @@ -import type { EnvironmentId } from "@t3tools/contracts"; - -export function resolveAutoSettleReferenceEnvironmentId( - environments: ReadonlyArray<{ - readonly environmentId: EnvironmentId; - readonly canWriteSettings: boolean | null; - }>, -): EnvironmentId | null { - for (const environment of environments) { - if (environment.canWriteSettings === null) return null; - if (environment.canWriteSettings) return environment.environmentId; - } - return environments[0]?.environmentId ?? null; -} - export function resolveAgentAwarenessPlatformPresentation(platform: string): { readonly supported: boolean; readonly subtitle: string | undefined; diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 2f7406d5b7820..34de55fd44082 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -12,23 +12,19 @@ import { currentWorkspaceSelections } from "../workspace/workspace-selection-sto import { rememberedSettingsPage } from "../workspace/workspace-tab-navigation"; import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { - ScreenScrollView as ScrollView, - ScreenScrollViewBottomInset, -} from "../../components/ui/ScreenScrollView"; +import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { EmptyState } from "../../components/ui/EmptyState"; import { useAuth, useUser } from "@clerk/expo"; import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { use, useMemo, type ComponentProps } from "react"; +import { use, type ComponentProps } from "react"; import { Platform, View } from "react-native"; import { useWorkspaceSurface } from "../workspace/useWorkspaceSurface"; -import { useDeviceType } from "../../native/native-layout-metrics"; -import { useCompactWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection, SettingsSidebarContext } from "./components/SettingsSection"; @@ -43,10 +39,6 @@ import { filterSettingsSections } from "./settings-search"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; export function SettingsRouteScreen() { - const surface = useWorkspaceSurface(); - const deviceType = useDeviceType(); - const compactTabs = useCompactWorkspaceTabs(); - const workspace = useWorkspaceTabs(); const { layout } = useAdaptiveWorkspaceLayout(); const inSidebar = use(NativePrimaryColumnContext) !== null && layout.usesSplitView; // The page open beside the list: the route right above the Settings tab's route. While @@ -58,7 +50,18 @@ export function SettingsRouteScreen() { ? rememberedSettingsPage(currentWorkspaceSelections()) : (state.routes[list + 1]?.name ?? null); }); - const sidebar = useMemo(() => (inSidebar ? { selected } : null), [inSidebar, selected]); + return ; +} + +export function SettingsListScreen(props: { + readonly sidebar: boolean; + readonly selected: string | null; +}) { + const surface = useWorkspaceSurface(); + const workspace = useWorkspaceTabs(); + const inSidebar = props.sidebar; + const selected = props.selected; + const sidebar = inSidebar ? { selected } : null; const content = hasCloudPublicConfig() ? ( ) : ( @@ -69,28 +72,27 @@ export function SettingsRouteScreen() { - - {Platform.OS === "android" ? ( - } - search={ - workspace - ? { - value: workspace.state.tabs.settings.query, - onChangeText: (query) => - workspace.dispatch({ type: "query", tab: "settings", query }), - placeholder: "Search settings", - } - : undefined - } - > - {content} - - ) : ( - content - )} - + {Platform.OS === "android" ? ( + } + search={ + workspace + ? { + value: workspace.state.tabs.settings.query, + onChangeText: (query) => + workspace.dispatch({ type: "query", tab: "settings", query }), + placeholder: "Search settings", + } + : undefined + } + > + {content} + + ) : ( + content + )} ); @@ -159,6 +161,8 @@ function SettingsList(props: { readonly connections: readonly SettingsListRow[] // Grouped cards only lift off the screen surface; the drawer is as dark as they are. const listSurface = use(SettingsSidebarContext) ? "bg-drawer" : "bg-screen"; const insets = useSafeAreaInsets(); + // UIKit insets the scroll view by its column's safe area, which this context can report as 0. + const bottomSafeArea = useNativeColumnLayoutMetrics()?.safeArea.bottom ?? insets.bottom; const workspace = useWorkspaceTabs(); const query = workspace?.state.tabs.settings.query ?? ""; const overflowRows = useOverflowRows(); @@ -178,15 +182,16 @@ function SettingsList(props: { readonly connections: readonly SettingsListRow[] contentContainerClassName="gap-4 px-5 pt-4" contentContainerStyle={{ // The workspace's clearance keeps the last row above its search field and keyboard. - paddingBottom: Math.max( - Math.max(insets.bottom, 18) + 18, - workspace?.bottomClearance ?? 0, - ), + paddingBottom: + Platform.OS === "ios" + ? Math.max(Math.max(bottomSafeArea, 18) + 18, workspace?.bottomClearance ?? 0) - + bottomSafeArea + : Math.max(Math.max(insets.bottom, 18) + 18, workspace?.bottomClearance ?? 0), }} > {sections.length ? ( - sections.map((section) => ( - + sections.map((section, index) => ( + {section.rows.map(({ id, ...row }) => ( ))} @@ -229,11 +234,13 @@ function useSettingsIndexSections(): SettingsListSection[] { ): SettingsListRow => ({ id: target, icon, label, target, ...extra }); const serverPage = (target: SettingsPageTarget, icon: SettingsListRow["icon"], label: string) => page(target, icon, label, { disabled: noServerTargets }); + // The same pages and groups as Settings on web, minus what mobile has no setting for. return [ { - title: "Interface", rows: [ + page("SettingsOrganization", "gearshape", "General"), page("SettingsAppearance", "paintbrush", "Appearance"), + page("SettingsFollowUp", "bubble.left.and.bubble.right", "Chat"), ...(navigationCustomizationAvailable() ? [page("SettingsNavigation", "line.3.horizontal", "Navigation tabs")] : []), @@ -246,34 +253,33 @@ function useSettingsIndexSections(): SettingsListSection[] { ], }, { - title: "Automations", - rows: [page("SettingsScheduledTasks", "clock", "Scheduled tasks")], - }, - { - title: "Projects & threads", + title: "Agents", rows: [ - ...(selectedProjectKey !== null - ? [page("SettingsProjectOverview", "folder", "Overview", { value: projectLabel })] - : []), - page("SettingsOrganization", "folder", "Organization"), - page("SettingsThreads", "text.bubble", "Thread behavior"), - page("SettingsFollowUp", "arrow.turn.left.up", "Follow-ups"), - page("SettingsArchive", "archivebox", "Archived Threads"), + serverPage("SettingsProviderAccounts", "person.crop.circle", "Providers"), + serverPage("SettingsThreads", "text.bubble", "Threads"), + page("SettingsScheduledTasks", "clock", "Scheduled tasks"), ], }, { - title: "Server settings", + title: "Code", rows: [ - serverPage("SettingsProviderAccounts", "person.crop.circle", "Provider accounts"), - serverPage("SettingsEnvironmentNewThreads", "text.bubble", "New threads"), + page( + "SettingsProjectOverview", + "folder", + "Projects", + selectedProjectKey !== null ? { value: projectLabel } : undefined, + ), serverPage("SettingsEnvironmentSourceControl", "arrow.triangle.branch", "Source control"), - serverPage("SettingsEnvironmentAgentBehavior", "text.alignleft", "Agent behavior"), - serverPage("SettingsEnvironmentMaintenance", "arrow.clockwise", "Maintenance"), + serverPage("SettingsWorktrees", "square.on.square", "Worktrees"), ], }, { - title: "App", - rows: [page("SettingsAbout", "info.circle", "About T3 Code")], + rows: [ + serverPage("SettingsEnvironmentMaintenance", "arrow.clockwise", "Maintenance"), + page("SettingsLabs", "sparkles", "Labs"), + page("SettingsAbout", "info.circle", "About"), + page("SettingsArchive", "archivebox", "Archive"), + ], }, ]; } diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index bc768b32ff875..583ab61cf7d2f 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -186,7 +186,11 @@ function SelectRow(props: { const value = ( {props.valueIcon} - + {props.value} @@ -200,7 +204,9 @@ function SelectRow(props: { : "min-h-14 flex-row items-center gap-3 px-4 py-3" } > - {props.label} + + {props.label} + {value} ); @@ -219,7 +225,9 @@ function SelectRow(props: { : "min-h-14 flex-row items-center gap-3 px-4 py-3 active:opacity-70" } > - {props.label} + + {props.label} + {value} props.onChange([...props.filters, emptyWebhookFilterDraft()])} className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" > - Add filter + + Add filter + ) : null} @@ -330,8 +340,14 @@ function PickerRow(props: { : "min-h-14 flex-row items-center gap-3 px-4 py-3 active:opacity-70 disabled:opacity-50" } > - {props.label} - + + {props.label} + + {props.value} @@ -935,6 +951,7 @@ function TaskForm({ setTimePickerOpen((open) => !open)} className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" > - Time - + + Time + + {formatTime(draft.schedule.timeOfDay)} )} - Enabled + + Enabled + void tryCopyTextWithHaptic(resolved.address)} className="gap-1 active:opacity-70" > - + {resolved.copyable ? "Webhook URL" : "Webhook path"} @@ -1310,8 +1334,8 @@ function EnvironmentTasks({ key={task.id} className={ index === 0 - ? "flex-row items-start gap-1 px-4 py-4" - : "flex-row items-start gap-1 border-t border-border-subtle px-4 py-4" + ? "flex-row items-start gap-1 px-4 py-3" + : "flex-row items-start gap-1 border-t border-border-subtle px-4 py-3" } > - + {task.title} diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 6cf6a8853d5ae..4fa4874da13b9 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -12,6 +12,7 @@ const { Children, mocks } = vi.hoisted(() => ({ updateSettings: vi.fn(), alert: vi.fn(), targets: [] as ReturnType[], + readOnly: new Set(), projectSelected: false, }, })); @@ -44,13 +45,22 @@ vi.mock("../../state/preferences", () => ({ updateMobilePreferencesAtom: {}, })); vi.mock("./components/AutoSettleDaysField", () => ({ AutoSettleDaysField: () => null })); +vi.mock("./components/SourceControlSignInSection", () => ({ + SourceControlSignInSection: () => null, +})); vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) })); vi.mock("../../state/session", () => ({ readEnvironmentScope: () => true, - useEnvironmentsWithScope: () => new Set(mocks.targets.map((target) => target.environmentId)), + useEnvironmentsWithScope: () => + new Set( + mocks.targets + .map((target) => target.environmentId) + .filter((environmentId) => !mocks.readOnly.has(environmentId)), + ), })); vi.mock("../../state/server", () => ({ serverEnvironment: { updateSettings: {} } })); +vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environments: [] }) })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.updateSettings, })); @@ -79,7 +89,7 @@ vi.mock("./components/SettingsEnvironmentFilterHeader", () => ({ })); vi.mock("./components/DatabaseStorageNotice", () => ({ DatabaseStorageNotice: () => null })); vi.mock("./components/SettingsSection", () => ({ SettingsSection: Children })); -vi.mock("./components/SettingsChoiceRow", () => ({ SettingsChoiceRow: () => null })); +vi.mock("../../components/ui/ListChoiceRow", () => ({ ListChoiceRow: () => null })); vi.mock("./components/SettingsSwitchRow", () => ({ SettingsSwitchRow: ({ label, @@ -125,7 +135,10 @@ vi.mock("./settings-environment-filter", () => ({ }), })); -import { SettingsEnvironmentSourceControlRouteScreen } from "./SettingsServerControlsRouteScreen"; +import { + SettingsThreadsRouteScreen, + SettingsWorktreesRouteScreen, +} from "./SettingsServerControlsRouteScreen"; function environment(environmentId: string, label: string) { return { @@ -154,6 +167,7 @@ beforeEach(() => { root = createRoot(container); mocks.targets = [environment("local", "Local")]; mocks.projectSelected = false; + mocks.readOnly = new Set(); mocks.alert.mockReset(); mocks.updateSettings.mockReset(); mocks.updateSettings.mockResolvedValue(AsyncResult.success(undefined)); @@ -167,7 +181,7 @@ describe("SettingsServerControlsRouteScreen", () => { it("sends a draft saved during a pending write once that write settles", async () => { const pending = Promise.withResolvers>(); mocks.updateSettings.mockReturnValueOnce(pending.promise); - act(() => root.render()); + act(() => root.render()); act(() => mocks.branchNaming.onChange({ branchNamingMode: "custom" })); act(() => mocks.branchNaming.onChange({ branchNameInstructions: "Use issue IDs." })); @@ -183,4 +197,36 @@ describe("SettingsServerControlsRouteScreen", () => { }, }); }); + + it("holds auto-settle changes until a pending project override write settles", async () => { + mocks.projectSelected = true; + const pending = Promise.withResolvers>(); + mocks.updateSettings.mockReturnValueOnce(pending.promise); + act(() => root.render()); + const toggle = (label: string) => + [...container.querySelectorAll('[role="switch"]')].find( + (button) => button.textContent === label, + )!; + + act(() => toggle("Agent browser access").click()); + act(() => toggle("Auto-settle merged threads").click()); + + // Both rewrite the whole project override, so a second write planned now would drop the first. + expect(mocks.updateSettings).toHaveBeenCalledOnce(); + await act(async () => pending.resolve(AsyncResult.success(undefined))); + }); + + it("disables auto-settle when a selected environment cannot save settings", () => { + const remote = environment("remote", "Remote"); + // Auto-settle rows skip a server without auto-settlement, but the page still saves to it. + remote.serverConfig.environment.capabilities.threadAutoSettlement = false; + mocks.targets = [environment("local", "Local"), remote]; + mocks.readOnly = new Set(["remote"]); + act(() => root.render()); + + const autoSettle = [...container.querySelectorAll('[role="switch"]')].find( + (button) => button.textContent === "Auto-settle merged threads", + )!; + expect(autoSettle.disabled).toBe(true); + }); }); diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 676f42d35eba5..91b0b1b044e58 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -25,9 +25,10 @@ import { } from "./components/SettingsEnvironmentFilterHeader"; import { BranchNamingSettings } from "./components/BranchNamingSettings"; import { DatabaseStorageNotice } from "./components/DatabaseStorageNotice"; -import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; +import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { SourceControlSignInSection } from "./components/SourceControlSignInSection"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { useSettingsWriter } from "./useSettingsWriter"; @@ -39,27 +40,35 @@ import { type ScopedMobileSettingsTarget, } from "./settings-scoped-server"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; +import { AutoSettleSettingsRows } from "./ThreadSettingsSections"; +import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; -type SettingsPage = "new-threads" | "source-control" | "agent-behavior" | "maintenance"; +type SettingsPage = "threads" | "source-control" | "worktrees" | "maintenance"; const PAGE_TITLES: Record = { - "new-threads": "New threads", + threads: "Threads", "source-control": "Source control", - "agent-behavior": "Agent behavior", + worktrees: "Worktrees", maintenance: "Maintenance", }; const PAGE_PROJECT_KEYS: Record = { - "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], - "source-control": [ - "defaultAutoPull", - "removeAgentCreditsOnMerge", + threads: [ + "defaultThreadEnvMode", + "defaultRuntimeMode", + "responseStreamingMode", + "enableAgentBrowserAccess", + "sidebarAutoSettleOnMerge", + "sidebarAutoSettleAfterDays", + ], + "source-control": ["defaultAutoPull", "removeAgentCreditsOnMerge"], + worktrees: [ "newWorktreesStartFromOrigin", + "worktreeSubmodules", "branchNamingMode", "branchNamePrefix", "branchNameInstructions", ], - "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], maintenance: ["continueThreadsAfterServerUpdate"], }; @@ -121,16 +130,42 @@ const STREAMING_CHOICES: ReadonlyArray<{ }, ]; -export function SettingsEnvironmentNewThreadsRouteScreen() { - return ; +// Only servers with thread auto-settlement store these, so other targets get the rest of a patch. +const AUTO_SETTLE_SETTING_KEYS: ReadonlySet = new Set([ + "sidebarAutoSettleOnMerge", + "sidebarAutoSettleAfterDays", + "autoResumeLimitedThreads", + "snoozeLimitedThreads", +]); + +function planServerSettingsWrites( + targets: readonly ScopedMobileSettingsTarget[], + projectSelected: boolean, + patch: ServerSettingsPatch, +) { + const capable = targets.filter((target) => supportsSharedSettingsSync(target.environment)); + const others = targets.filter((target) => !supportsSharedSettingsSync(target.environment)); + const othersPatch = Object.fromEntries( + Object.entries(patch).filter(([key]) => !AUTO_SETTLE_SETTING_KEYS.has(key)), + ) as ServerSettingsPatch; + return [ + ...planMobileScopedSettingsPatch(capable, projectSelected, patch), + ...(Object.keys(othersPatch).length > 0 + ? planMobileScopedSettingsPatch(others, projectSelected, othersPatch) + : []), + ]; +} + +export function SettingsThreadsRouteScreen() { + return ; } export function SettingsEnvironmentSourceControlRouteScreen() { return ; } -export function SettingsEnvironmentAgentBehaviorRouteScreen() { - return ; +export function SettingsWorktreesRouteScreen() { + return ; } export function SettingsEnvironmentMaintenanceRouteScreen() { @@ -187,7 +222,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) ) return; - const writes = planMobileScopedSettingsPatch(targets, projectSelected, patch); + const writes = planServerSettingsWrites(targets, projectSelected, patch); if (writes.length === 0) return; writeInFlight.current = true; setPendingTargets(targets); @@ -224,19 +259,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { setPendingWrites((count) => count - 1); }); }; - const supportsProjectOverrides = targets.every( - (target) => - target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, - ); - const disabled = - !canWriteSettings || - pendingWrites > 0 || - !hasConnectedSelection || - (projectSelected && !supportsProjectOverrides); - const supportsContinuation = targets.every( - (target) => - target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true, - ); + const disabled = !canWriteSettings || pendingWrites > 0 || !hasConnectedSelection; const disabledFor = (key: string) => disabled || (projectSelected && @@ -272,13 +295,12 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { hasOverrides={targets.some((target) => PAGE_PROJECT_KEYS[props.page].some((key) => target.sources[key] === "project"), )} - supportsOverrides={supportsProjectOverrides} pending={pendingWrites > 0} disabled={!canWriteSettings} onClear={clearProjectOverrides} /> ) : null} - {props.page === "new-threads" ? ( + {props.page === "threads" ? ( <> ) : null } > - {SUBMODULE_CHOICES.filter( - (choice) => choice.mode !== null || !projectSelected, - ).map((choice, index) => ( + {RUNTIME_MODE_CHOICES.map((choice, index) => ( 0} - disabled={disabledFor("worktreeSubmodules")} - onPress={() => write({ worktreeSubmodules: choice.mode })} + disabled={disabledFor("defaultRuntimeMode")} + onPress={() => write({ defaultRuntimeMode: choice.mode })} /> ))} ) : null } > - {RUNTIME_MODE_CHOICES.map((choice, index) => ( + {STREAMING_CHOICES.map((choice, index) => ( 0} - disabled={disabledFor("defaultRuntimeMode")} - onPress={() => write({ defaultRuntimeMode: choice.mode })} + disabled={disabledFor("responseStreamingMode")} + onPress={() => write({ responseStreamingMode: choice.mode })} /> ))} + + write({ enableAgentBrowserAccess: value })} + /> + + 0} /> ) : null} {props.page === "source-control" ? ( <> - + {projectSelected ? null : ( + writableEnvironments.has(environmentId)} + /> + )} write({ defaultAutoPull: value })} /> - + + ) : null} + + {props.page === "worktrees" ? ( + <> + write({ newWorktreesStartFromOrigin: value })} /> - - ) : null} - - {props.page === "agent-behavior" ? ( - <> ) : null } > - {STREAMING_CHOICES.map((choice, index) => ( + {SUBMODULE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( 0} - disabled={disabledFor("responseStreamingMode")} - onPress={() => write({ responseStreamingMode: choice.mode })} + disabled={disabledFor("worktreeSubmodules")} + onPress={() => write({ worktreeSubmodules: choice.mode })} /> ))} - - write({ enableAgentBrowserAccess: value })} - /> - + ) : null} @@ -475,15 +504,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { write({ continueThreadsAfterServerUpdate: value }) } diff --git a/apps/mobile/src/features/settings/SettingsWorkspaceLayout.android.tsx b/apps/mobile/src/features/settings/SettingsWorkspaceLayout.android.tsx new file mode 100644 index 0000000000000..77bd7bfab4c33 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsWorkspaceLayout.android.tsx @@ -0,0 +1,57 @@ +import { NavigationRouteContext } from "@react-navigation/native"; +import { Activity, useState, type ReactNode } from "react"; +import { View } from "react-native"; + +import { AppText } from "../../components/ui/AppText"; +import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; +import { useAndroidWorkspaceRoot } from "../workspace/AndroidWorkspaceTabsProvider"; +import { SettingsListScreen } from "./SettingsRouteScreen"; +import { SettingsScreen } from "./components/SettingsScreen"; + +const SETTINGS_LIST_ROUTE = { key: "settings-sidebar", name: "Settings" }; + +export function SettingsWorkspaceLayout(props: { readonly children: ReactNode }) { + const root = useAndroidWorkspaceRoot(); + const { layout } = useAdaptiveWorkspaceLayout(); + const split = root?.settingsOpen === true && layout.usesSplitView; + const [visited, setVisited] = useState(split); + if (split && !visited) setVisited(true); + const emptyDetail = split && root.settingsPage === null; + const route = root?.settingsRoute ?? SETTINGS_LIST_ROUTE; + + return ( + + {visited ? ( + + + + + + + + ) : null} + + + {props.children} + + {emptyDetail ? ( + + + + + Select a setting + + + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsWorkspaceLayout.tsx b/apps/mobile/src/features/settings/SettingsWorkspaceLayout.tsx new file mode 100644 index 0000000000000..d8a280e06a5cd --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsWorkspaceLayout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export function SettingsWorkspaceLayout(props: { readonly children: ReactNode }) { + return props.children; +} diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx similarity index 60% rename from apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx rename to apps/mobile/src/features/settings/ThreadSettingsSections.tsx index c8f3c4c7d170b..2e77d4d3937e7 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx @@ -1,84 +1,74 @@ import { Button } from "../../components/ui/Button"; -import { AuthSettingsWriteScope } from "@t3tools/contracts"; -import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; +import { AuthSettingsWriteScope, type ServerSettingsPatch } from "@t3tools/contracts"; +import { useEnvironmentsWithScope } from "../../state/session"; import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; -import { useRef, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { AppText as Text } from "../../components/ui/AppText"; +import { useEnvironments } from "../../state/environments"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "./components/SettingsSection"; -import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsScreen } from "./components/SettingsScreen"; -import { - AndroidSettingsEnvironmentFilter, - SettingsEnvironmentFilterHeader, -} from "./components/SettingsEnvironmentFilterHeader"; import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; -import { useSettingsWriter } from "./useSettingsWriter"; -import { - planMobileScopedSettingsClear, - planMobileScopedSettingsPatch, - resolveMobileSettingsTargets, - uniformMobileSetting, - type ScopedMobileSettingsTarget, -} from "./settings-scoped-server"; +import { resolveMobileSettingsTargets, uniformMobileSetting } from "./settings-scoped-server"; -export function SettingsThreadsRouteScreen() { +export function SettingsLabsRouteScreen() { const insets = useSafeAreaInsets(); return ( - <> - - }> - - - - - - - + + + + + + ); } const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; -function AutoSettleSettingsRows() { +/** + * Usage-limit and auto-settle rows. The Threads page owns their writes, so they share its queue + * with the page's other project overrides, and their project override reset. + */ +export function AutoSettleSettingsRows(props: { + readonly write: (patch: ServerSettingsPatch) => void; + readonly pending: boolean; +}) { const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope); const projectSelected = selectedProjectKey !== null; - const [pendingWrites, setPendingWrites] = useState(0); - const writeInFlight = useRef(false); - const [pendingTargets, setPendingTargets] = useState< - readonly ScopedMobileSettingsTarget[] | null - >(null); - const writeSettings = useSettingsWriter(); - const syncEnvironments = selectedTargets.filter(supportsSharedSettingsSync); - const syncTargets = resolveMobileSettingsTargets( - syncEnvironments, - projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null, + const projectMembers = projectSelected + ? (selectedProject?.members.map((member) => member.project) ?? []) + : null; + const displayTargets = resolveMobileSettingsTargets( + selectedTargets.filter(supportsSharedSettingsSync), + projectMembers, ); + // The page saves to all its targets, so any read-only one blocks the save. const canWriteSettings = - syncTargets.length > 0 && - syncTargets.every((target) => writableEnvironments.has(target.environment.environmentId)); - const displayTargets = - pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets; + displayTargets.length > 0 && + resolveMobileSettingsTargets(selectedTargets, projectMembers).every((target) => + writableEnvironments.has(target.environment.environmentId), + ); const reference = displayTargets[0] ?? null; const referenceSettings = reference?.settings ?? null; @@ -91,25 +81,7 @@ function AutoSettleSettingsRows() { autoResumeLimitedThreads?: boolean; snoozeLimitedThreads?: boolean; }, - ) => { - if ( - writeInFlight.current || - !syncTargets.every((target) => - readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), - ) - ) - return; - const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch); - if (writes.length === 0) return; - writeInFlight.current = true; - setPendingTargets(syncTargets); - setPendingWrites((count) => count + 1); - void writeSettings(syncTargets, writes).finally(() => { - writeInFlight.current = false; - setPendingTargets(null); - setPendingWrites((count) => count - 1); - }); - }; + ) => props.write(patch); const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( { @@ -125,56 +97,11 @@ function AutoSettleSettingsRows() { })), ); - const supportsProjectOverrides = syncTargets.every( - (target) => - target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, - ); - const disabled = - !canWriteSettings || pendingWrites > 0 || (projectSelected && !supportsProjectOverrides); - const hasProjectOverrides = - projectSelected && - syncTargets.some( - (target) => - target.sources.sidebarAutoSettleOnMerge === "project" || - target.sources.sidebarAutoSettleAfterDays === "project", - ); - const clearProjectOverrides = () => { - if ( - writeInFlight.current || - !syncTargets.every((target) => - readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), - ) - ) - return; - const writes = planMobileScopedSettingsClear(syncTargets, [ - "sidebarAutoSettleOnMerge", - "sidebarAutoSettleAfterDays", - ]); - if (writes.length === 0) return; - writeInFlight.current = true; - setPendingTargets(syncTargets); - setPendingWrites((count) => count + 1); - void writeSettings(syncTargets, writes).finally(() => { - writeInFlight.current = false; - setPendingTargets(null); - setPendingWrites((count) => count - 1); - }); - }; - + const disabled = !canWriteSettings || props.pending; const afterDays = referenceSettings.sidebarAutoSettleAfterDays; return ( - {projectSelected ? ( - 0} - disabled={!canWriteSettings} - onClear={clearProjectOverrides} - /> - ) : null} {!projectSelected ? ( ) : null} - {pendingWrites === 0 && mismatches.length > 0 ? ( + {!props.pending && mismatches.length > 0 ? ( Auto-settle defaults differ @@ -242,7 +169,7 @@ function AutoSettleSettingsRows() { ); } -function BetaSettingsSection() { +export function WorkingSectionSettings() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const preferences = useAtomValue(mobilePreferencesAtom); const workingShelfEnabled = @@ -250,7 +177,7 @@ function BetaSettingsSection() { return ( - + + savePreferences({ useLastProjectForNewThreads: value })} + /> + + ); +} + +/** Per machine, since each machine's own setting decides whether it links and answers links. */ +function BetaSettingsSection() { + const { environments } = useEnvironments(); + const supported = environments.filter( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.peerLinks === true, + ); + const writable = useEnvironmentsWithScope(supported, AuthSettingsWriteScope); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "linked environments beta update", + reportFailure: true, + }); + if (supported.length === 0) return null; + return ( + + + {supported.map((environment) => ( + + void updateSettings({ + environmentId: environment.environmentId, + input: { patch: { linkedEnvironmentsEnabled: value } }, + }) + } + /> + ))} + + + Let agents on a machine work on your other machines, and theirs on it. Turn it on for each + machine. While it is off, nothing new starts over a link; tasks already running finish. + + + ); +} + function LegacySettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const preferences = useAtomValue(mobilePreferencesAtom); diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index a7b2807d78226..26e17f45afb13 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,19 +1,14 @@ -import { - createContext, - startTransition, - use, - useCallback, - useEffect, - useState, - useLayoutEffect, - useMemo, - useRef, - type ReactNode, -} from "react"; +import { createContext, startTransition, use, useMemo, type ReactNode } from "react"; import { AppState, Appearance, Platform, useColorScheme } from "react-native"; +import { manufacturer } from "expo-device"; +import { + resolveAndroidNavigationStyle, + type AndroidNavigationStyle, +} from "../../../lib/androidNavigationStyle"; + import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/reactivity"; +import { Atom, AsyncResult } from "effect/reactivity"; import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind"; @@ -22,9 +17,14 @@ import { resolveAppearancePreferences, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; +import { appAtomRegistry } from "../../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; -import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette"; +import { + isSystemColorsAvailable, + readSystemColorPalettes, + type MaterialYouPalettes, +} from "../../../lib/materialYouPalette"; import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme"; import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; import type { MobileThemeVariables } from "../../../lib/mobileTheme"; @@ -49,6 +49,8 @@ interface AppearancePreferencesContextValue { readonly themeId: MobileThemeId; readonly themeIds: MobileThemeIds; readonly themeMode: MobileThemeMode; + readonly androidNavigationStyle: AndroidNavigationStyle; + readonly setAndroidNavigationStyle: (value: AndroidNavigationStyle) => void; readonly themeAppearance: MobileThemeAppearance; readonly systemColorsAvailable: boolean; readonly systemColorsActive: boolean; @@ -74,6 +76,115 @@ interface AppearancePreferencesContextValue { const AppearancePreferencesContext = createContext(null); +function storedPreferencesOf(result: AsyncResult.AsyncResult) { + return AsyncResult.isSuccess(result) ? result.value : null; +} + +/** + * Material You palettes follow the system wallpaper, which can change while the + * app is in the background. + */ +const systemColorPalettesAtom = Atom.make((get): MaterialYouPalettes | null => { + const initial = readSystemColorPalettes(); + if (!isSystemColorsAvailable) return initial; + let current = initial; + const refresh = () => { + const next = readSystemColorPalettes(); + if (JSON.stringify(current) === JSON.stringify(next)) return; + current = next; + get.setSelf(next); + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); + }); + const focusSubscription = AppState.addEventListener("focus", refresh); + get.addFinalizer(() => { + subscription.remove(); + focusSubscription.remove(); + }); + return initial; +}).pipe(Atom.withLabel("mobile:system-color-palettes")); + +function resolveThemeVariablesByAppearance( + themeIds: MobileThemeIds, + palettes: MaterialYouPalettes | null, +): Readonly> { + const resolve = (appearance: MobileThemeAppearance) => { + const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); + return themeIds[appearance] === "material-you" && palettes + ? materialYouPaletteToMobileThemeVariables(palettes[appearance], appearance, base) + : base; + }; + return { light: resolve("light"), dark: resolve("dark") }; +} + +/** The appearance whose theme is on screen, read from the native scheme. */ +export function resolveThemeAppearance(stored: Preferences | null): MobileThemeAppearance { + const themeMode = normalizeMobileThemeMode(stored?.themeMode); + if (themeMode !== "system") return themeMode; + return Appearance.getColorScheme() === "dark" ? "dark" : "light"; +} + +function runtimeStateFor(stored: Preferences | null): MobileThemeRuntimeState { + return { + baseFontSize: resolveAppearancePreferences(stored).baseFontSize, + themeMode: normalizeMobileThemeMode(stored?.themeMode), + themeAppearance: resolveThemeAppearance(stored), + }; +} + +const themeRuntimeStateAtom = Atom.make((get) => + runtimeStateFor(storedPreferencesOf(get(mobilePreferencesAtom))), +).pipe( + Atom.withEquality( + (left: MobileThemeRuntimeState, right: MobileThemeRuntimeState) => + left.baseFontSize === right.baseFontSize && + left.themeMode === right.themeMode && + left.themeAppearance === right.themeAppearance, + ), +); + +// Native appearance and Uniwind text variables are process-global, so the +// last applied state is too. Setters apply ahead of the preference write so the +// first frame after a change already uses it; the atom subscription then sees +// no difference. +let appliedRuntimeState: MobileThemeRuntimeState | null = null; + +function applyThemeRuntime(next: MobileThemeRuntimeState) { + for (const operation of createMobileThemeRuntimeOperations(appliedRuntimeState, next)) { + if (operation.kind === "update-text-variables") { + Uniwind.updateCSSVariables(operation.themeName, operation.variables); + continue; + } + if (operation.kind === "set-appearance-mode") { + Appearance.setColorScheme( + operation.themeMode === "system" ? "unspecified" : operation.appearance, + ); + } + } + appliedRuntimeState = next; +} + +function currentRuntimeState() { + return appliedRuntimeState ?? appAtomRegistry.get(themeRuntimeStateAtom); +} + +/** + * App-lifetime: applies theme preferences to the native runtime and holds the + * system palette listeners. Holding them here instead of `keepAlive` lets the + * fast-refresh disposer release the replaced module's AppState listeners. + */ +export function installAppearanceRuntime(): () => void { + const unsubscribeRuntime = appAtomRegistry.subscribe(themeRuntimeStateAtom, applyThemeRuntime, { + immediate: true, + }); + const releasePalettes = appAtomRegistry.mount(systemColorPalettesAtom); + return () => { + unsubscribeRuntime(); + releasePalettes(); + }; +} + export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -85,6 +196,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN () => resolveAppearancePreferences(storedPreferences), [storedPreferences], ); + const androidNavigationStyle = resolveAndroidNavigationStyle( + storedPreferences?.androidNavigationStyle, + manufacturer, + process.env.EXPO_PUBLIC_T3_FLOATING_TABS === "1", + ); const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode); const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode; const resolvedThemeIds = resolveMobileThemeIds(storedPreferences ?? {}); @@ -94,37 +210,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN ); const themeId = themeIds[themeAppearance]; const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; - const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); - useEffect(() => { - if (!isSystemColorsAvailable) return; - const refresh = () => { - const next = readSystemColorPalettes(); - setSystemColorPalettes((previous) => - JSON.stringify(previous) === JSON.stringify(next) ? previous : next, - ); - }; - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") refresh(); - }); - const focusSubscription = AppState.addEventListener("focus", refresh); - return () => { - subscription.remove(); - focusSubscription.remove(); - }; - }, []); - const themeVariablesByAppearance = useMemo(() => { - const resolve = (appearance: MobileThemeAppearance) => { - const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); - return themeIds[appearance] === "material-you" && systemColorPalettes - ? materialYouPaletteToMobileThemeVariables( - systemColorPalettes[appearance], - appearance, - base, - ) - : base; - }; - return { light: resolve("light"), dark: resolve("dark") }; - }, [themeIds, systemColorPalettes]); + const systemColorPalettes = useAtomValue(systemColorPalettesAtom); + const themeVariablesByAppearance = resolveThemeVariablesByAppearance( + themeIds, + systemColorPalettes, + ); const themeVariables = themeVariablesByAppearance[themeAppearance]; const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; @@ -133,141 +223,75 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize], ); const isReady = AsyncResult.isSuccess(preferencesResult); - const runtimeState = useMemo( - () => ({ - baseFontSize, - themeAppearance, - themeMode, - }), - [baseFontSize, themeAppearance, themeMode], - ); - const appliedRuntimeStateRef = useRef(null); - const selectedThemeIdsRef = useRef(themeIds); - - const applyThemeRuntime = useCallback((next: MobileThemeRuntimeState) => { - const operations = createMobileThemeRuntimeOperations(appliedRuntimeStateRef.current, next); - for (const operation of operations) { - if (operation.kind === "update-text-variables") { - Uniwind.updateCSSVariables(operation.themeName, operation.variables); - continue; - } - if (operation.kind === "set-appearance-mode") { - Appearance.setColorScheme( - operation.themeMode === "system" ? "unspecified" : operation.appearance, - ); - } - } - appliedRuntimeStateRef.current = next; - }, []); + const updatePreferences = (patch: Partial) => { + startTransition(() => savePreferences(patch)); + }; - const syncThemeRuntime = useCallback( - (next: MobileThemeRuntimeState) => applyThemeRuntime(next), - [applyThemeRuntime], - ); + const updateThemePreferences = (patch: Partial) => { + savePreferences(patch); + }; - const updatePreferences = useCallback( - (patch: Partial) => { - startTransition(() => savePreferences(patch)); - }, - [savePreferences], - ); + const setThemeIdForAppearance = (appearance: MobileThemeAppearance, value: MobileThemeId) => { + // Read at call time: a second pick before the next render must build on + // the first one's optimistic write. + const selected = resolveMobileThemeIds( + storedPreferencesOf(appAtomRegistry.get(mobilePreferencesAtom)) ?? {}, + ); + updateThemePreferences( + createMobileThemeSelectionPatch(selected, themeAppearance, appearance, value), + ); + }; - const updateThemePreferences = useCallback( - (patch: Partial) => { - savePreferences(patch); - }, - [savePreferences], - ); + const setThemeIdForBothAppearances = (value: MobileThemeId) => { + updateThemePreferences(createMobileThemePairPatch(value)); + }; - useLayoutEffect(() => { - selectedThemeIdsRef.current = themeIds; - syncThemeRuntime(runtimeState); - }, [runtimeState, syncThemeRuntime, themeIds]); - - const setThemeIdForAppearance = useCallback( - (appearance: MobileThemeAppearance, value: MobileThemeId) => { - const patch = createMobileThemeSelectionPatch( - selectedThemeIdsRef.current, - themeAppearance, - appearance, - value, - ); - selectedThemeIdsRef.current = resolveMobileThemeIds(patch); - updateThemePreferences(patch); - }, - [themeAppearance, updateThemePreferences], - ); + const setThemeMode = (value: MobileThemeMode) => { + const current = currentRuntimeState(); - const setThemeIdForBothAppearances = useCallback( - (value: MobileThemeId) => { - const patch = createMobileThemePairPatch(value); - selectedThemeIdsRef.current = resolveMobileThemeIds(patch); - updateThemePreferences(patch); - }, - [updateThemePreferences], - ); + // Clear forced appearance before publishing System so useColorScheme batches the system palette into the preference commit. + if (value === "system") { + Appearance.setColorScheme("unspecified"); + } + const nextAppearance = + value === "system" ? (Appearance.getColorScheme() === "dark" ? "dark" : "light") : value; + const next = { + ...current, + themeAppearance: nextAppearance, + themeMode: value, + }; - const setThemeMode = useCallback( - (value: MobileThemeMode) => { - const current = appliedRuntimeStateRef.current ?? runtimeState; - - // Clear forced appearance before publishing System so useColorScheme batches the system palette into the preference commit. - if (value === "system") { - Appearance.setColorScheme("unspecified"); - } - const nextAppearance = - value === "system" ? (Appearance.getColorScheme() === "dark" ? "dark" : "light") : value; - const next = { - ...current, - themeAppearance: nextAppearance, - themeMode: value, - }; - - updateThemePreferences({ themeMode: value }); - if (value === "system") { - appliedRuntimeStateRef.current = next; - } else { - syncThemeRuntime(next); - } - }, - [runtimeState, syncThemeRuntime, updateThemePreferences], - ); + if (value === "system") { + appliedRuntimeState = next; + } else { + applyThemeRuntime(next); + } + updateThemePreferences({ themeMode: value }); + }; - const setBaseFontSize = useCallback( - (value: number) => { - const current = appliedRuntimeStateRef.current ?? runtimeState; - syncThemeRuntime({ ...current, baseFontSize: value }); - updatePreferences({ baseFontSize: value }); - }, - [runtimeState, syncThemeRuntime, updatePreferences], - ); + const setAndroidNavigationStyle = (value: AndroidNavigationStyle) => + updatePreferences({ androidNavigationStyle: value }); - const setTerminalFontSize = useCallback( - (value: number | null) => { - updatePreferences({ terminalFontSize: value }); - }, - [updatePreferences], - ); + const setBaseFontSize = (value: number) => { + applyThemeRuntime({ ...currentRuntimeState(), baseFontSize: value }); + updatePreferences({ baseFontSize: value }); + }; - const setCodeFontSize = useCallback( - (value: number | null) => { - updatePreferences({ codeFontSize: value }); - }, - [updatePreferences], - ); + const setTerminalFontSize = (value: number | null) => { + updatePreferences({ terminalFontSize: value }); + }; - const setCodeWordBreak = useCallback( - (value: boolean) => { - updatePreferences({ codeWordBreak: value }); - }, - [updatePreferences], - ); + const setCodeFontSize = (value: number | null) => { + updatePreferences({ codeFontSize: value }); + }; + + const setCodeWordBreak = (value: boolean) => { + updatePreferences({ codeWordBreak: value }); + }; const mathRenderingEnabled = storedPreferences?.mathRenderingEnabled === true; - const setMathRenderingEnabled = useCallback( - (value: boolean) => updatePreferences({ mathRenderingEnabled: value }), - [updatePreferences], - ); + const setMathRenderingEnabled = (value: boolean) => + updatePreferences({ mathRenderingEnabled: value }); const value = useMemo( (): AppearancePreferencesContextValue => ({ @@ -275,6 +299,8 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeId, themeIds, themeMode, + androidNavigationStyle, + setAndroidNavigationStyle, themeAppearance, systemColorsAvailable: isSystemColorsAvailable, systemColorsActive, @@ -297,6 +323,8 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeId, themeIds, themeMode, + androidNavigationStyle, + setAndroidNavigationStyle, themeAppearance, systemColorsActive, themeVariables, @@ -331,3 +359,8 @@ export function useAppearancePreferences(): AppearancePreferencesContextValue { } return context; } + +export function useAndroidFloatingTabs() { + const { androidNavigationStyle } = useAppearancePreferences(); + return Platform.OS === "android" && androidNavigationStyle === "one-ui"; +} diff --git a/apps/mobile/src/features/settings/appearance/sections/AndroidNavigationAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/AndroidNavigationAppearanceSection.tsx new file mode 100644 index 0000000000000..6d751f2e7f886 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/AndroidNavigationAppearanceSection.tsx @@ -0,0 +1,30 @@ +import { View } from "react-native"; + +import { ListChoiceRow as SettingsChoiceRow } from "../../../../components/ui/ListChoiceRow"; +import { SettingsSection } from "../../components/SettingsSection"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; + +export function AndroidNavigationAppearanceSection() { + const { isReady, androidNavigationStyle, setAndroidNavigationStyle } = useAppearancePreferences(); + return ( + + + setAndroidNavigationStyle("one-ui")} + /> + setAndroidNavigationStyle("material")} + separated + /> + + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx index 6760504bfa282..3359d175fdb64 100644 --- a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx @@ -1,5 +1,3 @@ -import { useCallback } from "react"; - import { CODE_FONT_SIZE_STEP, MAX_CODE_FONT_SIZE, @@ -18,12 +16,9 @@ export function CodeAppearanceSection() { const { isReady, appearance, setCodeFontSize, setCodeWordBreak } = useAppearancePreferences(); const custom = appearance.isCodeFontSizeCustom; - const handleToggleCustom = useCallback( - (enabled: boolean) => { - setCodeFontSize(enabled ? appearance.codeFontSize : null); - }, - [appearance.codeFontSize, setCodeFontSize], - ); + const handleToggleCustom = (enabled: boolean) => { + setCodeFontSize(enabled ? appearance.codeFontSize : null); + }; return ( diff --git a/apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx index 1e8b5a3fbef77..dd85c51614ada 100644 --- a/apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx @@ -1,5 +1,3 @@ -import { useCallback } from "react"; - import { MAX_TERMINAL_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, @@ -18,12 +16,9 @@ export function TerminalAppearanceSection() { const { isReady, appearance, setTerminalFontSize } = useAppearancePreferences(); const custom = appearance.isTerminalFontSizeCustom; - const handleToggleCustom = useCallback( - (enabled: boolean) => { - setTerminalFontSize(enabled ? appearance.terminalFontSize : null); - }, - [appearance.terminalFontSize, setTerminalFontSize], - ); + const handleToggleCustom = (enabled: boolean) => { + setTerminalFontSize(enabled ? appearance.terminalFontSize : null); + }; return ( diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts index e8d28c0e7416f..ffeb6114c17a5 100644 --- a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts +++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts @@ -1,5 +1,3 @@ -import { useMemo } from "react"; - import { resolveScaledTextRole } from "../../../lib/appearancePreferences"; import { MOBILE_TYPOGRAPHY } from "../../../lib/typography"; import { useAppearancePreferences } from "./AppearancePreferencesProvider"; @@ -12,8 +10,5 @@ export interface ScaledTextRole { /** Avoid CSS-variable subscriptions so palette-only setTheme calls remain native-only. */ export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole { const { appearance } = useAppearancePreferences(); - return useMemo( - () => resolveScaledTextRole(role, appearance.baseFontSize), - [appearance.baseFontSize, role], - ); + return resolveScaledTextRole(role, appearance.baseFontSize); } diff --git a/apps/mobile/src/features/settings/components/BranchNamingSettings.test.tsx b/apps/mobile/src/features/settings/components/BranchNamingSettings.test.tsx index e1e65138c5bd1..de6ba2d19bf35 100644 --- a/apps/mobile/src/features/settings/components/BranchNamingSettings.test.tsx +++ b/apps/mobile/src/features/settings/components/BranchNamingSettings.test.tsx @@ -23,7 +23,7 @@ vi.mock("../../../components/ui/AppText", () => ({ return null; }, })); -vi.mock("./SettingsChoiceRow", () => ({ SettingsChoiceRow: () => null })); +vi.mock("../../../components/ui/ListChoiceRow", () => ({ ListChoiceRow: () => null })); vi.mock("./SettingsSection", () => ({ SettingsSection: ({ children }: { children: ReactNode }) =>
{children}
, })); diff --git a/apps/mobile/src/features/settings/components/BranchNamingSettings.tsx b/apps/mobile/src/features/settings/components/BranchNamingSettings.tsx index c660b3cfe1faf..d77451d55e9ec 100644 --- a/apps/mobile/src/features/settings/components/BranchNamingSettings.tsx +++ b/apps/mobile/src/features/settings/components/BranchNamingSettings.tsx @@ -3,7 +3,7 @@ import { useEffect, useEffectEvent, useRef } from "react"; import { View } from "react-native"; import { AppText as Text, AppTextInput } from "../../../components/ui/AppText"; -import { SettingsChoiceRow } from "./SettingsChoiceRow"; +import { ListChoiceRow as SettingsChoiceRow } from "../../../components/ui/ListChoiceRow"; import { SettingsSection } from "./SettingsSection"; const MODES = { @@ -50,6 +50,7 @@ export function BranchNamingSettings(props: { const saveDrafts = useEffectEvent(() => send({ ...take("branchNamePrefix"), ...take("branchNameInstructions") }), ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- saves unsent branch naming drafts to server settings when the section unmounts useEffect(() => () => saveDrafts(), []); return ( void; + onUpdate?: (event: PanEvent) => void; + onFinalize?: (event: PanEvent, success: boolean) => void; +}; + +const pans = vi.hoisted(() => [] as PanHandlers[]); + +vi.mock("react-native", () => ({ + View: ({ children }: { children?: ReactNode }) =>
{children}
, +})); +vi.mock("react-native-gesture-handler", () => ({ + GestureDetector: ({ children }: { children?: ReactNode }) => children, + Gesture: { + Pan: () => { + const handlers: PanHandlers = {}; + pans.push(handlers); + const pan = { + minDistance: () => pan, + shouldCancelWhenOutside: () => pan, + runOnJS: () => pan, + onStart: (callback: PanHandlers["onStart"]) => ((handlers.onStart = callback), pan), + onUpdate: (callback: PanHandlers["onUpdate"]) => ((handlers.onUpdate = callback), pan), + onFinalize: (callback: PanHandlers["onFinalize"]) => ( + (handlers.onFinalize = callback), + pan + ), + }; + return pan; + }, + }, +})); +vi.mock("../../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); + +import { SettingsDragHandle } from "./SettingsDragHandle"; + +describe("SettingsDragHandle", () => { + // The worklets Babel plugin copies values a gesture callback closes over when the gesture is + // built, so a drop must not read a translation tracked in a local variable. + it("reports the released translation when the pan ends", () => { + pans.length = 0; + const onEnd = vi.fn(); + const root = createRoot(document.createElement("div")); + act(() => + root.render( + {}} + onMove={() => {}} + onEnd={onEnd} + onStep={() => {}} + />, + ), + ); + const pan = pans.at(-1)!; + + act(() => { + pan.onStart?.(); + pan.onUpdate?.({ translationY: 64 }); + pan.onUpdate?.({ translationY: 130 }); + pan.onFinalize?.({ translationY: 130 }, true); + }); + + expect(onEnd).toHaveBeenCalledWith(130, false); + act(() => root.unmount()); + }); +}); diff --git a/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx b/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx index c27db4ce28e45..9a73983f9da0a 100644 --- a/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx +++ b/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx @@ -18,22 +18,17 @@ export function SettingsDragHandle(props: { const onEnd = useEffectEvent((translation: number, cancelled: boolean) => props.onEnd(translation, cancelled), ); - const [gesture] = useState(() => { - let translation = 0; - return Gesture.Pan() + // The worklets plugin copies the values these callbacks close over when the gesture is built, + // so the drop reads its translation from the final event rather than a tracked variable. + const [gesture] = useState(() => + Gesture.Pan() .minDistance(0) .shouldCancelWhenOutside(false) .runOnJS(true) - .onStart(() => { - translation = 0; - onStart(); - }) - .onUpdate((event) => { - translation = event.translationY; - onMove(event.translationY); - }) - .onFinalize((_, success) => onEnd(translation, !success)); - }); + .onStart(() => onStart()) + .onUpdate((event) => onMove(event.translationY)) + .onFinalize((event, success) => onEnd(event.translationY, !success)), + ); return ( void; @@ -18,7 +17,7 @@ export function SettingsProjectOverridesSection(props: { {props.projectLabel} - {!props.pending && props.supportsOverrides && props.hasOverrides ? ( + {!props.pending && props.hasOverrides ? (