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/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index b2f5443e431a1..66be80cd12312 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -31,11 +31,21 @@ when inspecting or seeding SQLite. Stop the test server before fixture writes. From the checkout being tested on the selected device host, run: ```bash -node scripts/mobile-native-client.ts ensure +vp run mobile:install ``` -This reuses a matching native client or builds and installs one. Authorized -mobile verification includes that build step unless the user prohibits it. +This installs the dev client for the checkout's native fingerprint: from the +device, the local cache, or the shared cache in seconds, building once only +when no machine has that fingerprint yet (5-15 minutes, logged to the file it +names). The device id may be omitted when exactly one device is booted. It +prints one JSON line; on failure it names the next command. Authorized mobile +verification includes that step unless the user prohibits it. + +For a device you opened in an agent-device session elsewhere (a device pool, +cloud, or remote host), run `vp run mobile:install --session ` +from the directory you opened the session in. It installs the cached build into +that session (`--abi arm64-v8a` for an ARM Android device; `--metro ` also +opens it against Metro) and never builds. Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging to this checkout. Open its printed development-client URL with AgentDevice 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/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 2ff10b84fd3a9..77a526076de4d 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -32,6 +32,8 @@ const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; // These tests read other packages without declaring dependencies. const TEST_ONLY_DEPS = { + // Evals import the server's source to test its real model-facing text. + "@t3tools/evals": ["t3"], "@t3tools/provider-pi": ["@t3tools/client-runtime"], "@t3tools/scripts": [ "@t3tools/desktop", 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/coil-ci.yml b/.github/workflows/coil-ci.yml index 88f3d16a5dc4a..71ba5cef40696 100644 --- a/.github/workflows/coil-ci.yml +++ b/.github/workflows/coil-ci.yml @@ -155,5 +155,8 @@ jobs: - name: Install Chromium for server browser integration tests run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium + # git 2.56 (ubuntu image 20261025+) reports a dangling `.git` file as "gitfile does not point + # to a valid repository", which upstream's storageCleanup.ts does not recognise yet. Drop the + # `-t` once upstream handles it. Keep in sync with `scripts/coil/sync-upstream.sh`. - name: Test - run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} --testTimeout=120000 --hookTimeout=120000 + run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} --testTimeout=120000 --hookTimeout=120000 -t '^(?!.*compares\sthe\schecked-out\sbranch\swithout\sa\sfull\sstatus)' diff --git a/.gitignore b/.gitignore index 26b8965ecab8b..b6b793e020ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ apps/mobile/.generated/ 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 ca86bf54bd824..cff91bdc1d2be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- The first `vp run dev` in a worktree does this for you when its database is missing. `--no-seed` or `T3CODE_DEV_SEED=0` skips it. +- The first `vp run dev` in a worktree does this for you when its database is missing and the source is at most 500 MiB. Larger sources warn and dev starts with an empty database. `--no-seed` or `T3CODE_DEV_SEED=0` skips seeding. Manual migration still accepts larger sources and may use substantial disk space before pruning. - Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. @@ -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,12 +137,18 @@ 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. -For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. +- 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 + +`evals/` checks how real models behave with T3 Code's model-facing text: agent instructions, MCP tool descriptions and schemas, and generated titles. Before you change that text, read [evals/README.md](evals/README.md) and run the evals it names. When you fix a model behavior bug, add a case that fails before the fix. ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. -- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): use `cafe` for existing PRs, issues, comments and labels, but push and open PRs on GitHub, where CI runs. +- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): push to `origin`, open and track PRs with `cafe` and their git.cafe URLs, and keep `gh` for merging. - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. @@ -200,6 +209,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/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/README.md b/README.md index 8aa78f81b9ad7..36f558a79dc60 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ We are (mostly) not accepting contributions yet. Small fixes may be considered. Full docs live in [docs/](./docs). There's no docs site yet. +- [FAQ](./docs/user/faq.md) - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) 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 05d5a3254a84f..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; } @@ -382,7 +385,10 @@ export const make = Effect.gen(function* () { webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request); }); }), - complete: (response) => Effect.sync(() => broker.complete(response)), + // The IPC handler owns the activation-completion span. + complete: Effect.fnUntraced(function* (response) { + return yield* Effect.sync(() => broker.complete(response)); + }), }); }); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 1e572412d4693..34b7286c335ed 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -28,8 +28,6 @@ vi.mock("@clerk/electron", () => ({ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); - -import * as Option from "effect/Option"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -158,6 +156,7 @@ describe("DesktopClerk", () => { `setPath:userData:${userData}`, "requestSingleInstanceLock", "on:second-instance", + "on:open-url", "createClerkBridge", ]); }, @@ -202,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>(); @@ -210,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) => @@ -222,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)), @@ -297,7 +298,7 @@ it.effect( }), } as unknown as ElectronApp.ElectronApp["Service"]; const electronWindow = { - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), reveal: () => Effect.sync(() => revealed.resolve()), } as unknown as ElectronWindow.ElectronWindow["Service"]; return Effect.gen(function* () { @@ -427,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; @@ -439,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; @@ -447,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/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index db56aa3c83b03..3cdfdb6cfa383 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -100,9 +100,15 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const command = yield* commandIn({ home }); const link = path.join(home, ".local", "bin", "t3"); - expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + expect(yield* command.state).toEqual({ + supported: true, + installedPath: null, + onPath: false, + launcherPath: null, + }); const installed = yield* command.install; expect(installed.installedPath).toBe(link); + expect(installed.launcherPath).toBe(path.join(home, ".t3", "bin", "t3")); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); expect((yield* command.install).installedPath).toBe(link); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 95d844e602124..2ae070d638263 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -150,18 +150,26 @@ export const make = Effect.gen(function* () { const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { if (!environment.isPackaged) { - return { supported: false, installedPath: null, onPath: false } as const; + return { supported: false, installedPath: null, onPath: false, launcherPath: null } as const; } + const launcherPath = (yield* exists(launcher)) ? launcher : null; const installed = yield* installedAt; const shadowedBy = yield* foreignFirstOnPath; const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; if (Option.isNone(installed)) { - return { supported: true, installedPath: null, onPath: false, ...shadow }; + return { supported: true, installedPath: null, onPath: false, launcherPath, ...shadow }; } const first = yield* firstOnPath; const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); - return { supported: true, installedPath: installed.value, onPath, ...shadow }; - }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); + return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow }; + }).pipe( + Effect.orElseSucceed(() => ({ + supported: false, + installedPath: null, + onPath: false, + launcherPath: null, + })), + ); const ensureLauncher = DesktopCliShim.install.pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), 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/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 6ddb31cb6eaac..ab8ea0b8e9d6e 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -342,4 +342,36 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), ); }); + + it.effect("describes the Node runtime and the desktop mode under semantic or t3code keys", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const layerEnvironment = layerEnvironmentFor(baseDir); + yield* writeObservabilitySettings(layerEnvironment, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop resource under test").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), + ), + ); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"process.runtime.name","value":{"stringValue":"nodejs"}'); + assert.include( + body, + `"key":"process.runtime.version","value":{"stringValue":"${process.versions.node}"}`, + ); + assert.include(body, '"key":"t3code.desktop.mode","value":{"stringValue":"development"}'); + assert.notInclude(body, "service.mode"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 6baf2c4117f9d..3cd3936a59bfc 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,11 +2,14 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, + nodeProcessRuntimeAttributes, + processServiceInstanceId, type SignalExport, } from "@t3tools/shared/observability"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SharedObservability from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -456,90 +459,91 @@ const makeBackendOutputLogShape = ( ): Effect.Effect => Option.match(sink, { onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), - onSome: (logFile) => - Effect.gen(function* () { - const sessionRef = yield* Ref.make(Option.none()); - const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( - function* (session: BackendOutputSession, details: string) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output start", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "START", - details: session.startDetails, - }, - }); - for (const output of session.chunks) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: output.streamName === "stderr" ? "ERROR" : "INFO", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - stream: output.streamName, - text: textDecoder.decode(output.chunk.subarray(output.offset)), - }, - }); - } - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output end", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "END", - details: sanitizeLogValue(details), - }, - }); + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (logFile) { + const sessionRef = yield* Ref.make(Option.none()); + const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")(function* ( + session: BackendOutputSession, + details: string, + ) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output start", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "START", + details: session.startDetails, }, - ); - return { - beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ - details, - }) { - const runId = yield* currentDesktopRunId; - yield* Ref.set( - sessionRef, - Option.some({ - runId, - startDetails: sanitizeLogValue(details), - chunks: [], - byteLength: 0, - }), - ); - }), - writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); - } - yield* Ref.update( - sessionRef, - Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), - ); - }), - persistFailureSnapshot: Effect.fn( - "desktop.observability.backendOutput.persistFailureSnapshot", - )(function* ({ details }) { - const session = yield* Ref.get(sessionRef); - if (Option.isSome(session)) { - yield* writeFailure(session.value, details); - } - }), - persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")( - function* ({ details }) { - const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); - if (Option.isNone(session)) return; - yield* writeFailure(session.value, details); + }); + for (const output of session.chunks) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: output.streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + stream: output.streamName, + text: textDecoder.decode(output.chunk.subarray(output.offset)), }, - ), - discardSession: Ref.set(sessionRef, Option.none()), - } satisfies DesktopBackendOutputLogShape; - }), + }); + } + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output end", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "END", + details: sanitizeLogValue(details), + }, + }); + }); + return { + beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ + details, + }) { + const runId = yield* currentDesktopRunId; + yield* Ref.set( + sessionRef, + Option.some({ + runId, + startDetails: sanitizeLogValue(details), + chunks: [], + byteLength: 0, + }), + ); + }), + writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + yield* Ref.update( + sessionRef, + Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), + ); + }), + persistFailureSnapshot: Effect.fn( + "desktop.observability.backendOutput.persistFailureSnapshot", + )(function* ({ details }) { + const session = yield* Ref.get(sessionRef); + if (Option.isSome(session)) { + yield* writeFailure(session.value, details); + } + }), + persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")(function* ({ + details, + }) { + const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); + if (Option.isNone(session)) return; + yield* writeFailure(session.value, details); + }), + discardSession: Ref.set(sessionRef, Option.none()), + } satisfies DesktopBackendOutputLogShape; + }), }); const layerBackendOutputLogFactory = Layer.effect( @@ -586,7 +590,9 @@ const layerBackendOutputLogFactory = Layer.effect( }); return DesktopBackendOutputLogFactory.of({ - forInstance: (id) => makeForId(id), + forInstance: Effect.fn("DesktopBackendOutputLogFactory.forInstance")(function (id) { + return makeForId(id); + }), }); }), ); @@ -595,15 +601,24 @@ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; + // Unpackaged runs launch a script, so Electron reports its own version. + const serviceVersion = environment.isPackaged + ? environment.appVersion + : desktopPackageJson.version; const resource = { serviceName: "t3code-desktop", - // Unpackaged runs launch a script, so Electron reports its own version. - serviceVersion: environment.isPackaged ? environment.appVersion : desktopPackageJson.version, + serviceVersion, attributes: { "service.namespace": "t3code", "service.runtime": "desktop", - "service.mode": environment.isDevelopment ? "development" : "packaged", + "t3code.desktop.mode": environment.isDevelopment ? "development" : "packaged", + "service.instance.id": processServiceInstanceId(endpoints.resourceAttributes), + ...releaseResourceAttributes( + environment.isDevelopment ? "dev" : releaseChannelOf(serviceVersion), + endpoints.resourceAttributes, + ), "host.name": endpoints.resourceAttributes["host.name"] || (yield* HostProcess.Hostname), + ...nodeProcessRuntimeAttributes(), }, }; 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/app/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts index 9e87cfb7a9c02..9b5bc89148dea 100644 --- a/apps/desktop/src/app/DesktopWebLinks.test.ts +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeEvents from "node:events"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; @@ -26,7 +25,7 @@ const makeWindow = () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), reveal: (target: Electron.BrowserWindow) => Effect.sync(() => void revealed.push(target)), } as unknown as ElectronWindow.ElectronWindow["Service"]), ), diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index f448f184b893f..ce63a710f7f7e 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -69,16 +69,15 @@ const make = Effect.gen(function* () { }); return DesktopWebLinks.of({ - receive: (url) => - Effect.suspend(() => { + receive: Effect.fn("DesktopWebLinks.receive")(function (url) { + return Effect.suspend(() => { pending.push(url); return flush; - }).pipe(Effect.withSpan("DesktopWebLinks.receive")), - setRendererReady: (ready) => - (ready ? startListening : Effect.sync(stopListening)).pipe( - Effect.andThen(flush), - Effect.withSpan("DesktopWebLinks.setRendererReady"), - ), + }); + }), + setRendererReady: Effect.fn("DesktopWebLinks.setRendererReady")(function (ready) { + return ready ? startListening : Effect.sync(stopListening); + }, Effect.andThen(flush)), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 81c9d318da853..447de4fd13ad4 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -719,7 +719,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolveWsl carries the kill switch into the distro", () => + it.effect("resolveWsl carries the kill switch and resource attributes into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -729,10 +729,12 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; + const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; process.env.T3CODE_TELEMETRY_ENABLED = "false"; + process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -742,6 +744,8 @@ describe("DesktopBackendConfiguration", () => { assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); }).pipe( Effect.provide( layerBackendConfiguration().pipe( @@ -763,6 +767,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); + restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 029602af36052..ef4d0b3826b57 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", @@ -110,6 +110,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_TRACES_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_LOGS_EXPORTER", + "OTEL_RESOURCE_ATTRIBUTES", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; 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 007f7369365a2..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( @@ -1043,58 +1074,58 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); yield* Option.match(active, { onNone: () => Effect.void, - onSome: (run) => - Effect.gen(function* () { - const closed = yield* closeRun(run, parentScope, options); - if (!closed) { - return; - } - const cleanup = yield* mutex.withPermits(1)( - Ref.modify( - state, - ( - latest, - ): readonly [ - { - readonly needsCleanup: boolean; - readonly shouldStart: boolean; - }, - BackendManagerState, - ] => { - const current = Option.getOrUndefined(latest.active); - if (current?.id !== run.id) { - return [ - { - needsCleanup: false, - shouldStart: - latest.desiredRunning && - Option.isNone(latest.active) && - Option.isNone(latest.restartFiber), - }, - latest, - ]; - } + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (run) { + const closed = yield* closeRun(run, parentScope, options); + if (!closed) { + return; + } + const cleanup = yield* mutex.withPermits(1)( + Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly needsCleanup: boolean; + readonly shouldStart: boolean; + }, + BackendManagerState, + ] => { + const current = Option.getOrUndefined(latest.active); + if (current?.id !== run.id) { return [ { - needsCleanup: true, - shouldStart: latest.desiredRunning, - }, - { - ...latest, - active: Option.none(), + needsCleanup: false, + shouldStart: + latest.desiredRunning && + Option.isNone(latest.active) && + Option.isNone(latest.restartFiber), }, + latest, ]; - }, - ), - ); - if (cleanup.needsCleanup) { - yield* desktopTelemetryPublisher.removeControlSource(spec.id); - yield* backendOutputLog.discardSession; - } - if (cleanup.shouldStart) { - yield* start; - } - }), + } + return [ + { + needsCleanup: true, + shouldStart: latest.desiredRunning, + }, + { + ...latest, + active: Option.none(), + }, + ]; + }, + ), + ); + if (cleanup.needsCleanup) { + yield* desktopTelemetryPublisher.removeControlSource(spec.id); + yield* backendOutputLog.discardSession; + } + if (cleanup.shouldStart) { + yield* start; + } + }), }); }); 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(); @@ -132,7 +135,7 @@ export const layer = Layer.effect( }); yield* electronDialog.showErrorBox( "WSL backend couldn't start", - `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, + `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Sharing & access once the WSL distro is fixed.`, ); // Clear wslOnly too so enabling WSL cannot restore a failed mode. // Keep the in-memory fallback if persistence fails. @@ -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: () => @@ -291,13 +298,14 @@ export const layer = Layer.effect( }); return DesktopBackendPool.of({ - get: (id) => - SynchronizedRef.get(instancesRef).pipe( + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => { const entry = instances.get(id); return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); }), - ), + ); + }), list: SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => Array.from(instances.values()).flatMap((entry) => @@ -326,11 +334,17 @@ export const layerTest = ( ); const primary = instances[0]!; return DesktopBackendPool.of({ - get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return Effect.succeed(Option.fromNullishOr(byId.get(id))); + }), list: Effect.succeed(Array.from(byId.values())), primary: Effect.succeed(primary), - register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), - unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), + register: Effect.fn("DesktopBackendPool.register")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support register"); + }), + unregister: Effect.fn("DesktopBackendPool.unregister")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support unregister"); + }), }); }), ); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 7190c70aedc21..04f26b1bced84 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -135,59 +135,73 @@ export const make = ElectronApp.of({ quit: Effect.sync(() => { Electron.app.quit(); }), - exit: (code) => - Effect.sync(() => { + exit: Effect.fn("ElectronApp.exit")(function (code) { + return Effect.sync(() => { Electron.app.exit(code); - }), - relaunch: (options) => - Effect.sync(() => { + }); + }), + relaunch: Effect.fn("ElectronApp.relaunch")(function (options) { + return Effect.sync(() => { Electron.app.relaunch(options); - }), - setPath: (name, path) => - Effect.sync(() => { + }); + }), + setPath: Effect.fn("ElectronApp.setPath")(function (name, path) { + return Effect.sync(() => { Electron.app.setPath(name, path); - }), - setName: (name) => - Effect.sync(() => { + }); + }), + setName: Effect.fn("ElectronApp.setName")(function (name) { + return Effect.sync(() => { Electron.app.setName(name); - }), - setAboutPanelOptions: (options) => - Effect.sync(() => { + }); + }), + setAboutPanelOptions: Effect.fn("ElectronApp.setAboutPanelOptions")(function (options) { + return Effect.sync(() => { Electron.app.setAboutPanelOptions(options); - }), - setAppUserModelId: (id) => - Effect.sync(() => { + }); + }), + setAppUserModelId: Effect.fn("ElectronApp.setAppUserModelId")(function (id) { + return Effect.sync(() => { Electron.app.setAppUserModelId(id); - }), + }); + }), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), - setAsDefaultProtocolClient: (protocol, path, args) => - Effect.sync(() => { - if (path === undefined) { - return Electron.app.setAsDefaultProtocolClient(protocol); - } - return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); - }), - setDesktopName: (desktopName) => - Effect.sync(() => { + setAsDefaultProtocolClient: Effect.fn("ElectronApp.setAsDefaultProtocolClient")( + function (protocol, path, args) { + return Effect.sync(() => { + if (path === undefined) { + return Electron.app.setAsDefaultProtocolClient(protocol); + } + return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); + }); + }, + ), + setDesktopName: Effect.fn("ElectronApp.setDesktopName")(function (desktopName) { + return Effect.sync(() => { const linuxApp = Electron.app as Electron.App & { setDesktopName?: (desktopName: string) => void; }; linuxApp.setDesktopName?.(desktopName); - }), - setDockIcon: (iconPath) => - Effect.sync(() => { + }); + }), + setDockIcon: Effect.fn("ElectronApp.setDockIcon")(function (iconPath) { + return Effect.sync(() => { Electron.app.dock?.setIcon(iconPath); - }), - appendCommandLineSwitch: (switchName, value) => - Effect.sync(() => { - if (value === undefined) { - Electron.app.commandLine.appendSwitch(switchName); - return; - } - Electron.app.commandLine.appendSwitch(switchName, value); - }), - onBeforeQuitForUpdate: (listener) => - Effect.acquireRelease( + }); + }), + appendCommandLineSwitch: Effect.fn("ElectronApp.appendCommandLineSwitch")( + function (switchName, value) { + return Effect.sync(() => { + if (value === undefined) { + Electron.app.commandLine.appendSwitch(switchName); + return; + } + Electron.app.commandLine.appendSwitch(switchName, value); + }); + }, + ), + onBeforeQuitForUpdate: Effect.fn("ElectronApp.onBeforeQuitForUpdate")(function (listener) { + return Effect.acquireRelease( Effect.sync(() => { Electron.autoUpdater.on("before-quit-for-update", listener); }), @@ -195,11 +209,13 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.autoUpdater.removeListener("before-quit-for-update", listener); }), - ).pipe(Effect.asVoid), - removeCommandLineSwitch: (switchName) => - Effect.sync(() => { + ).pipe(Effect.asVoid); + }), + removeCommandLineSwitch: Effect.fn("ElectronApp.removeCommandLineSwitch")(function (switchName) { + return Effect.sync(() => { Electron.app.commandLine.removeSwitch(switchName); - }), + }); + }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 8f0df22016169..4f9ab12eddc39 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -166,8 +166,8 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - showMessageBox: (options, owner) => - Effect.tryPromise({ + showMessageBox: Effect.fn("ElectronDialog.showMessageBox")(function (options, owner) { + return Effect.tryPromise({ try: () => owner === undefined ? Electron.dialog.showMessageBox(options) @@ -181,9 +181,10 @@ export const make = ElectronDialog.of({ buttonCount: options.buttons?.length ?? 0, cause, }), - }), - showErrorBox: (title, content) => - Effect.try({ + }); + }), + showErrorBox: Effect.fn("ElectronDialog.showErrorBox")(function (title, content) { + return Effect.try({ try: () => Electron.dialog.showErrorBox(title, content), catch: (cause) => new ElectronDialogShowErrorBoxError({ @@ -191,7 +192,8 @@ export const make = ElectronDialog.of({ contentLength: content.length, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), }); export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index a58eb0c743fb0..d4338b45e280b 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -187,8 +187,8 @@ export const make = Effect.gen(function* () { }; return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.try({ + setApplicationMenu: Effect.fn("ElectronMenu.setApplicationMenu")(function (template) { + return Effect.try({ try: () => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }, @@ -200,9 +200,10 @@ export const make = Effect.gen(function* () { itemCount: template.length, cause, }), - }).pipe(Effect.orDie), - popupTemplate: (input) => - input.template.length === 0 + }).pipe(Effect.orDie); + }), + popupTemplate: Effect.fn("ElectronMenu.popupTemplate")(function (input) { + return input.template.length === 0 ? Effect.void : Effect.try({ try: () => @@ -218,9 +219,10 @@ export const make = Effect.gen(function* () { itemCount: input.template.length, cause, }), - }).pipe(Effect.orDie), - showContextMenu: (input) => - Effect.callback>((resume) => { + }).pipe(Effect.orDie); + }), + showContextMenu: Effect.fn("ElectronMenu.showContextMenu")(function (input) { + return Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); if (normalizedItems.length === 0) { resume(Effect.succeedNone); @@ -272,7 +274,8 @@ export const make = Effect.gen(function* () { ), ); } - }), + }); + }), }); }); diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts index 41c36acf2250a..5a702b61a8111 100644 --- a/apps/desktop/src/electron/ElectronPowerMonitor.ts +++ b/apps/desktop/src/electron/ElectronPowerMonitor.ts @@ -79,8 +79,11 @@ const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] export const make = ElectronPowerMonitor.of({ isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), - getSystemIdleState: (idleThresholdSeconds) => - Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)), + getSystemIdleState: Effect.fn("ElectronPowerMonitor.getSystemIdleState")( + function (idleThresholdSeconds) { + return Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)); + }, + ), getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()), onSimpleEvent, onThermalStateChange, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 0395d20e9bd38..9725fd41e1318 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -74,16 +74,18 @@ export const make = Effect.gen(function* () { try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), - encryptString: (value) => - Effect.try({ + encryptString: Effect.fn("ElectronSafeStorage.encryptString")(function (value) { + return Effect.try({ try: () => Electron.safeStorage.encryptString(value), catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), - }), - decryptString: (value) => - Effect.try({ + }); + }), + decryptString: Effect.fn("ElectronSafeStorage.decryptString")(function (value) { + return Effect.try({ try: () => Electron.safeStorage.decryptString(Buffer.from(value)), catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), - }), + }); + }), selectedStorageBackend: Effect.sync(() => { if (platform !== "linux") { return Option.none(); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index a72963c2e258a..e32dba36a6dfa 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -63,8 +63,8 @@ export class ElectronShell extends Context.Service< /** @public */ export const make = ElectronShell.of({ - openExternal: (rawUrl) => - Option.match(parseSafeExternalUrl(rawUrl), { + openExternal: Effect.fn("ElectronShell.openExternal")(function (rawUrl) { + return Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), onSome: (externalUrl) => Effect.promise(() => @@ -73,16 +73,19 @@ export const make = ElectronShell.of({ () => false, ), ), - }), - openSystemSettings: (pane) => - Effect.promise(() => + }); + }), + openSystemSettings: Effect.fn("ElectronShell.openSystemSettings")(function (pane) { + return Effect.promise(() => Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then( () => true, () => false, ), - ), - copyText: (text) => - Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)), + ); + }), + copyText: Effect.fn("ElectronShell.copyText")(function (text) { + return Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)); + }), }); export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index a84eedda13b4e..ab0673b817ae2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -31,15 +31,16 @@ export class ElectronTheme extends Context.Service< /** @public */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), - setSource: (theme) => - Effect.try({ + setSource: Effect.fn("ElectronTheme.setSource")(function (theme) { + return Effect.try({ try: () => { Electron.nativeTheme.themeSource = theme; }, catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), - }), - onUpdated: (listener) => - Effect.acquireRelease( + }); + }), + onUpdated: Effect.fn("ElectronTheme.onUpdated")(function (listener) { + return Effect.acquireRelease( Effect.suspend(() => { Electron.nativeTheme.on("updated", listener); return Effect.void; @@ -49,7 +50,8 @@ export const make = ElectronTheme.of({ Electron.nativeTheme.removeListener("updated", listener); return Effect.void; }), - ), + ); + }), }); export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 397044932607b..c71b42040db29 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -82,47 +82,57 @@ export class ElectronUpdater extends Context.Service< /** @public */ export const make = ElectronUpdater.of({ - setFeedURL: (options) => - Effect.suspend(() => { + setFeedURL: Effect.fn("ElectronUpdater.setFeedURL")(function (options) { + return Effect.suspend(() => { autoUpdater.setFeedURL(options); return Effect.void; - }), - setAutoDownload: (value) => - Effect.suspend(() => { + }); + }), + setAutoDownload: Effect.fn("ElectronUpdater.setAutoDownload")(function (value) { + return Effect.suspend(() => { autoUpdater.autoDownload = value; return Effect.void; - }), - setAutoInstallOnAppQuit: (value) => - Effect.suspend(() => { + }); + }), + setAutoInstallOnAppQuit: Effect.fn("ElectronUpdater.setAutoInstallOnAppQuit")(function (value) { + return Effect.suspend(() => { autoUpdater.autoInstallOnAppQuit = value; return Effect.void; - }), - setChannel: (channel) => - Effect.suspend(() => { + }); + }), + setChannel: Effect.fn("ElectronUpdater.setChannel")(function (channel) { + return Effect.suspend(() => { autoUpdater.channel = channel; return Effect.void; - }), - setAllowPrerelease: (value) => - Effect.suspend(() => { + }); + }), + setAllowPrerelease: Effect.fn("ElectronUpdater.setAllowPrerelease")(function (value) { + return Effect.suspend(() => { autoUpdater.allowPrerelease = value; return Effect.void; - }), + }); + }), allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), - setAllowDowngrade: (value) => - Effect.suspend(() => { + setAllowDowngrade: Effect.fn("ElectronUpdater.setAllowDowngrade")(function (value) { + return Effect.suspend(() => { autoUpdater.allowDowngrade = value; return Effect.void; - }), - setFullChangelog: (value) => - Effect.suspend(() => { + }); + }), + setFullChangelog: Effect.fn("ElectronUpdater.setFullChangelog")(function (value) { + return Effect.suspend(() => { autoUpdater.fullChangelog = value; return Effect.void; - }), - setDisableDifferentialDownload: (value) => - Effect.suspend(() => { - autoUpdater.disableDifferentialDownload = value; - return Effect.void; - }), + }); + }), + setDisableDifferentialDownload: Effect.fn("ElectronUpdater.setDisableDifferentialDownload")( + function (value) { + return Effect.suspend(() => { + autoUpdater.disableDifferentialDownload = value; + return Effect.void; + }); + }, + ), checkForUpdates: Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.tryPromise({ @@ -137,8 +147,11 @@ export const make = ElectronUpdater.of({ catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), }).pipe(Effect.asVoid); }), - quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.suspend(() => { + quitAndInstall: Effect.fn("ElectronUpdater.quitAndInstall")(function ({ + isSilent, + isForceRunAfter, + }) { + return Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.try({ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), @@ -150,8 +163,9 @@ export const make = ElectronUpdater.of({ cause, }), }); - }), - on: (eventName, listener) => { + }); + }), + on: Effect.fn("ElectronUpdater.on")(function (eventName, listener) { const eventTarget = autoUpdater as unknown as { on: (eventName: string, listener: (...args: Array) => void) => void; removeListener: (eventName: string, listener: (...args: Array) => void) => void; @@ -166,7 +180,7 @@ export const make = ElectronUpdater.of({ eventTarget.removeListener(eventName, untypedListener); }), ).pipe(Effect.asVoid); - }, + }), }); export const layer = Layer.succeed(ElectronUpdater, make); 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 60908ee90dc89..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: (options) => { + create: Effect.fn("ElectronWindow.create")(function (options, behavior) { const webPreferences = options.webPreferences; const diagnosticOptions = { title: options.title ?? null, @@ -232,16 +237,22 @@ 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 }), }); - }, + }), main: liveMain, currentMainOrFirst, focusedMainOrFirst, - setMain: (window) => Ref.set(mainWindowRef, Option.some(window)), - clearMain: (window) => - Ref.update(mainWindowRef, (current) => { + setMain: Effect.fn("ElectronWindow.setMain")(function (window) { + return Ref.set(mainWindowRef, Option.some(window)); + }), + clearMain: Effect.fn("ElectronWindow.clearMain")(function (window) { + return Ref.update(mainWindowRef, (current) => { if (Option.isNone(current)) { return current; } @@ -249,9 +260,10 @@ export const make = Effect.gen(function* () { return current; } return Option.none(); - }), - prepareReveal: (window) => - Effect.promise(async () => { + }); + }), + prepareReveal: Effect.fn("ElectronWindow.prepareReveal")(function (window) { + return Effect.promise(async () => { if (platform !== "win32" || window.isDestroyed()) { return false; } @@ -259,9 +271,10 @@ export const make = Effect.gen(function* () { return ensureWindowsForegroundFocus() .prepare(windowsForegroundFocusTarget(window)) .catch(() => false); - }), - reveal: (window) => - Effect.tryPromise({ + }); + }), + reveal: Effect.fn("ElectronWindow.reveal")(function (window) { + return Effect.tryPromise({ try: async () => { if (window.isDestroyed()) { return; @@ -322,7 +335,8 @@ export const make = Effect.gen(function* () { channel: null, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), sendAll: /* Window broadcasts run for streamed updates; avoid a span per broadcast. */ Effect.fnUntraced(function* (channel, ...args) { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 306cfc670be09..8a5360a3fa9d8 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"; @@ -122,7 +117,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); @@ -131,7 +125,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 8a229a54bb410..2dda014edd291 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/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index b5fe707f4caee..ed821165b40f7 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -20,9 +20,13 @@ vi.mock("electron", () => ({ import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getRelayTelemetryEnabled, + pickProjectFavicon, probeRemoteEditors, } from "./window.ts"; @@ -192,6 +196,78 @@ describe("getRelayTelemetryEnabled", () => { ); }); +describe("pickProjectFavicon", () => { + const pickerLayer = ( + pickFiles: () => Effect.Effect>, + settings?: DesktopAppSettings.DesktopSettings, + ) => + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeedNone, + }), + DesktopAppSettings.layerTest(settings), + ); + + it.effect("opens a single-image picker from the project directory", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon + .handler("/project") + .pipe(Effect.provide(pickerLayer(pickFiles))); + + assert.strictEqual(result, "/pictures/icon.png"); + assert.deepEqual(pickFiles.mock.calls, [ + [ + { + owner: Option.none(), + defaultPath: Option.some("/project"), + multiple: false, + filters: [ + { + name: "Images", + extensions: [ + "avif", + "bmp", + "gif", + "ico", + "heic", + "heif", + "jpeg", + "jpg", + "png", + "svg", + "tif", + "tiff", + "webp", + "icns", + ], + }, + ], + }, + ], + ]); + }), + ); + + it.effect("does not open a picker while the local environment is off", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + pickerLayer(pickFiles, { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }), + ), + ); + + assert.strictEqual(result, null); + assert.strictEqual(pickFiles.mock.calls.length, 0); + }), + ); +}); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "finds remote editors installed without PATH launchers", () => diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 45e1ee4064e19..d4e64148bb69e 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; @@ -415,15 +411,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/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index ec314d1f9d478..1c1f9dc078ca0 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -65,8 +65,8 @@ describe("CDP relay", () => { ]); pending[0]!(); await settle(); - expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined); - expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session"); + expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined, undefined); + expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session", undefined); expect(written.at(-1)).toEqual({ id: 5, result: { method: "Page.captureScreenshot" }, @@ -92,7 +92,30 @@ describe("CDP relay", () => { }), ); await settle(); - expect(send.mock.calls).toEqual([["Runtime.runIfWaitingForDebugger", {}, "iframe-session"]]); + expect(send.mock.calls).toEqual([ + ["Runtime.runIfWaitingForDebugger", {}, "iframe-session", undefined], + ]); + }); + + // Playwright enables focus emulation on every page it connects to, and Chromium then + // counts the guest as captured: a preview the panel hides keeps its timers unthrottled. + it("acknowledges Playwright's focus emulation without forcing it onto the guest", async () => { + const send = vi.fn(async () => ({})); + const written: Array> = []; + const relay = createCdpRelayConnection(makeTarget(send), (raw) => + written.push(JSON.parse(raw)), + ); + relay.receive( + JSON.stringify({ + id: 1, + method: "Emulation.setFocusEmulationEnabled", + params: { enabled: true }, + sessionId: "t3-preview-page", + }), + ); + await settle(); + expect(written).toEqual([{ id: 1, result: {}, sessionId: "t3-preview-page" }]); + expect(send).not.toHaveBeenCalled(); }); it("routes tab events to the page session once the page is announced", async () => { @@ -147,7 +170,7 @@ describe("CDP relay extra sessions", () => { expect(written.find((m) => m["id"] === 4)).toHaveProperty("error"); relay.receive(JSON.stringify({ id: 5, method: "Page.startScreencast", sessionId: extra })); await settle(); - expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined); + expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined, extra); written.length = 0; relay.event("Page.screencastFrame", { data: "x" }, undefined); expect(written.map((m) => m["sessionId"])).toEqual(["t3-preview-page", extra]); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 82264cc4b870b..948bc361f5d92 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -5,7 +5,10 @@ export interface CdpRelayTarget { method: string, params: Record, sessionId: string | undefined, + /** The extra session that sent it, such as a viewer's; the main connection has none. */ + owner?: string, ) => Promise; + readonly sessionDetached?: (owner: string) => void; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; @@ -111,8 +114,10 @@ export function createCdpRelayConnection( } case "Target.detachFromTarget": { const sessionId = command.params?.["sessionId"]; - if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) + if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) { sessions.delete(sessionId); + target.sessionDetached?.(sessionId); + } return {}; } case "Target.setAutoAttach": { @@ -133,11 +138,15 @@ export function createCdpRelayConnection( const pageCommand = (command: CdpCommand): Promise => { const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; + const owner = + child === undefined && command.sessionId !== PAGE_SESSION_ID ? command.sessionId : undefined; // Child frames/workers pause on auto-attach until resumed; only the main page can skip this command. if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) { return Promise.resolve({}); } - return target.send(command.method, command.params ?? {}, child); + // Playwright asks for this on every page; the host emulates focus only while it is in use. + if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({}); + return target.send(command.method, command.params ?? {}, child, owner); }; return { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 50039c4e4b04e..08fefd8d733f5 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -6,6 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as NodeEvents from "node:events"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; @@ -30,18 +31,39 @@ const command = ( }), ); -const makeDebuggee = () => { +const makeDebuggee = ( + options: { + readonly deferFocus?: boolean; + readonly refuse?: (method: string, sessionId: string | undefined) => boolean; + } = {}, +) => { const emitter = new NodeEvents.EventEmitter(); const pending: Array<() => void> = []; + const focusChanges: Array<() => void> = []; const sent: Array = []; const sendWaiters: Array<() => void> = []; + const focusEmulation: Array = []; const debuggee = Object.assign(emitter, { - sendCommand: (method: string, params?: { id?: number }) => - new Promise((resolve) => { + sendCommand: ( + method: string, + params?: { id?: number; enabled?: boolean }, + sessionId?: string, + ) => + new Promise((resolve, reject) => { + if (options.refuse?.(method, sessionId)) { + reject(new Error("Refused")); + return; + } if (method === "Target.getTargetInfo") { resolve({ targetInfo: { targetId: "GUEST" } }); return; } + if (method === "Emulation.setFocusEmulationEnabled") { + focusEmulation.push(params?.enabled === true); + if (options.deferFocus) focusChanges.push(() => resolve({})); + else resolve({}); + return; + } sent.push(params?.id); pending.push(() => resolve({ method })); sendWaiters.shift()?.(); @@ -61,12 +83,18 @@ const makeDebuggee = () => { navigate: () => webContents.emit("did-navigate"), /** Ids of the commands the debugger received, in order. */ sent, + focusEmulation, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), releaseFirst: () => pending.shift()?.(), + settleFocus: () => focusChanges.splice(0).forEach((resolve) => resolve()), }; }; +/** Commands wait behind a focus emulation change; it settles within a macrotask. */ +// oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time +const settle = Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], count: number) => host.events.pipe( Stream.take(count), @@ -93,6 +121,18 @@ const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => return replies; }); +/** Whether the client should paint the guest, each time that changes. */ +const watchPainting = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => + Effect.gen(function* () { + const painting: Array = []; + yield* host.painting.pipe( + Stream.runForEach(({ active }) => Effect.sync(() => painting.push(active))), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + return painting; + }); + describe("DesktopBrowserHost", () => { it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { @@ -123,6 +163,7 @@ describe("DesktopBrowserHost", () => { yield* command(1, "Page.captureScreenshot"); yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(2, "DOM.enable"); + yield* settle; debuggee.release(); const [, reply] = yield* Fiber.join(reader); @@ -141,6 +182,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "DOM.enable"); + yield* settle; expect(debuggee.sent).toEqual([1, 3]); debuggee.releaseFirst(); @@ -159,6 +201,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; debuggee.navigate(); expect(debuggee.sent).toEqual([1, 2]); @@ -181,6 +224,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(host, 3, "Page.captureScreenshot"); expect(debuggee.sent).toEqual([1]); @@ -192,6 +236,682 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("emulates focus while the server drives a guest, then lets a hidden one throttle", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "DOM.enable"); + yield* settle; + expect(debuggee.focusEmulation).toEqual([true]); + expect(debuggee.sent).toEqual([1]); + debuggee.release(); + yield* settle; + + yield* TestClock.adjust("4 seconds"); + yield* command(host, 2, "Input.dispatchMouseEvent"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("4 seconds"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* TestClock.adjust("10 seconds"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.focusEmulation).toEqual([true, false, true]); + expect(debuggee.sent).toEqual([1, 2, 3]); + }), + ); + + it.effect("keeps a guest focused while a command it is running is still pending", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + // An evaluation awaiting the page's next frame, which a throttled guest never paints. + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + expect(debuggee.sent).toEqual([1]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(1); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle once Chromium can no longer answer its pending command", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A command the old document never answers. + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.sent).toEqual([1, 2]); + debuggee.navigate(); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true, false]); + }), + ); + + it.effect("keeps a guest focused for the screenshot a navigation lets start", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + expect(debuggee.sent).toEqual([1, 2]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + }), + ); + + it.effect("never sends a screenshot a navigation superseded while focus was changing", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + debuggee.settleFocus(); + yield* settle; + // Two captures in flight would undo the one-at-a-time guard. + expect(debuggee.sent).toEqual([2]); + }), + ); + + it.effect("keeps a guest focused when its turn starts after the agent's first command", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* command(host, 1, "Input.dispatchKeyEvent"); + yield* settle; + yield* turn(true); + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect("lets a guest throttle through a turn that only sent it housekeeping", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + + // Playwright re-enabling a domain after a reload is not the agent using the tab. + yield* command(host, 1, "Runtime.enable"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + yield* command(host, 2, "Runtime.evaluate"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true]); + }), + ); + + it.effect("keeps a guest the agent used focused through its turn's pauses", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* turn(true); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([]); + + yield* command(host, 1, "Input.dispatchKeyEvent"); + yield* settle; + debuggee.release(); + yield* settle; + // The model thinks between steps; a focus-sensitive menu must not blur meanwhile. + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect( + "ends a command's hold with the turn that sent it, even if Chromium never answers", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + + // A screenshot of a hidden guest the server already gave up on. + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.sent).toEqual([1]); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: false }), + ); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect("ends the agent's own session's holds with its turn, but not a viewer's stream", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The server's own CDP session, evaluating a promise that never settles. + yield* turn(true); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Runtime.evaluate", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A viewer streaming the guest keeps it focused through a turn's end. + yield* turn(true); + yield* raw({ id: 3, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(3); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true]); + }).pipe(Effect.scoped), + ); + + it.effect("paints for a screenshot queued behind one its viewer abandoned mid-capture", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + expect(painting).toEqual([true, false]); + + // The agent's screenshot waits its turn; painting lets the abandoned one finish first. + yield* command(host, 4, "Page.captureScreenshot"); + yield* settle; + expect(painting).toEqual([true, false, true]); + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(debuggee.sent).toEqual([undefined, 4]); + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(4); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a queued screenshot once its session detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The agent's screenshot is abandoned at turn end; a viewer's waits behind it, then leaves. + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + expect(painting).toEqual([true, false, true]); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(4); + expect(painting).toEqual([true, false, true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("never sends a screenshot whose session detached while focus was changing", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + debuggee.settleFocus(); + yield* settle; + expect(debuggee.sent).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a screenshot still queued when the agent's turn ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // The first answers late; the second still goes in turn, but holds nothing. + debuggee.releaseFirst(); + yield* settle; + expect(debuggee.sent).toEqual([1, 2]); + expect(painting).toEqual([true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ refuse: (method) => method === "Page.startScreencast" }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(painting).toEqual([true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle after a person's input through a viewer that then closed", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 3, method: "Input.dispatchMouseEvent", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a page's screencast when a child session's screencast is refused", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ + refuse: (method, sessionId) => method === "Page.startScreencast" && sessionId !== undefined, + }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* command(host, 1, "Page.startScreencast"); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "child-frame" }); + expect(yield* Queue.take(replies)).toBe(2); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + }).pipe(Effect.scoped), + ); + + it.effect("ignores a screencast that arrives on a session after it detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ + id: 2, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle once the session waiting on its command detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + // A viewer's own CDP session, closed while its still of a hidden guest is pending. + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("never sends a screenshot queued by a session that has since detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* command(host, 2, "Page.captureScreenshot"); + yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(4); + // The dropped screenshot's error reply leaves as the first frees its turn. + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([3, 2]); + yield* settle; + expect(debuggee.sent).toEqual([2]); + + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a streamed guest focused until its screencast ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.startScreencast"); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* command(host, 2, "Page.stopScreencast"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A viewer whose connection went away without stopping its screencast. + yield* command(host, 3, "Page.startScreencast"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true, false]); + }), + ); + + it.effect("keeps a guest focused while any of its screencasts runs", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + // Two viewers, or a viewer and a recording, each on its own session. + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("paints a hidden guest only while the screenshot it is taking is pending", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + + yield* command(host, 1, "DOM.enable"); + yield* settle; + expect(painting).toEqual([]); + + yield* command(host, 2, "Page.captureScreenshot"); + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + expect(painting).toEqual([true]); + + // The first answers once the guest paints; the queued one then takes its turn. + debuggee.release(); + yield* settle; + expect(debuggee.sent).toEqual([1, 2, 3]); + debuggee.release(); + yield* settle; + expect(painting).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a screenshot Chromium never answers", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The server gave up on it, and the agent's turn ends. + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // The page loads a new document, which also frees the screenshot turn. + debuggee.navigate(); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + + // The server releases the relay. + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* settle; + expect(painting).toEqual([true, false, true, false, true, false]); + + // The guest detaches; a replacement starts with no screenshot pending. + const replacement = makeDebuggee(); + host.attach(key, replacement.tab); + yield* command(host, 4, "Page.captureScreenshot"); + yield* settle; + host.detach(key); + yield* settle; + expect(painting).toEqual([true, false, true, false, true, false, true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("paints a streamed guest until its last screencast ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + expect(painting).toEqual([true]); + + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + yield* settle; + expect(painting).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + it.effect("saves a server tab's download under its CDP guid where the server asked", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 849ae658a8219..30f252e3a677e 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -22,7 +22,10 @@ const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBr const lineEncoder = new TextEncoder(); const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; +/** The same commands as above, by method, for the relay's sends. */ +const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/; const AGENT_DOWNLOAD_WINDOW_MS = 5_000; +const FOCUS_IDLE = "5 seconds"; export interface DesktopBrowserTabKey { readonly threadId: string; @@ -41,10 +44,33 @@ interface AttachedTab { readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; screenshotTurn: object | null; - readonly waitingScreenshots: Array<() => void>; + /** Each holds focus and painting while `held` and whoever queued it still waits on it. */ + readonly waitingScreenshots: Array<{ + readonly start: () => void; + readonly live: () => boolean; + held: boolean; + }>; downloadDirectory: string | null; pendingDownloadGuid: string | null; agentInputAt: number; + focusEmulated: boolean; + focusChange: Promise | null; + focusUses: number; + /** Each pending command, by the extra session waiting on it; the main connection's have none. */ + readonly focusPending: Map< + object, + { readonly owner: string | undefined; readonly paints: boolean } + >; + /** Whether the client keeps the guest painting, as published on `painting`. */ + painting: boolean; + /** + * Driven by the agent since its thread's last turn ended or the guest last throttled. A + * turn's start can reach us after its first command, so this holds focus once it does. + */ + agentUsed: boolean; + focusReleasing: boolean; + /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ + readonly screencasts: Set; readonly onMessage: ( event: Electron.Event, method: string, @@ -69,6 +95,11 @@ export class DesktopBrowserHost extends Context.Service< readonly x: number; readonly y: number; }>; + /** Whether a guest needs its frames painted while the panel hides it. */ + readonly painting: Stream.Stream<{ + readonly key: DesktopBrowserTabKey; + readonly active: boolean; + }>; } >()("@t3tools/desktop/preview/DesktopBrowserHost") {} @@ -80,44 +111,192 @@ export const make = Effect.gen(function* () { readonly x: number; readonly y: number; }>(16); + const painting = yield* PubSub.unbounded<{ + readonly key: DesktopBrowserTabKey; + readonly active: boolean; + }>(); const runFork = Effect.runForkWith(yield* Effect.context()); const tabs = new Map(); + /** Threads whose agent turn runs, by the server's account. */ + const agentTurns = new Set(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); const nextScreenshot = (tab: AttachedTab) => { tab.screenshotTurn = null; - tab.waitingScreenshots.shift()?.(); + tab.waitingScreenshots.shift()?.start(); + releaseFocusWhenIdle(tab); + syncPainting(tab); }; /** * Chromium below 157 crashes the whole app when a guest's renderer exits with a * screenshot copy in flight (crbug.com/567321049). Overlapping screenshots make that * far likelier, and one the server gave up on stays pending in Chromium, so each guest - * gets one at a time. One queued by a relay released since then is dropped unsent. + * gets one at a time. One whose relay or session has since gone is dropped unsent. */ const takeScreenshot = ( tab: AttachedTab, relay: CdpRelayConnection, - capture: () => Promise, + ownerLive: () => boolean, + /** Sends only while `current` holds; holds focus and painting only while `held`. */ + capture: (current: () => boolean, held: boolean) => Promise, ) => new Promise((resolve, reject) => { - const start = () => { - if (tab.relay !== relay) { - reject(new Error("The preview connection that asked for this screenshot closed.")); - nextScreenshot(tab); + const live = () => tab.relay === relay && ownerLive(); + const waiting = { + start: () => { + if (!live()) { + reject(new Error("The preview connection that asked for this screenshot closed.")); + nextScreenshot(tab); + return; + } + const turn = {}; + tab.screenshotTurn = turn; + capture(() => tab.screenshotTurn === turn && live(), waiting.held) + .finally(() => { + if (tab.screenshotTurn === turn) nextScreenshot(tab); + }) + .then(resolve, reject); + }, + live, + held: true, + }; + if (tab.screenshotTurn === null) waiting.start(); + else { + // The one ahead may be abandoned mid-capture; it needs frames to finish and free its turn. + tab.waitingScreenshots.push(waiting); + setFocusEmulation(tab, true); + syncPainting(tab); + } + }); + + /** + * Chromium counts a focus-emulated guest as captured, so a guest the panel hides would + * never throttle. Without it, a hidden guest paints no frames and focus() fires no focus + * events, so the guest is emulated focused while the server drives it or streams it. + */ + const setFocusEmulation = (tab: AttachedTab, enabled: boolean) => { + if (tab.focusEmulated === enabled) return; + tab.focusEmulated = enabled; + // A guest that refuses it, or a debugger already gone, behaves as unfocused. + const change: Promise = tab.debuggee.debugger + .sendCommand("Emulation.setFocusEmulationEnabled", { enabled }) + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + if (tab.focusChange === change) tab.focusChange = null; + }); + tab.focusChange = change; + }; + + const waitingHolds = (tab: AttachedTab) => + tab.waitingScreenshots.some((waiting) => waiting.held && waiting.live()); + + const releaseFocusWhenIdle = (tab: AttachedTab) => { + if (tab.focusReleasing) return; + tab.focusReleasing = true; + runFork( + Effect.gen(function* () { + let uses: number; + do { + uses = tab.focusUses; + yield* Effect.sleep(FOCUS_IDLE); + } while (uses !== tab.focusUses); + tab.focusReleasing = false; + // The last pending command to settle starts another idle wait. + if (tab.focusPending.size > 0 || tab.screencasts.size > 0 || waitingHolds(tab)) { return; } - const turn = {}; - tab.screenshotTurn = turn; - capture() - .finally(() => { - if (tab.screenshotTurn === turn) nextScreenshot(tab); - }) - .then(resolve, reject); - }; - if (tab.screenshotTurn === null) start(); - else tab.waitingScreenshots.push(start); + // The agent pauses to think between steps; its tab stays focused until the turn ends. + if (tab.agentUsed && agentTurns.has(tab.key.threadId)) return; + tab.agentUsed = false; + if (tabs.get(keyOf(tab.key)) === tab) setFocusEmulation(tab, false); + }), + ); + }; + + /** + * Electron composites no offscreen guest, so its screenshot or screencast waits for a frame + * that never comes. The client paints the guest behind the app while one is pending; the + * capture then finishes on its own, and the guest goes back offscreen once none is. + */ + const syncPainting = (tab: AttachedTab) => { + const active = + tabs.get(keyOf(tab.key)) === tab && + (tab.screencasts.size > 0 || + waitingHolds(tab) || + [...tab.focusPending.values()].some((hold) => hold.paints)); + if (tab.painting === active) return; + tab.painting = active; + runFork(PubSub.publish(painting, { key: tab.key, active })); + }; + + /** A command may wait on the page's next frame, so the guest stays focused until it settles. */ + const holdFocus = ( + tab: AttachedTab, + method: string, + owner: string | undefined, + work: () => Promise, + ) => { + const pending = {}; + tab.focusPending.set(pending, { owner, paints: method === "Page.captureScreenshot" }); + tab.focusUses += 1; + setFocusEmulation(tab, true); + releaseFocusWhenIdle(tab); + syncPainting(tab); + return work().finally(() => { + if (!tab.focusPending.delete(pending)) return; + tab.focusUses += 1; + releaseFocusWhenIdle(tab); + syncPainting(tab); }); + }; + + /** + * Chromium can leave a command unanswered, so the screenshot guard's release points + * also end every hold: a released relay has no one waiting, and a new document answers + * nothing pending from the old one. A released relay's screencast has no viewer to stop it. + */ + const dropFocusHolds = (tab: AttachedTab) => { + tab.focusPending.clear(); + releaseFocusWhenIdle(tab); + syncPainting(tab); + }; + + const dropRelay = (tab: AttachedTab) => { + tab.relay = null; + tab.screencasts.clear(); + tab.agentUsed = false; + dropFocusHolds(tab); + }; + + /** A detached session, such as a closed viewer, waits on nothing it sent. */ + const dropSessionHolds = (tab: AttachedTab, owner: string) => { + tab.screencasts.delete(owner); + for (const [pending, hold] of tab.focusPending) { + if (hold.owner === owner) tab.focusPending.delete(pending); + } + releaseFocusWhenIdle(tab); + syncPainting(tab); + }; + + /** + * A command the server stopped waiting on, such as a timed-out screenshot or evaluation, + * may never settle; the turn's end ends every hold. The agent also sends on its own extra + * session, so this cannot spare other sessions; a viewer's screencast still holds. + */ + const endAgentTurn = (threadId: string) => { + agentTurns.delete(threadId); + for (const tab of tabs.values()) { + if (tab.key.threadId !== threadId) continue; + tab.agentUsed = false; + // Still sent in turn, so the queue keeps moving, but no longer kept painting. + for (const waiting of tab.waitingScreenshots) waiting.held = false; + dropFocusHolds(tab); + } + }; const relayFor = (tab: AttachedTab) => { if (tab.relay) return tab.relay; @@ -126,12 +305,57 @@ export const make = Effect.gen(function* () { sessionId === undefined ? sendPageCommand(webContents, debuggee, method, params) : debuggee.sendCommand(method, params, sessionId); + const detachedOwners = new Set(); + /** Viewers and recordings stream; the agent's own sessions never do. */ + const streamingOwners = new Set(); const relay: CdpRelayConnection = createCdpRelayConnection( { - send: (method, params, sessionId) => - method === "Page.captureScreenshot" - ? takeScreenshot(tab, relay, () => send(method, params, sessionId)) - : send(method, params, sessionId), + send: (method, params, sessionId, owner) => { + // A child session's screencast is not the page's, such as a late one on a detached session. + if (sessionId === undefined && method === "Page.startScreencast") { + tab.screencasts.add(owner); + if (owner !== undefined) streamingOwners.add(owner); + } else if (sessionId === undefined && method === "Page.stopScreencast") { + tab.screencasts.delete(owner); + } + // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. + if ( + AGENT_INPUT_METHOD.test(method) && + (owner === undefined || !streamingOwners.has(owner)) + ) { + tab.agentUsed = true; + } + // Waits for focus emulation; insertText and other non-CDP input would not queue behind it. + const forward = (current?: () => boolean) => { + const change = tab.focusChange; + const run = () => + current === undefined || current() + ? send(method, params, sessionId) + : Promise.reject(new Error("The page this screenshot was for has moved on.")); + return change === null ? run() : change.then(run); + }; + return method === "Page.captureScreenshot" + ? takeScreenshot( + tab, + relay, + () => owner === undefined || !detachedOwners.has(owner), + (current, held) => + held ? holdFocus(tab, method, owner, () => forward(current)) : forward(current), + ) + : method === "Page.startScreencast" + ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { + // A viewer can outlive a refused stream; nothing is streaming for it. + if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); + releaseFocusWhenIdle(tab); + syncPainting(tab); + throw cause; + }) + : holdFocus(tab, method, owner, forward); + }, + sessionDetached: (owner) => { + detachedOwners.add(owner); + dropSessionHolds(tab, owner); + }, targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -172,6 +396,7 @@ export const make = Effect.gen(function* () { tab.relay = null; tab.debuggee.debugger.off("message", tab.onMessage); tab.debuggee.webContents.off("did-navigate", tab.onNewDocument); + syncPainting(tab); emit({ type: "detached", ...key }); }; @@ -188,6 +413,14 @@ export const make = Effect.gen(function* () { downloadDirectory: null, pendingDownloadGuid: null, agentInputAt: Number.NEGATIVE_INFINITY, + focusEmulated: false, + focusChange: null, + focusUses: 0, + focusPending: new Map(), + painting: false, + agentUsed: false, + focusReleasing: false, + screencasts: new Set(), onMessage: (_event, method, params, sessionId) => { if (method === "Browser.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; @@ -198,6 +431,8 @@ export const make = Effect.gen(function* () { // A screenshot pending when the page loads a new document, a reload included, can // stay unanswered. A crashed page lands here once it reloads, well after its renderer died. onNewDocument: () => { + // Before the next screenshot starts, so its own hold survives. + dropFocusHolds(tab); if (tab.screenshotTurn !== null) nextScreenshot(tab); }, }; @@ -211,6 +446,11 @@ export const make = Effect.gen(function* () { Effect.sync(() => { const command = decodeCommand(line); if (Option.isNone(command)) return; + if (command.value.type === "turn") { + if (command.value.active) agentTurns.add(command.value.threadId); + else endAgentTurn(command.value.threadId); + return; + } const tab = tabs.get(keyOf(command.value)); if (!tab) return; if (command.value.type === "pointer") { @@ -219,7 +459,7 @@ export const make = Effect.gen(function* () { return; } if (command.value.type === "release") { - tab.relay = null; + dropRelay(tab); return; } if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now(); @@ -231,19 +471,22 @@ export const make = Effect.gen(function* () { return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; }; - const announceAll = Effect.suspend(() => - Effect.forEach( + const announceAll = Effect.suspend(() => { + // A new backend: the old one's turns ended without telling us. + agentTurns.clear(); + return Effect.forEach( [...tabs.values()], (tab) => { - tab.relay = null; + dropRelay(tab); return PubSub.publish(outbox, { type: "attached", ...tab.key }); }, { discard: true }, - ), - ); + ); + }); return DesktopBrowserHost.of({ pointers: Stream.fromPubSub(pointers), + painting: Stream.fromPubSub(painting), // Subscribes before announcing, so no attach falls between the two. events: Stream.unwrap( Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 85fa6115477a0..f19079e46bce4 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -147,6 +147,7 @@ const { vi.mock("electron", () => ({ BrowserWindow: browserWindowConstructor, + // oxlint-disable-next-line typescript/no-extraneous-class -- This constructor-only mock implements the browser ClipboardItem API. ClipboardItem: class { constructor(data: Record) { clipboardItemConstructor(data); @@ -1712,6 +1713,65 @@ describe("PreviewManager", () => { }).pipe(Effect.provide(managerLayer()), Effect.scoped), ); + effectIt.effect( + "asks the client to paint a navigated server tab while the agent captures it", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.DesktopBrowserHost; + const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; + const contents = Object.assign( + makeTestPreviewWebContents(async () => { + throw new Error("unused"); + }, 42), + { + isDevToolsOpened: () => false, + getUserAgent: () => "Electron", + loadURL: vi.fn(async () => undefined), + }, + ); + const screenshot = Promise.withResolvers(); + contents.debugger.sendCommand = vi.fn((method: string) => + method === "Page.captureScreenshot" ? screenshot.promise : Promise.resolve(undefined), + ) as unknown as typeof contents.debugger.sendCommand; + fromId.mockReturnValue(contents); + const manager = yield* PreviewManager.PreviewManager; + const capturing = yield* Queue.unbounded(); + yield* manager.subscribeStateChanges((_tabId, state) => + Queue.offer(capturing, state.capturing).pipe(Effect.asVoid), + ); + const attached = yield* Deferred.make(); + yield* host.events.pipe( + Stream.runForEach((line) => + new TextDecoder().decode(line).includes('"type":"attached"') + ? Deferred.succeed(attached, undefined) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.registerWebview("tab_1", 42); + yield* Deferred.await(attached); + // Navigating rebuilds the tab's state, which must keep the server tab it answers for. + yield* manager.navigate("tab_1", "https://example.com/next"); + yield* Queue.clear(capturing); + + yield* host.handleCommandLine( + JSON.stringify({ + type: "cdp", + ...serverTab, + message: JSON.stringify({ + id: 1, + method: "Page.captureScreenshot", + sessionId: "t3-preview-page", + }), + }), + ); + expect(yield* Queue.take(capturing)).toBe(true); + screenshot.resolve({ data: "" }); + expect(yield* Queue.take(capturing)).toBe(false); + }).pipe(Effect.provide(managerLayer()), Effect.scoped), + ); + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index b9e0192ca8a7d..036c0d4afd932 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -102,6 +102,7 @@ export interface PreviewTabState { audioMuted: boolean; audible: boolean; controller: "human" | "agent" | "none"; + capturing: boolean; favicon?: DesktopPreviewFavicon; serverTab?: { readonly threadId: string; readonly tabId: string }; updatedAt: string; @@ -358,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 } @@ -535,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 @@ -800,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, ) => @@ -1076,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); @@ -1504,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)) { @@ -1652,6 +1624,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: false, audible: false, controller: "none", + capturing: false, updatedAt, }; return [ @@ -1725,6 +1698,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: false, audible: false, controller: "none", + capturing: false, updatedAt, }; yield* emit(tabId, closed); @@ -1946,7 +1920,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", + capturing: current?.capturing ?? false, ...(current?.favicon ? { favicon: current.favicon } : {}), + ...(current?.serverTab ? { serverTab: current.serverTab } : {}), updatedAt, }; return [ @@ -2299,7 +2275,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } if (afterAttach.serverTab) { - yield* listenForAgentPointers; + yield* listenToBrowserHost; browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger }); } if (afterAttach.colorScheme !== "system") { @@ -2512,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* ( @@ -3239,14 +3203,33 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); - let pointersStarted = false; - const listenForAgentPointers = Effect.suspend(() => { - if (pointersStarted) return Effect.void; - pointersStarted = true; - return browserHost.pointers.pipe( - Stream.runForEach(emitAgentPointer), - Effect.forkIn(parentScope), - Effect.asVoid, + const updateServerTabCapturing = Effect.fn("PreviewManager.updateServerTabCapturing")( + function* (painting: { + readonly key: { readonly threadId: string; readonly tabId: string }; + readonly active: boolean; + }) { + const tab = [...(yield* SynchronizedRef.get(tabsRef)).values()].find( + (candidate) => + candidate.serverTab?.threadId === painting.key.threadId && + candidate.serverTab.tabId === painting.key.tabId, + ); + if (tab && tab.capturing !== painting.active) { + yield* update(tab.tabId, { capturing: painting.active }); + } + }, + ); + let browserHostStarted = false; + // Subscribes before the attach that follows, so no event falls between the two. + const listenToBrowserHost = Effect.suspend(() => { + if (browserHostStarted) return Effect.void; + browserHostStarted = true; + const listen = Effect.forkIn(parentScope, { startImmediately: true }); + return Effect.all( + [ + browserHost.pointers.pipe(Stream.runForEach(emitAgentPointer), listen), + browserHost.painting.pipe(Stream.runForEach(updateServerTabCapturing), listen), + ], + { discard: true }, ); }); @@ -3282,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 }, @@ -3330,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")), @@ -3573,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") {} @@ -3709,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/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 832a774577149..0135225f90c48 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -90,6 +91,37 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect("records one bounds operation span with its input attributes", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + yield* withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, true); + }), + ).pipe(Effect.provideService(Tracer.Tracer, tracer)); + const operationSpans = spans.filter( + (span) => span.name === "desktop.settings.setMainWindowBounds", + ); + assert.lengthOf(operationSpans, 1); + assert.deepEqual(Object.fromEntries(operationSpans[0]!.attributes), { + x: 10, + y: 20, + width: 900, + height: 600, + isMaximized: true, + }); + assert.equal(operationSpans[0]!.status._tag, "Ended"); + }), + ); + it.effect( "persists disabling and re-enabling local execution without clearing backend settings", () => diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 433e8127a5098..5767057b4a35d 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -521,48 +521,56 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), - setMainWindowBounds: (bounds, isMaximized) => - persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( - Effect.withSpan("desktop.settings.setMainWindowBounds", { - attributes: { - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - isMaximized, - }, - }), - ), - setServerExposureMode: (mode) => - persist((settings) => setServerExposureMode(settings, mode)).pipe( - Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), - ), - setTailscaleServe: (input) => - persist((settings) => setTailscaleServe(settings, input)).pipe( - Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), - ), - setUpdateChannel: (channel) => - persist((settings) => setUpdateChannel(settings, channel)).pipe( - Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), - ), - setWslBackendEnabled: (enabled) => - persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), - ), - setWslDistro: (distro) => - persist((settings) => setWslDistro(settings, distro)).pipe( - Effect.withSpan("desktop.settings.setWslDistro", { - attributes: { distro: distro ?? null }, - }), - ), - setWslOnly: (enabled) => - persist((settings) => setWslOnly(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), - ), - setLocalEnvironmentEnabled: (enabled) => - persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), - ), + setMainWindowBounds: Effect.fn("desktop.settings.setMainWindowBounds")( + function* (bounds, isMaximized) { + yield* Effect.annotateCurrentSpan({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }); + + return yield* persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("desktop.settings.setServerExposureMode")(function* (mode) { + yield* Effect.annotateCurrentSpan({ mode }); + + return yield* persist((settings) => setServerExposureMode(settings, mode)); + }), + setTailscaleServe: Effect.fn("desktop.settings.setTailscaleServe")(function* (input) { + yield* Effect.annotateCurrentSpan(input); + + return yield* persist((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("desktop.settings.setUpdateChannel")(function* (channel) { + yield* Effect.annotateCurrentSpan({ channel }); + + return yield* persist((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("desktop.settings.setWslBackendEnabled")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslBackendEnabled(settings, enabled)); + }), + setWslDistro: Effect.fn("desktop.settings.setWslDistro")(function* (distro) { + yield* Effect.annotateCurrentSpan({ distro: distro ?? null }); + + return yield* persist((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("desktop.settings.setWslOnly")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("desktop.settings.setLocalEnvironmentEnabled")( + function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -594,18 +602,38 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), - setMainWindowBounds: (bounds, isMaximized) => - update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), - setServerExposureMode: (mode) => - update((settings) => setServerExposureMode(settings, mode)), - setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), - setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), - setWslBackendEnabled: (enabled) => - update((settings) => setWslBackendEnabled(settings, enabled)), - setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), - setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), - setLocalEnvironmentEnabled: (enabled) => - update((settings) => setLocalEnvironmentEnabled(settings, enabled)), + setMainWindowBounds: Effect.fn("DesktopAppSettings.setMainWindowBounds")( + function (bounds, isMaximized) { + return update((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("DesktopAppSettings.setServerExposureMode")( + function (mode) { + return update((settings) => setServerExposureMode(settings, mode)); + }, + ), + setTailscaleServe: Effect.fn("DesktopAppSettings.setTailscaleServe")(function (input) { + return update((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("DesktopAppSettings.setUpdateChannel")(function (channel) { + return update((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("DesktopAppSettings.setWslBackendEnabled")( + function (enabled) { + return update((settings) => setWslBackendEnabled(settings, enabled)); + }, + ), + setWslDistro: Effect.fn("DesktopAppSettings.setWslDistro")(function (distro) { + return update((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("DesktopAppSettings.setWslOnly")(function (enabled) { + return update((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("DesktopAppSettings.setLocalEnvironmentEnabled")( + function (enabled) { + return update((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); 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/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 7c65c35cf0439..0836db6678226 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -194,8 +194,8 @@ export const make = Effect.gen(function* () { get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( Effect.withSpan("desktop.clientSettings.get"), ), - set: (settings) => - crypto.randomUUIDv4().pipe( + set: Effect.fn("desktop.clientSettings.set")(function (settings) { + return crypto.randomUUIDv4().pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), Effect.mapError( (cause) => @@ -214,8 +214,8 @@ export const make = Effect.gen(function* () { suffix, }), ), - Effect.withSpan("desktop.clientSettings.set"), - ), + ); + }), }); }); @@ -228,7 +228,9 @@ export const layerTest = (initialSettings: Option.Option = Optio const settingsRef = yield* Ref.make(initialSettings); return DesktopClientSettings.of({ get: Ref.get(settingsRef), - set: (settings) => Ref.set(settingsRef, Option.some(settings)), + set: Effect.fn("DesktopClientSettings.set")(function (settings) { + return Ref.set(settingsRef, Option.some(settings)); + }), }); }), ); 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 9958fad3705ae..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( @@ -1718,24 +1719,28 @@ export const make = Effect.gen(function* () { ), ), ), - setAnimationDestination: (id, destination) => - Effect.promise(async () => { - if (linuxFeedback?.id === id && destination.relativeFrame) { - await linuxFeedback.feedback - .animateTo(destination.relativeFrame) - .catch(() => closeLinuxFeedback(id)); - return; - } - transition.animateTo(id, destination); - await transition.waitForLanding(id); - }), - dismissAnimation: (id) => - Effect.sync(() => { + setAnimationDestination: Effect.fn("DesktopSnapShot.setAnimationDestination")( + function (id, destination) { + return Effect.promise(async () => { + if (linuxFeedback?.id === id && destination.relativeFrame) { + await linuxFeedback.feedback + .animateTo(destination.relativeFrame) + .catch(() => closeLinuxFeedback(id)); + return; + } + transition.animateTo(id, destination); + await transition.waitForLanding(id); + }); + }, + ), + dismissAnimation: Effect.fn("DesktopSnapShot.dismissAnimation")(function (id) { + return Effect.sync(() => { closeLinuxFeedback(id); transition.dismiss(id); - }), - acknowledge: (id) => - Effect.promise(async () => { + }); + }), + acknowledge: Effect.fn("DesktopSnapShot.acknowledge")(function (id) { + return Effect.promise(async () => { await completeLinuxFeedback(id); await transition.complete(id); }).pipe( @@ -1751,7 +1756,8 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new DesktopSnapShotError({ operation: "acknowledge", captureId: id, cause }), ), - ), + ); + }), }); }); 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/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index eac148ae4d0f3..f6c9ee43d7580 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -133,32 +133,26 @@ export const make = Effect.gen(function* () { const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ - discoverHosts: (input) => - discoverDesktopSshHostsEffect(input).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.discoverHosts"), - ), - resolveHost: (alias) => - resolveSshTarget(alias.trim()).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.resolveHost"), - ), - ensureEnvironment: (target, ensureOptions) => - manager - .ensureEnvironment(target, ensureOptions) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.ensureEnvironment"), - ), - disconnectEnvironment: (target) => - manager - .disconnectEnvironment(target) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.disconnectEnvironment"), - ), + discoverHosts: Effect.fn("desktop.ssh.discoverHosts")(function* (input) { + return yield* discoverDesktopSshHostsEffect(input); + }, Effect.provide(runtimeContext)), + resolveHost: Effect.fn("desktop.ssh.resolveHost")(function* (alias) { + return yield* resolveSshTarget(alias.trim()); + }, Effect.provide(runtimeContext)), + ensureEnvironment: Effect.fn("desktop.ssh.ensureEnvironment")( + function* (target, ensureOptions) { + return yield* manager.ensureEnvironment(target, ensureOptions); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), + disconnectEnvironment: Effect.fn("desktop.ssh.disconnectEnvironment")( + function* (target) { + return yield* manager.disconnectEnvironment(target); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 5265d65523ba2..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; @@ -1159,7 +1157,9 @@ export const make = Effect.gen(function* () { install: installWithExpectedVersion().pipe( Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })), ), - installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion), + installPrepared: Effect.fn("DesktopUpdates.installPrepared")(function (expectedVersion) { + return installWithExpectedVersion(expectedVersion); + }), }); }); 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 5017793d07357..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"); @@ -1070,33 +1068,57 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { isAvailable: Effect.succeed(stub.isAvailable ?? false), listDistros: probeDistros.pipe(Effect.orElseSucceed(() => [])), probeDistros, - preWarm: () => Effect.void, - windowsToWslPath: (distro, windowsPath) => - Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()), - getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()), - getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()), - prepareRuntime: (distro, archive) => - Effect.succeed( + preWarm: Effect.fn("DesktopWslEnvironment.preWarm")(function () { + return Effect.void; + }), + windowsToWslPath: Effect.fn("DesktopWslEnvironment.windowsToWslPath")( + function (distro, windowsPath) { + return Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()); + }, + ), + getUserHome: Effect.fn("DesktopWslEnvironment.getUserHome")(function (distro) { + return Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()); + }), + getDistroIp: Effect.fn("DesktopWslEnvironment.getDistroIp")(function (distro) { + return Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()); + }), + prepareRuntime: Effect.fn("DesktopWslEnvironment.prepareRuntime")(function (distro, archive) { + return Effect.succeed( stub.prepareRuntime?.(distro, archive) ?? { ok: false, reason: "prepareRuntime stub not configured", }, - ), - pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, - invalidateRuntime: (distro, runtimeId) => - stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, - probeRuntime: (distro, linuxAppRoot) => - Effect.succeed( - stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" }, - ), - ensureNodePty: (distro, linuxAppRoot, options) => - Effect.succeed( - stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { - ok: false, - reason: "ensureNodePty stub not configured", - fatal: true, - }, - ), + ); + }), + pruneRuntimes: Effect.fn("DesktopWslEnvironment.pruneRuntimes")(function (distro, runtimeId) { + return stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void; + }), + invalidateRuntime: Effect.fn("DesktopWslEnvironment.invalidateRuntime")( + function (distro, runtimeId) { + return stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void; + }, + ), + probeRuntime: Effect.fn("DesktopWslEnvironment.probeRuntime")( + function (distro, linuxAppRoot) { + return Effect.succeed( + stub.probeRuntime?.(distro, linuxAppRoot) ?? { + ok: true, + resolvedPath: "/usr/bin:/bin", + }, + ); + }, + ), + ensureNodePty: Effect.fn("DesktopWslEnvironment.ensureNodePty")( + function (distro, linuxAppRoot, options) { + return Effect.succeed( + stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { + ok: false, + reason: "ensureNodePty stub not configured", + fatal: true, + }, + ); + }, + ), }), ); }; @@ -1151,31 +1173,29 @@ export const layer = Layer.effect( Effect.withSpan("desktop.wsl.listDistros"), ), probeDistros, - preWarm: (distro) => - provideSpawner(preWarmImpl(distro)).pipe(Effect.withSpan("desktop.wsl.preWarm")), + preWarm: Effect.fn("desktop.wsl.preWarm")(function (distro) { + return provideSpawner(preWarmImpl(distro)); + }), windowsToWslPath, getUserHome, getDistroIp, - prepareRuntime: (distro, archive) => - provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)).pipe( - Effect.withSpan("desktop.wsl.prepareRuntime"), - ), - pruneRuntimes: (distro, runtimeId) => - provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)).pipe( - Effect.withSpan("desktop.wsl.pruneRuntimes"), - ), - invalidateRuntime: (distro, runtimeId) => - provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( - Effect.withSpan("desktop.wsl.invalidateRuntime"), - ), - probeRuntime: (distro, linuxAppRoot) => - provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe( - Effect.withSpan("desktop.wsl.probeRuntime"), - ), - ensureNodePty: (distro, linuxAppRoot, options) => - provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( - Effect.withSpan("desktop.wsl.ensureNodePty"), - ), + prepareRuntime: Effect.fn("desktop.wsl.prepareRuntime")(function (distro, archive) { + return provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)); + }), + pruneRuntimes: Effect.fn("desktop.wsl.pruneRuntimes")(function (distro, runtimeId) { + return provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)); + }), + invalidateRuntime: Effect.fn("desktop.wsl.invalidateRuntime")(function (distro, runtimeId) { + return provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)); + }), + probeRuntime: Effect.fn("desktop.wsl.probeRuntime")(function (distro, linuxAppRoot) { + return provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)); + }), + ensureNodePty: Effect.fn("desktop.wsl.ensureNodePty")( + function (distro, linuxAppRoot, options) { + return provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)); + }, + ), }); }), ); 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/README.md b/apps/mobile/README.md index b3b0556f6b285..1b033082d58f9 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -22,20 +22,33 @@ repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See ## Development -For simulator/emulator development, select and boot a device, then ensure its native client matches -this checkout before starting Metro: +For simulator, emulator, or Android phone development, boot or connect a device, then install the +native client that matches this checkout before starting Metro: ```bash -node ../../scripts/mobile-native-client.ts ensure ios -# Or: node ../../scripts/mobile-native-client.ts ensure android +vp run mobile:install ios [simulator-udid] +# Or: vp run mobile:install android [emulator-or-phone-serial] vp run dev:client ``` -The helper compares a local Expo fingerprint and the installed binary with its last successful -build record. It builds and installs missing, stale, or unverified clients and reuses matching ones. -Use `check` instead of `ensure` for a read-only decision: exit 0 means compatible, 2 means a build is -needed, and 1 means an operational error. Run it on the simulator host; no EAS login is required. -An externally installed client is unverified until the helper builds it once. +The device argument can be omitted when exactly one device is booted or connected. The helper +compares this checkout's Expo fingerprint and the installed binary with its last install record, +and reuses a matching client. Otherwise it installs the build for that fingerprint from +`~/.cache/t3code/native-clients/artifacts`, then the shared task cache, and builds only when +neither has it. One iOS simulator build serves every iPhone and iPad simulator, and one APK per +ABI (x86_64 or arm64-v8a) serves every emulator and phone of that ABI. Build output goes to the +log file it prints (`T3_NATIVE_CLIENT_VERBOSE=1` streams it instead). Builds are shared back when +your task-cache login (`bash infra/task-cache/install.sh`) can write. `T3_NATIVE_CLIENT_CACHE=read` +skips the upload and `off` skips the shared cache. Physical iPhones still use `vp run ios:dev`. + +`--session ` installs into an agent-device session opened elsewhere, such as a device pool +or a cloud simulator, instead of a local device: `vp run mobile:install android --session pool-1 +--metro http://:8081`. It uses only the cached build (x86_64 by default; `--abi` +for ARM) and fails with the next step when no machine has built this fingerprint yet. + +For a read-only decision, run `node ../../scripts/mobile-native-client.ts check +[device]`: exit 0 means compatible, 2 means an install is needed, and 1 means an operational error. +An externally installed client is unverified until the helper installs it once. Start Metro for an already verified dev client: 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 f9af134ff22de..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 only 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-8d2f84a8.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz deleted file mode 100644 index 1dc70ba39f327..0000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.43.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.43.tgz deleted file mode 100644 index 9406a8d7fcfff..0000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.43.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 15ae4a15ba4af..f773c2c5bbc3a 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -18,6 +18,13 @@ "image": "macos-tahoe-26.6-xcode-27.1" } }, + "development-simulator": { + "extends": "development", + "ios": { + "simulator": true, + "image": "macos-tahoe-26.6-xcode-27.1" + } + }, "preview": { "corepack": true, "env": { @@ -59,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-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index d78fa318051e2..a5b8f7a44e3a6 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -10,6 +10,7 @@ public final class T3NativeControlsModule: Module { private var filePresentation: T3NativeFilePresentation? public func definition() -> ModuleDefinition { + Events("videoPlaybackChange") Constants { if #available(iOS 26.0, *) { return [ @@ -247,10 +248,16 @@ public final class T3NativeControlsModule: Module { userInfo: [NSLocalizedDescriptionKey: "The video preview is no longer available."] ) } - let presentation = T3NativeVideoPresentation(identifier: identifier, url: url, title: title) { [weak self] error in - self?.videoPresentation = nil - if let error { promise.reject(error) } else { promise.resolve(nil) } - } + let presentation = T3NativeVideoPresentation( + identifier: identifier, url: url, title: title, + onPlaybackChange: { [weak self] isPlaying in + self?.sendEvent("videoPlaybackChange", ["identifier": identifier, "isPlaying": isPlaying]) + }, + completion: { [weak self] error in + self?.videoPresentation = nil + if let error { promise.reject(error) } else { promise.resolve(nil) } + } + ) videoPresentation = presentation presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift index 388e12cb668c6..fa0fdcafa7c7c 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift @@ -7,6 +7,7 @@ final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, private let controller = AVPlayerViewController() private let completion: (Error?) -> Void private var itemObservation: NSKeyValueObservation? + private var playbackObservation: NSKeyValueObservation? private var backgroundObserver: NSObjectProtocol? private var playbackError: Error? private var presented = false @@ -27,7 +28,8 @@ final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, private weak var fullScreenController: UIViewController? private var embedded = false - init(identifier: String, url: URL, title: String, completion: @escaping (Error?) -> Void) { + init(identifier: String, url: URL, title: String, + onPlaybackChange: @escaping (Bool) -> Void, completion: @escaping (Error?) -> Void) { self.identifier = identifier self.completion = completion super.init() @@ -38,6 +40,12 @@ final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, metadata.value = title as NSString item.externalMetadata = [metadata] controller.player = AVPlayer(playerItem: item) + playbackObservation = controller.player?.observe(\.timeControlStatus, options: [.new]) { player, _ in + // Waiting keeps the playback request alive through buffering and seeks. + if player.timeControlStatus != .waitingToPlayAtSpecifiedRate { + onPlaybackChange(player.timeControlStatus == .playing) + } + } controller.delegate = self controller.overrideUserInterfaceStyle = .dark controller.allowsPictureInPicturePlayback = false @@ -148,6 +156,7 @@ final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, controller.removeFromParent() } itemObservation = nil + playbackObservation = nil controller.player = nil if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } backgroundObserver = nil diff --git a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift index 642a87f14e85a..cb3c9041efcc5 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift @@ -2,16 +2,18 @@ import ExpoModulesCore import UIKit /// Wraps a screen's header scroll view. Where a column pads its content away from the iPhone Duo's -/// vertical bar, this widens the scroll view under the bar. UIKit draws -/// the header's scroll-edge effect inside the scroll view, so the blur reaches the bar like in the -/// system apps while the content stays in the safe area. Everything happens in UIKit layout, so -/// folds move the blur and the content together. +/// vertical bar, this widens the scroll view under the bar, like the system apps' lists. UIKit +/// draws the header's scroll-edge effect inside the scroll view, so the blur reaches the bar while +/// the content stays in the safe area. Everything happens in UIKit layout, so folds move the blur +/// and the content together. final class T3ScrollEdgeBleedView: ExpoView { // Fabric recycles scroll views, so everything changed here is put back before this view leaves // the window, which unmounting does before any recycling. private var scrollView: UIScrollView? private var container: UIView? - private var bleed: CGFloat = 0 + /// React Native's content view, which every row lives in. + private var content: UIView? + private var bleed = UIEdgeInsets.zero /// Whether the scroll view currently carries this view's changes. private var applied = false /// React Native's clipping, saved while bleeding. React Native only rewrites it when the @@ -55,55 +57,72 @@ final class T3ScrollEdgeBleedView: ExpoView { detach() scrollView = found container = parent + // React Native adds one plain UIView to its scroll view and mounts every child into it. + content = found.subviews.first { type(of: $0) == UIView.self } } bleed = verticalBarBleed() + // Without the content view to shift back, bleeding left would move the rows under the bar. + if content == nil { bleed.left = 0 } apply() } - /// How far the vertical bar's safe area reaches past this view's right edge, when this view sits - /// flush against the padding its column added for it. Nothing else is bled into: not a sidebar - /// column overlapping this one, not a notch, and not a bar on the left, where widening would - /// move the content and need a horizontal offset React Native keeps rewriting. - private func verticalBarBleed() -> CGFloat { - guard verticalBarIsOnTheRight() else { return 0 } + /// How far the vertical bar's safe area reaches past this view's edge, when this view sits flush + /// against the padding its column added for it. Nothing else is bled into: not a sidebar column + /// overlapping this one, and not a notch. + private func verticalBarBleed() -> UIEdgeInsets { + guard let side = verticalBarSide() else { return .zero } var ancestor = superview while let view = ancestor { - let inset = view.safeAreaInsets.right + let inset = side == .left ? view.safeAreaInsets.left : view.safeAreaInsets.right if inset > 0 { let frame = convert(bounds, to: view) - return abs(view.bounds.maxX - inset - frame.maxX) < 0.5 ? inset : 0 + let flush = side == .left + ? abs(frame.minX - (view.bounds.minX + inset)) < 0.5 + : abs(view.bounds.maxX - inset - frame.maxX) < 0.5 + guard flush else { return .zero } + return side == .left + ? UIEdgeInsets(top: 0, left: inset, bottom: 0, right: 0) + : UIEdgeInsets(top: 0, left: 0, bottom: 0, right: inset) } ancestor = view.superview } - return 0 + return .zero } - private func verticalBarIsOnTheRight() -> Bool { + private enum Side { case left, right } + + private func verticalBarSide() -> Side? { // The vertical-bar API ships in the iOS 27.1 SDK (UIKit 9127.0.85); see T3LayoutMetricsView. #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft switch traitCollection.verticalBarEdge { - case .leading: return rtl - case .trailing: return !rtl - default: return false + case .leading: return rtl ? .right : .left + case .trailing: return rtl ? .left : .right + default: return nil } } #endif - return false + return nil } - /// Widening to the right leaves the content where React Native laid it out, so only the frame, - /// the container's clipping and the scroll indicator change; contentInset and contentOffset are - /// never touched. Nothing at all is written unless this view is bleeding or undoing its bleed. + /// Widening to the left would move the rows with it. Instead of a horizontal content offset, + /// which React Native rewrites on its own layout passes, the content view's bounds origin shifts + /// them back: React Native only ever sets that view's frame, which leaves its bounds origin. + /// Nothing at all is written unless this view is bleeding or undoing its bleed. private func apply() { guard let scrollView, let container else { return } - guard bleed > 0 || applied else { return } + let bleeding = bleed != .zero + guard bleeding || applied else { return } // React Native sizes the scroll view's component view; the UIScrollView inside follows it by // autoresizing, so the widened frame survives React Native's own layout updates. - let frame = container.bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: -bleed)) + let frame = container.bounds.inset( + by: UIEdgeInsets(top: 0, left: -bleed.left, bottom: 0, right: -bleed.right)) if scrollView.frame != frame { scrollView.frame = frame } - if bleed == 0 { + if let content, content.bounds.origin.x != -bleed.left { + content.bounds.origin.x = -bleed.left + } + if !bleeding { if let clipped = containerClipped { containerClipped = nil container.layer.masksToBounds = clipped @@ -115,19 +134,20 @@ final class T3ScrollEdgeBleedView: ExpoView { container.layer.masksToBounds = false } var indicators = scrollView.verticalScrollIndicatorInsets - if indicators.right != bleed { - indicators.right = bleed + if indicators.right != bleed.right { + indicators.right = bleed.right scrollView.verticalScrollIndicatorInsets = indicators } - applied = bleed > 0 + applied = bleeding } private func detach() { guard scrollView != nil else { return } - bleed = 0 + bleed = .zero apply() scrollView = nil container = nil + content = nil } /// The scroll view sits a fixed few levels down (marker, React Native's scroll view component); 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-source-text/src/SourceText.tsx b/apps/mobile/modules/t3-source-text/src/SourceText.tsx index aebac31d42c35..70bbb4fd48a64 100644 --- a/apps/mobile/modules/t3-source-text/src/SourceText.tsx +++ b/apps/mobile/modules/t3-source-text/src/SourceText.tsx @@ -28,18 +28,23 @@ export function SourceText({ style, children, nativeTextRef, ...props }: SourceT ); } - const runs = React.Children.toArray(children).map((child, index) => { - if (React.isValidElement(child)) return child; - if (typeof child !== "string" && typeof child !== "number") return null; - return ( - - ); - }); + const runs = React.Children.toArray(children).map( + ( + child, + index, + ) => /* oxlint-disable react/no-array-index-key -- Native text runs are stateless positions within the supplied text. */ { + if (React.isValidElement(child)) return child; + if (typeof child !== "string" && typeof child !== "number") return null; + return ( + + ); + } /* oxlint-enable react/no-array-index-key */, + ); if (isAncestor) { return {runs}; } diff --git a/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec b/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec index 2d40f97769732..a4123a555036a 100644 --- a/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec +++ b/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec @@ -14,6 +14,10 @@ Pod::Spec.new do |s| s.source = { :path => '.' } s.source_files = 'ios/**/*.{h,m,mm,swift}' s.vendored_frameworks = 'Vendor/libghostty/GhosttyKit.xcframework' + # libghostty has no x86_64 simulator slice, so a simulator build for every + # architecture (EAS's generic destination) fails to resolve GhosttyKit. + s.pod_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.user_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } s.frameworks = 'IOSurface', 'Metal', 'MetalKit', 'QuartzCore', 'UIKit' s.libraries = 'c++', 'z' s.swift_version = '5.9' 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 cf99f02c028dc..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 @@ -11,9 +11,14 @@ class T3TerminalModule : Module() { // logs so a stale native binary is distinguishable from a broken key pipeline. Constants( "hardwareKeyRevision" to 2, + "outputRevision" to 1, ) View(T3TerminalView::class) { + AsyncFunction("writeOutput") { view: T3TerminalView, data: String, reset: Boolean -> + view.writeOutput(data, reset) + } + Prop("terminalKey") { view: T3TerminalView, terminalKey: String -> view.terminalKey = terminalKey } @@ -61,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 ab7d306e5960f..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 @@ -55,8 +55,11 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } private var terminalHandle = 0L private var fedBuffer = "" + 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") @@ -73,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) { @@ -90,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 @@ -326,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 @@ -342,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() } @@ -361,13 +391,14 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex paletteColors, ) fedBuffer = "" + streamedOutput?.let { output -> feedOutput(output.data) } } private fun recreateTerminal() { if (terminalHandle == 0L) return destroyTerminal() createTerminal() - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -379,8 +410,14 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalCanvas.resetSelectionState() } + fun applyPendingBuffer() { + if (!hasPendingBuffer) return + hasPendingBuffer = false + feedPendingBuffer() + } + private fun feedPendingBuffer() { - if (terminalHandle == 0L || initialBuffer == fedBuffer) return + if (streamedOutput != null || terminalHandle == 0L || initialBuffer == fedBuffer) return if (!initialBuffer.startsWith(fedBuffer)) { recreateTerminal() if (terminalHandle == 0L) return @@ -399,6 +436,29 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex renderSnapshot() } + fun writeOutput(data: String, reset: Boolean) { + if (isCleanedUp) return + val output = streamedOutput ?: TerminalOutputBuffer() + val bytes = data.toByteArray(Charsets.UTF_8) + output.write(bytes, reset) + streamedOutput = output + if (reset) { + recreateTerminal() + } else if (terminalHandle != 0L) { + feedOutput(bytes) + renderSnapshot() + } + } + + private fun feedOutput(data: ByteArray) { + if (terminalHandle == 0L || data.isEmpty()) return + emitResponse(GhosttyBridge.nativeFeed(terminalHandle, data)) + if (terminalCanvas.hasActiveSelection()) { + GhosttyBridge.nativeClearSelection(terminalHandle) + terminalCanvas.resetSelectionState() + } + } + private fun renderSnapshot() { if (terminalHandle == 0L) return TerminalFrame.decode( diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalOutputBuffer.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalOutputBuffer.kt new file mode 100644 index 0000000000000..fde3393b911e4 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalOutputBuffer.kt @@ -0,0 +1,71 @@ +package expo.modules.t3terminal + +import java.util.ArrayDeque + +private const val REPLAY_LIMIT = 512 * 1024 +private const val REPLAY_CHUNK_SIZE = 16 * 1024 + +// Keep only replay history for intentional terminal recreation. +internal class TerminalOutputBuffer { + private val chunks = ArrayDeque() + private var firstOffset = 0 + private var tailSize = 0 + private var size = 0 + + // Only terminal recreation needs contiguous history; appends reuse the retained chunks. + val data: ByteArray + get() { + val result = ByteArray(size) + var destination = 0 + var start = firstOffset + for (chunk in chunks) { + val end = if (chunk === chunks.last) tailSize else chunk.size + chunk.copyInto(result, destination, start, end) + destination += end - start + start = 0 + } + return result + } + + fun write(incoming: ByteArray, reset: Boolean) { + if (reset || incoming.size >= REPLAY_LIMIT) clear() + var start = maxOf(0, incoming.size - REPLAY_LIMIT) + while (start < incoming.size && incoming[start].toInt() and 0xC0 == 0x80) start += 1 + while (start < incoming.size) { + if (chunks.isEmpty() || tailSize == REPLAY_CHUNK_SIZE) { + chunks.addLast(ByteArray(REPLAY_CHUNK_SIZE)) + tailSize = 0 + } + val count = minOf(incoming.size - start, REPLAY_CHUNK_SIZE - tailSize) + incoming.copyInto(chunks.last, tailSize, start, start + count) + start += count + tailSize += count + size += count + } + discardPrefix(maxOf(0, size - REPLAY_LIMIT)) + while (size > 0 && chunks.first[firstOffset].toInt() and 0xC0 == 0x80) discardPrefix(1) + } + + private fun discardPrefix(count: Int) { + var remaining = count + while (remaining > 0) { + val end = if (chunks.size == 1) tailSize else chunks.first.size + val discarded = minOf(remaining, end - firstOffset) + firstOffset += discarded + remaining -= discarded + size -= discarded + if (firstOffset == end) { + chunks.removeFirst() + firstOffset = 0 + if (chunks.isEmpty()) tailSize = 0 + } + } + } + + private fun clear() { + chunks.clear() + firstOffset = 0 + tailSize = 0 + size = 0 + } +} diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index a3ac8f622c509..d68e46a5416b1 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -8,9 +8,14 @@ public class T3TerminalModule: Module { // logs so a stale native binary is distinguishable from a broken key pipeline. Constants([ "hardwareKeyRevision": 3, + "outputRevision": 1, ]) View(T3TerminalView.self) { + AsyncFunction("writeOutput") { (view: T3TerminalView, data: String, reset: Bool) in + view.writeOutput(data, reset: reset) + } + Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in view.terminalKey = terminalKey } @@ -58,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 f407080371ec0..0ae146bb1241a 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -196,6 +196,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { private var lastContentScale: CGFloat = 0 private var lastReportedGrid: (cols: Int, rows: Int)? private var lastAppliedBuffer = "" + private var streamedOutput: TerminalOutputBuffer? private var pendingVerticalScrollPoints: CGFloat = 0 private var app: ghostty_app_t? private var surface: ghostty_surface_t? @@ -242,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() } } @@ -526,7 +541,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme) setupWriteCallback() resizeSurface() - feedBuffer(initialBuffer) + if let streamedOutput { + feedData(streamedOutput.data) + } else if !hasPendingBuffer { + feedBuffer(initialBuffer) + } } private func resetSurface() { @@ -554,9 +573,23 @@ 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) { + guard streamedOutput == nil else { return } guard surface != nil else { createSurfaceIfPossible() return @@ -579,6 +612,20 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { createSurfaceIfPossible() } + func writeOutput(_ data: String, reset: Bool) { + let output = streamedOutput ?? TerminalOutputBuffer() + let bytes = Data(data.utf8) + output.write(bytes, reset: reset) + streamedOutput = output + if reset { + refreshSurface() + } else if surface == nil { + createSurfaceIfPossible() + } else { + feedData(bytes) + } + } + private func feedBuffer(_ buffer: String) { guard !buffer.isEmpty else { return } feedData(Data(buffer.utf8)) @@ -627,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() { @@ -638,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() { @@ -701,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/modules/t3-terminal/ios/TerminalOutputBuffer.swift b/apps/mobile/modules/t3-terminal/ios/TerminalOutputBuffer.swift new file mode 100644 index 0000000000000..b25c18cd0b659 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/ios/TerminalOutputBuffer.swift @@ -0,0 +1,28 @@ +import Foundation + +// Keep only replay history for intentional surface recreation (font/theme changes). +final class TerminalOutputBuffer { + private var storage = Data() + private var retainedStart = 0 + private let limit = 512 * 1024 + + var data: Data { storage.subdata(in: retainedStart..= limit { + storage = incoming.subdata(in: max(0, incoming.count - limit)..= limit { + storage = storage.subdata(in: retainedStart.. { - 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 8b444aa08f4f8..225496bb7a407 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,4 +1,6 @@ -import { PermissionUpdateNotice } from "./features/connection/PermissionUpdateNotice"; +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,22 +25,55 @@ 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"; import { environmentShell } from "./state/shell"; 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 { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider"; +import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; import { @@ -45,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"; @@ -55,10 +103,28 @@ 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" }, - filter: shouldHandleAppLink, + filter: (url: string) => shouldHandleAppLink(url, hasCloudPublicConfig()), getInitialURL: async () => (await Linking.getInitialURL()) ?? (await launchWorkspaceURL()), getActionFromState: ( state: Parameters[0], @@ -66,20 +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]) => - 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 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]); @@ -88,18 +207,6 @@ function SplashScreenCoordinator() { } export default function App() { - useUiRuntimeMemoryWarningGc(); - useEffect( - () => - subscribeComposerDraftModelReconciliation({ - registry: appAtomRegistry, - threadShellAtom: environmentThreadShells.threadShellAtom, - threadRefsAtom: environmentThreadShells.threadRefsAtom, - shellStateAtom: environmentShell.stateAtom, - }), - [], - ); - return ( @@ -119,31 +226,26 @@ function AppContent() { <> - - - - {/* native-stack needs the theme's dark flag to select dark native headers. */} - - - - recordWorkspaceSelections(navigationRef.getRootState())} - theme={navigationTheme} - onStateChange={recordWorkspaceSelections} - /> - - - - - - - - + + {/* native-stack needs the theme's dark flag to select dark native headers. */} + + + + + + + + + diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 8a07f8b7a0867..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"; @@ -65,7 +68,10 @@ import { ThreadPullRequestsSheet } from "./features/threads/ThreadPullRequestsSh import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; import { ThreadBackgroundWorkSheet } from "./features/threads/ThreadBackgroundWorkSheet"; import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; -import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; +import { + FullscreenThreadRouteScreen, + ThreadRouteScreen, +} from "./features/threads/ThreadRouteScreen"; import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; @@ -79,11 +85,11 @@ import { NewTaskEnvironmentPickerRouteScreen, } from "./features/threads/NewTaskContextPickerScreens"; import { - ExistingThreadSettingsRouteProvider, ExistingThreadSettingsRouteScreen, 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"; @@ -95,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 { @@ -123,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"; @@ -131,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); @@ -261,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, @@ -272,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, @@ -294,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, @@ -314,7 +320,7 @@ const SETTINGS_SCREENS = { linking: "settings/archive", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Archived Threads", + title: "Archive", }, }), SettingsNavigation: createNativeStackScreen({ @@ -335,7 +341,7 @@ const SETTINGS_SCREENS = { linking: "settings/project-grouping", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Organization", + title: "General", }, }), SettingsOrganization: createNativeStackScreen({ @@ -343,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, @@ -372,7 +378,7 @@ const SETTINGS_SCREENS = { linking: "settings/follow-ups", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Follow-ups", + title: "Chat", }, }), SettingsScheduledTasks: createNativeStackScreen({ @@ -380,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" }, }, }), @@ -470,97 +476,74 @@ 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} - - - - - + + + + {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, @@ -813,6 +801,13 @@ const RootStackConfig = createWorkspaceStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/terminal`, options: SOLID_HEADER_OPTIONS, }), + ThreadInspectorFullscreen: createNativeStackScreen({ + screen: FullscreenThreadRouteScreen, + options: { + ...THREAD_HEADER_OPTIONS, + presentation: "fullScreenModal", + }, + }), ThreadDevicePreview: createNativeStackScreen({ screen: DevicePreviewRouteScreen, linking: `${THREAD_LINKING_PREFIX}/devices`, @@ -1067,6 +1062,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig const nativeMetrics = useNativeLayoutMetrics(); const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView; + /* oxlint-disable react/no-unstable-nested-components -- React Navigation invokes screenLayout as a render callback. */ return ( } @@ -1077,6 +1073,8 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig } /> ); + + /* oxlint-enable react/no-unstable-nested-components */ }); type RootStackType = typeof RootStack; 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/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 8cac0b59cfdf9..923afb87a3a1f 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -3,10 +3,13 @@ import { composerImageGallery } from "../lib/composerImageGallery"; import { SymbolView } from "./ui/AppSymbol"; import { imageMimeType } from "@t3tools/shared/image"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEffect, useMemo, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { Paths } from "expo-file-system"; import { Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./ui/AppText"; +import { MaterializedPreviewKey, materializedPreviewAtom } from "../lib/composerImagePreview"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { isFileBackedComposerAttachment, @@ -93,65 +96,31 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr ); } -const PREVIEW_CACHE_DIRECTORY = "t3-composer-previews"; - /** Fabric re-parses URLs each layout; large data URLs can stall and abort commits. */ const INLINE_PREVIEW_FALLBACK_MAX_CHARS = 256_000; -async function materializeDataUrlPreview(id: string, dataUrl: string): Promise { - const comma = dataUrl.indexOf(","); - if (comma < 0) return null; - const { Directory, File, Paths } = await import("expo-file-system"); - const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/jpeg"; - const extension = (mimeType.split("/")[1] ?? "jpg").replace("jpeg", "jpg"); - const directory = new Directory(Paths.cache, PREVIEW_CACHE_DIRECTORY); - directory.create({ idempotent: true, intermediates: true }); - const file = new File(directory, `${id}.${extension}`); - if (!file.exists) { - file.create(); - await file.write(dataUrl.slice(comma + 1), { encoding: "base64" }); - } - return file.uri; -} +const noMaterializedPreview = Atom.make(AsyncResult.initial()).pipe( + Atom.withLabel("composer-image-preview:none"), +); function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string | null { - const { id, fileUri, previewUri } = attachment; - const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null); - const [materialized, setMaterialized] = useState<{ id: string; uri: string | null } | null>(null); + const { fileUri, previewUri } = attachment; const inlinePreview = fileUri === undefined && previewUri.startsWith("data:"); - useEffect(() => { - if (fileUri === undefined) return; - let cancelled = false; - void (async () => { - const { Paths } = await import("expo-file-system"); - const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri); - if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned }); - })(); - return () => { - cancelled = true; - }; - }, [fileUri, previewUri]); - useEffect(() => { - if (!inlinePreview) return; - let cancelled = false; - void materializeDataUrlPreview(id, previewUri) - .then((uri) => { - if (!cancelled && uri !== null) setMaterialized({ id, uri }); - }) - .catch((error: unknown) => { - console.warn("[composer-attachments] could not cache an image preview", error); - if (!cancelled) setMaterialized({ id, uri: null }); - }); - return () => { - cancelled = true; - }; - }, [id, inlinePreview, previewUri]); - if (fileUri !== undefined && rebased?.fileUri === fileUri) return rebased.uri; - if (fileUri !== undefined) return previewUri.startsWith("data:") ? fileUri : previewUri; + const materialized = useAtomValue( + inlinePreview + ? materializedPreviewAtom(new MaterializedPreviewKey(attachment)) + : noMaterializedPreview, + ); + if (fileUri !== undefined) { + // Reinstalls move the app container, so a stored file URI may point at the old one. + const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri); + if (owned !== null) return owned; + return previewUri.startsWith("data:") ? fileUri : previewUri; + } if (inlinePreview) { - if (materialized?.id !== id) return null; + if (!AsyncResult.isSuccess(materialized)) return null; return ( - materialized.uri ?? + materialized.value ?? (previewUri.length <= INLINE_PREVIEW_FALLBACK_MAX_CHARS ? previewUri : null) ); } @@ -280,15 +249,12 @@ function ComposerVideoAttachment(props: { const { attachment } = props; const sourceIdentifier = `draft:${attachment.id}`; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; - const actionsSource = useMemo( - () => ({ - name: attachment.name, - mimeType: videoMimeType(attachment) ?? attachment.mimeType, - sourceIdentifier, - attachment, - }), - [attachment, sourceIdentifier], - ); + const actionsSource: MediaActionsSource = { + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + sourceIdentifier, + attachment, + }; return ( composerImageGallery(props.attachments), [props.attachments]); + const gallery = composerImageGallery(props.attachments); const size = props.imageSize ?? 72; const radius = props.imageBorderRadius ?? 16; const removeButtonPlacement = props.removeButtonPlacement ?? "overlay"; diff --git a/apps/mobile/src/components/ComposerContextAttachment.tsx b/apps/mobile/src/components/ComposerContextAttachment.tsx index b198c5add654c..edb5522f3d0f8 100644 --- a/apps/mobile/src/components/ComposerContextAttachment.tsx +++ b/apps/mobile/src/components/ComposerContextAttachment.tsx @@ -5,10 +5,13 @@ import * as Dialog from "./ui/Dialog"; import type { ComposerContextRecord, EnvironmentId } from "@t3tools/contracts"; import { Pressable, View } from "react-native"; import { Image } from "expo-image"; -import { useEffect, useId, useState } from "react"; +import { useId, useState } from "react"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages"; -import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { + loadLocalAttachmentPreview, + useLocalAttachmentPreview, +} from "../lib/localAttachmentPreview"; import { downloadAndShareAttachment } from "../lib/attachmentDownload"; import { assetImageSource } from "../lib/assetImageSource"; import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; @@ -35,31 +38,13 @@ export function ComposerContextAttachment(props: { const remoteEnvironmentId = local || inlineUri ? null : (props.environmentId ?? null); const asset = useAssetUrlState(remoteEnvironmentId, resource); const refresh = useRefreshAssetUrl(remoteEnvironmentId, resource); - const [localUri, setLocalUri] = useState(null); - const [error, setError] = useState(null); + const localPreview = useLocalAttachmentPreview(local ?? null); + const error = localPreview.error ? "The local file is unavailable. Attach it again." : null; const [sharing, setSharing] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); - useEffect(() => { - if (!local) return; - const controller = new AbortController(); - let dispose: (() => void) | undefined; - void loadLocalAttachmentPreview(local, controller.signal) - .then((preview) => { - if (!preview) return; - if (controller.signal.aborted) return preview.dispose(); - dispose = preview.dispose; - setLocalUri(preview.uri); - setError(null); - }) - .catch(() => { - if (!controller.signal.aborted) setError("The local file is unavailable. Attach it again."); - }); - return () => { - controller.abort(); - dispose?.(); - }; - }, [local]); - const uri = local ? localUri : (inlineUri ?? (asset._tag === "Success" ? asset.url : null)); + const uri = local + ? localPreview.uri + : (inlineUri ?? (asset._tag === "Success" ? asset.url : null)); const share = async () => { if (sharing) return; setSharing(true); diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 598e3cf10d175..8121bfdece763 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -111,7 +111,7 @@ export function ComposerContextSheet(props: { : undefined; if (record && !("payload" in record) && (record.kind === "image" || record.kind === "file")) { const mimeType = videoMimeType(record) ?? record.mimeType; - const previewKind = filePreviewKind(record); + const previewKind = filePreviewKind(record, { richDocuments: false }); const resource = { _tag: "attachment" as const, attachmentId: record.attachmentId, @@ -268,18 +268,23 @@ export function ComposerContextSheet(props: { - {record.elements?.map((element, index) => ( - - - - - - - - ))} + {record.elements?.map( + (element, index) => ( + /* oxlint-disable react/no-array-index-key -- Captured DOM elements are a fixed snapshot without persistent element IDs. */ + + + + + + + ) /* oxlint-enable react/no-array-index-key */, + )} ) : null} {record.kind === "element" ? ( @@ -319,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 8dfbb5587b621..59c0a74e4525f 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -3,7 +3,7 @@ import { ComposerContextId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; @@ -11,6 +11,7 @@ import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEdit import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor"; import { appendComposerDraftAttachments, + composerContextImportsAtom, createComposerDraftContextHistory, getComposerDraftAfterSelection, getComposerDraftSnapshot, @@ -22,8 +23,11 @@ import { useComposerDraft, } from "../state/use-composer-drafts"; import { importComposerContextClipboard } from "../lib/composerContextClipboard"; +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 { @@ -58,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( @@ -73,32 +78,34 @@ export function ComposerEditor({ const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( null, ); - const importRef = useRef(null); - const [importing, setImporting] = useState(false); - useEffect( - () => () => { - importRef.current?.abort(); - }, - [draftKey], - ); + const linkedEnvironments = useLinkedEnvironments(environmentId ?? null); + const contextImports = useAtomValue(composerContextImportsAtom); + const importing = draftKey ? contextImports[draftKey] === true : false; const pasteContext = async ( clipboard: Parameters>[0], ) => { - if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; + if ( + !draftKey || + appAtomRegistry.get(composerContextImportsAtom)[draftKey] || + props.readOnly || + props.editable === false + ) + return; const insertion = { text: clipboard.value, ...clipboard.selection }; - const controller = new AbortController(); - importRef.current = controller; - setImporting(true); + const operation = startComposerDraftOperation(draftKey); + const { signal } = operation; setComposerContextImporting(draftKey, true); const settled = await settlePromise(async () => { const retained = getComposerDraftAfterSelection(draftKey, insertion); const result = await importComposerContextClipboard( clipboard, retained.attachments.length, - controller.signal, + signal, retained.context?.records.length ?? 0, environmentId, + linkedEnvironments, ); + if (signal.aborted) return; if (!result) { insertComposerDraftText(draftKey, clipboard.text, insertion); return; @@ -125,10 +132,9 @@ export function ComposerEditor({ [...warnings, "Those references are marked unavailable."].join("\n\n"), ); }); + operation.finish(); setComposerContextImporting(draftKey, false); - importRef.current = null; - setImporting(false); - if (AsyncResult.isFailure(settled) && !controller.signal.aborted) { + if (AsyncResult.isFailure(settled) && !signal.aborted) { const error = squashAtomCommandFailure(settled); Dialog.alert( "Could not paste context", 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 fe2ebdfdfb25e..849bacd8ac41b 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -1,18 +1,14 @@ import * as Dialog from "./ui/Dialog"; -import { useNavigation } from "@react-navigation/native"; -import { - useCallback, - useContext, - useEffect, - useEffectEvent, - useState, - useSyncExternalStore, - useMemo, -} from "react"; -import { Keyboard } from "react-native"; +import { useContext, useEffect, useEffectEvent, useState } from "react"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import { useRefreshAssetUrl } from "../state/assets"; +import { + downloadAndShareAttachment, + nativeViewerErrorMessage, + shareLocalAttachment, +} from "../lib/attachmentDownload"; +import { usePreviewPresentation } from "../lib/previewPresentation"; +import { refreshAssetUrl } from "../state/assets"; import { filePreviewGallery, filePreviewKey, filePreviewName } from "../lib/filePreviewGallery"; import { FilePreviewGallery } from "./FilePreviewGallery"; import { FilePreview } from "./FilePreview"; @@ -28,77 +24,47 @@ export type { ResolvedFilePreviewSource, } from "./FilePreviewModal.types"; -function PreviewItemResolver(props: { - readonly source: FilePreviewItem; - readonly onResolved: (source: ResolvedFilePreviewSource) => void; - readonly onRequestClose: () => void; - readonly onOpenError?: (error: unknown) => void; -}) { - const { source } = props; - const environmentId = "environmentId" in source ? source.environmentId : null; - const refreshAssetUrl = useRefreshAssetUrl( - environmentId, - "resource" in source ? source.resource : null, - ); - // Resolve once per presentation; background URL refreshes must not reopen the native viewer. - const [uri, setUri] = useState("uri" in source ? source.uri : null); - const onRequestClose = useEffectEvent(props.onRequestClose); - const onResolutionError = useEffectEvent((error: unknown, fallbackMessage: string) => { - if (props.onOpenError) props.onOpenError(error); - else Dialog.alert("Could not open preview", fallbackMessage); - onRequestClose(); - }); - useEffect(() => Keyboard.dismiss(), []); - useEffect(() => { - if (environmentId === null || uri !== null) return; - let cancelled = false; - // Refresh expired URLs before handing them to Quick Look or ACTION_VIEW, which retain them. - void refreshAssetUrl() - .then((url) => { - if (cancelled) return; - if (!url) throw new Error("Reconnect to this environment and try again."); - setUri(url + (source.srcFragment ?? "")); - }) - .catch((error: unknown) => { - if (cancelled) return; - onResolutionError( - error, - "Reconnect to this environment and try again. The file may have been moved or deleted.", - ); - }); - return () => { - cancelled = true; - }; - }, [environmentId, uri, refreshAssetUrl, source.srcFragment]); - useEffect(() => { - if (!("attachment" in source)) return; - const controller = new AbortController(); - let release: (() => void) | undefined; - void loadLocalAttachmentPreview(source.attachment, controller.signal) - .then((file) => { - if (!file) return; - if (controller.signal.aborted) { - file.dispose(); - return; - } - release = file.dispose; - setUri(file.uri); - }) - .catch((error: unknown) => { - if (controller.signal.aborted) return; - onResolutionError(error, "Attach the file again and retry."); - }); - return () => { - controller.abort(); - release?.(); - }; - }, [source]); +class PreviewResolutionError extends Error { + constructor( + override readonly cause: unknown, + readonly fallbackMessage: string, + ) { + super(fallbackMessage, { cause }); + } +} - const onResolved = useEffectEvent(props.onResolved); - useEffect(() => { - if (uri !== null) onResolved({ ...source, name: filePreviewName(source), uri }); - }, [uri, source]); - return null; +interface ResolvedPreviewItem { + readonly source: ResolvedFilePreviewSource; + readonly dispose?: () => void; +} + +async function resolvePreviewItem( + item: FilePreviewItem, + signal: AbortSignal, +): Promise { + const name = filePreviewName(item); + if ("attachment" in item) { + const file = await loadLocalAttachmentPreview(item.attachment, signal).catch( + (error: unknown) => { + throw new PreviewResolutionError(error, "Attach the file again and retry."); + }, + ); + return file && { source: { ...item, name, uri: file.uri }, dispose: file.dispose }; + } + if (!("environmentId" in item)) return { source: { ...item, name } }; + // Refresh expired URLs before handing them to Quick Look or ACTION_VIEW, which retain them. + const url = await refreshAssetUrl(item.environmentId, item.resource, signal) + .then((value) => { + if (!value) throw new Error("Reconnect to this environment and try again."); + return value; + }) + .catch((error: unknown) => { + throw new PreviewResolutionError( + error, + "Reconnect to this environment and try again. The file may have been moved or deleted.", + ); + }); + return { source: { ...item, name, uri: url + (item.srcFragment ?? "") } }; } function ResolvedFilePreview(props: { @@ -108,39 +74,88 @@ function ResolvedFilePreview(props: { }) { // Freeze the group at opening; streaming messages and URL refreshes must not reopen the viewer. const [items] = useState(() => filePreviewGallery(props.source, [])); - const [resolved, setResolved] = useState>({}); - const onResolved = useCallback((item: ResolvedFilePreviewSource, key: string) => { - setResolved((current) => ({ ...current, [key]: item })); - }, []); - const selected = resolved[filePreviewKey(props.source)]; - const gallery = useMemo( - () => - items.flatMap((item) => { - const value = resolved[filePreviewKey(item)]; - return value ? [value] : []; - }), - [items, resolved], + const selectedKey = filePreviewKey(props.source); + const [gallery, setGallery] = useState(() => + items.every((item) => "uri" in item) + ? items.map((item) => ({ ...item, name: filePreviewName(item) })) + : null, ); - const ready = selected && gallery.length === items.length; + const onResolutionError = useEffectEvent((error: PreviewResolutionError) => { + if (props.onOpenError) props.onOpenError(error.cause); + else Dialog.alert("Could not open preview", error.fallbackMessage); + 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(); + // The first failure closes the preview at once and cancels the rest of the gallery. + const fail = (error: unknown) => { + if (controller.signal.aborted) return; + controller.abort(); + onResolutionError( + error instanceof PreviewResolutionError + ? error + : new PreviewResolutionError(error, "Try again."), + ); + }; + const resolving = Promise.allSettled( + items.map((item) => + resolvePreviewItem(item, controller.signal).catch((error: unknown) => { + fail(error); + throw error; + }), + ), + ); + void resolving.then((settled) => { + if (controller.signal.aborted) return; + const sources = settled.flatMap((item) => + item.status === "fulfilled" && item.value ? [item.value.source] : [], + ); + if (sources.length === items.length) setGallery(sources); + }); + return () => { + controller.abort(); + void resolving.then((settled) => { + for (const item of settled) if (item.status === "fulfilled") item.value?.dispose?.(); + }); + }; + }, [items]); + const selected = gallery?.[items.findIndex((item) => filePreviewKey(item) === selectedKey)]; + if (!gallery || !selected) return null; + const onOpenError = (error: unknown) => { + if (props.onOpenError) return props.onOpenError(error); + Dialog.alert("Could not open preview", nativeViewerErrorMessage(error), [ + { text: "Cancel", style: "cancel" }, + ...(selected.kind === "document" + ? [ + { + text: "Save or share", + onPress: () => { + const input = { + attachment: { + name: filePreviewName(selected), + mimeType: selected.mimeType ?? "application/octet-stream", + }, + signal: new AbortController().signal, + }; + const sharing = /^(file|content):/.test(selected.uri) + ? shareLocalAttachment({ ...input, uri: selected.uri }) + : downloadAndShareAttachment({ ...input, url: selected.uri }); + void sharing.catch(() => Dialog.alert("Could not share file", "Please try again.")); + }, + }, + ] + : []), + ]); + }; return ( - <> - {items.map((item) => ( - onResolved(value, filePreviewKey(item))} - onRequestClose={props.onRequestClose} - onOpenError={props.onOpenError} - /> - ))} - {ready ? ( - - ) : null} - + ); } @@ -151,41 +166,13 @@ export function FilePreviewModal(props: { readonly onOpenError?: (error: unknown) => void; }) { const group = useContext(FilePreviewGallery); - const source = useMemo( - () => - props.source === null - ? null - : { - ...props.source, - gallery: filePreviewGallery(props.source, group), - }, - [props.source, group], - ); - const navigation = useNavigation(); - const subscribeToFocus = useCallback( - (listener: () => void) => { - const offFocus = navigation.addListener("focus", listener); - const offBlur = navigation.addListener("blur", listener); - return () => { - offFocus(); - offBlur(); - }; - }, - [navigation], - ); - // The legacy envelope's focus context may describe a different route than this v5 card. - const isFocused = useSyncExternalStore( - subscribeToFocus, - navigation.isFocused, - navigation.isFocused, - ); - const hasSource = props.source !== null; - const onRequestClose = useEffectEvent(props.onRequestClose); - useEffect(() => { - if (!isFocused && hasSource) onRequestClose(); - }, [isFocused, hasSource]); + const source = + props.source === null + ? null + : { ...props.source, gallery: filePreviewGallery(props.source, group) }; + usePreviewPresentation(source && filePreviewKey(source), props.onRequestClose); - if (!source || !isFocused) return null; + if (!source) return null; return ( Promise; readonly playRequested: boolean; readonly paused: boolean; + readonly visible: boolean; + readonly onPlaybackStart?: () => void; }) { - const focused = useIsFocused(); - const fullscreenVideoOpen = useFullscreenVideoOpen(); - const paused = props.paused || fullscreenVideoOpen; - const active = useRef(focused && AppState.currentState === "active"); - const fullscreen = useRef(false); const [attempt, setAttempt] = useState(0); // Expo's Android player also reports completed playback as idle. const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending"); @@ -31,31 +28,57 @@ function LoadedMediaVideo(props: { player.staysActiveInBackground = false; player.bufferOptions = { preferredForwardBufferDuration: 5 }; }); + 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(); + else playback.playingChanged(isPlaying); + }); + const requested = player.addListener("playbackRequestedChange", ({ isPlaybackRequested }) => { + const wasPlaybackRequested = previousPlaybackRequested.current; + previousPlaybackRequested.current = isPlaybackRequested; + if (!isPlaybackRequested) { + if (playback.requested) playback.pause(); + } else if (!active.current && !fullscreen.current) playback.pause(); + else { + if (!wasPlaybackRequested) onPlaybackStart(); + playback.playingChanged(true); + } + }); + const ended = player.addListener("playToEnd", () => playback.pause()); + const failed = player.addListener("statusChange", ({ status }) => { + if (status === "error") playback.pause(); + }); + return () => { + subscription.remove(); + requested.remove(); + ended.remove(); + failed.remove(); + playback.pause(); + previousPlaybackRequested.current = false; + }; + }, [active, fullscreen, player, playback]); const { status } = useEvent(player, "statusChange", { status: player.status }); - const { isPlaying } = useEvent(player, "playingChange", { isPlaying: player.playing }); - useMediaAudioSessionHold(isPlaying); const loadSource = useEffectEvent(async (signal: AbortSignal) => { + const playIfUninterrupted = createPlayRequest(); const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri; if (signal.aborted) return; if (uri === null) throw new Error("Video unavailable"); - player.pause(); + playback.pause(); + previousPlaybackRequested.current = false; await player.replaceAsync({ uri, contentType: "progressive" }); - if (!signal.aborted && props.playRequested && active.current) player.play(); + if (!signal.aborted && props.playRequested) await playIfUninterrupted(); }); - useEffect(() => { - active.current = focused && !paused && AppState.currentState === "active"; - if (!focused || paused || (!active.current && !fullscreen.current)) player.pause(); - // RN reports both leaving the app and Android fullscreen as background; native handling distinguishes them. - const subscription = AppState.addEventListener("change", (state) => { - active.current = focused && !paused && state === "active"; - if (state === "inactive" || (state === "background" && !fullscreen.current)) player.pause(); - }); - return () => subscription.remove(); - }, [focused, player, paused]); - + // 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. setLoadState("pending"); // A renewed signature is used on Retry, not as a reason to reset the native player. void loadSource(controller.signal).then( @@ -67,6 +90,7 @@ function LoadedMediaVideo(props: { }, ); return () => controller.abort(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A play request or Retry (attempt) reloads the source into the same player. }, [player, props.playRequested, attempt]); return ( @@ -78,12 +102,8 @@ function LoadedMediaVideo(props: { nativeControls contentFit="contain" fullscreenOptions={{ enable: true }} - onFullscreenEnter={() => { - fullscreen.current = true; - }} - onFullscreenExit={() => { - fullscreen.current = false; - }} + onFullscreenEnter={onFullscreenEnter} + onFullscreenExit={onFullscreenExit} allowsPictureInPicture={false} /> {loadState === "error" || (loadState === "complete" && status === "error") ? ( @@ -113,10 +133,12 @@ interface MediaVideoPlayerProps { readonly name: string; readonly thumbnailKey: string; readonly thumbnailVisible?: boolean; + readonly visible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; readonly autoPlay?: boolean; readonly paused?: boolean; + readonly onPlaybackStart?: () => void; readonly actionsSource?: MediaActionsSource; } @@ -138,6 +160,8 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { resolvePlaybackUri={props.resolvePlaybackUri} playRequested={!props.expanded || props.autoPlay === true} paused={(props.paused ?? false) || mediaActions.sharing} + visible={props.visible ?? true} + onPlaybackStart={props.onPlaybackStart} /> ) : ( @@ -150,7 +174,10 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} // Stays pressable so the long-press menu still opens on a failed or unsigned tile. onPress={() => { - if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri); + if (props.uri !== null && !props.unavailable) { + props.onPlaybackStart?.(); + setPlaybackUri(props.uri); + } }} className="flex-1 items-center justify-center px-4" > diff --git a/apps/mobile/src/components/PdfPreview.android.tsx b/apps/mobile/src/components/PdfPreview.android.tsx index 5492c4ce7c64f..32e79d576e11e 100644 --- a/apps/mobile/src/components/PdfPreview.android.tsx +++ b/apps/mobile/src/components/PdfPreview.android.tsx @@ -1,4 +1,8 @@ -import { useEffect, useRef, useState } from "react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Atom, AsyncResult } from "effect/reactivity"; +import { useState } from "react"; import { View } from "react-native"; import Pdf from "react-native-pdf-jsi"; @@ -7,39 +11,55 @@ import { pdfInitialPage, pdfLinkUrl, preparePdfPreview } from "../lib/pdfPreview import { tryOpenExternalUrl } from "../lib/openExternalUrl"; import { ControlPill } from "./ui/ControlPill"; -function NativePdfPreview(props: { readonly uri: string; readonly onRetry: () => void }) { - const [localUri, setLocalUri] = useState(null); - const [error, setError] = useState(null); - const cached = useRef>>(null); +const preparedPdfAtom = Atom.family((uri: string) => + Atom.make( + // The downloaded copy lives as long as a viewer reads it; Retry refreshes the atom, + // which closes the scope and discards the copy before downloading again. Acquisition + // stays interruptible so closing the viewer aborts the download. + Effect.acquireRelease( + Effect.tryPromise({ + try: (signal) => { + const prepared = preparePdfPreview(uri, signal); + // An interrupt can land after the copy exists but before acquireRelease owns it. + signal.addEventListener("abort", () => { + void prepared.then( + (file) => file?.dispose(), + () => undefined, + ); + }); + return prepared; + }, + catch: (cause) => (cause instanceof Error ? cause.message : "The PDF could not be loaded."), + }).pipe( + Effect.flatMap((file) => (file === null ? Effect.interrupt : Effect.succeed(file))), + Effect.interruptible, + ), + (file) => Effect.sync(file.dispose), + ), + ).pipe(Atom.withLabel(`android-pdf-preview:${uri}`)), +); - useEffect(() => { - const controller = new AbortController(); - void preparePdfPreview(props.uri, controller.signal) - .then((file) => { - if (controller.signal.aborted) file?.dispose(); - else { - cached.current = file; - setLocalUri(file?.uri ?? null); - } - }) - .catch((cause: unknown) => { - if (!controller.signal.aborted) { - setError(cause instanceof Error ? cause.message : "The PDF could not be loaded."); - } - }); - return () => { - controller.abort(); - cached.current?.dispose(); - cached.current = null; - }; - }, [props.uri]); +export function PdfPreview(props: { readonly uri: string }) { + const atom = preparedPdfAtom(props.uri); + const prepared = useAtomValue(atom); + const refresh = useAtomRefresh(atom); + // A render failure belongs to the downloaded copy that failed; Retry replaces it. + const [unrenderable, setUnrenderable] = useState(null); + const file = prepared._tag === "Success" && !prepared.waiting ? prepared.value : null; + const error = + file !== null && unrenderable === file + ? "The PDF could not be rendered. It may be damaged or password protected." + : prepared._tag === "Failure" && !prepared.waiting + ? Option.getOrElse(AsyncResult.error(prepared), () => "The PDF could not be loaded.") + : null; + const localUri = file?.uri ?? null; if (error) { return ( {error} - + ); @@ -59,22 +79,9 @@ function NativePdfPreview(props: { readonly uri: string; readonly onRetry: () => if (url !== null) void tryOpenExternalUrl(url, "file-preview"); }} onError={() => { - cached.current?.dispose(); - cached.current = null; - setError("The PDF could not be rendered. It may be damaged or password protected."); + setUnrenderable(file); }} style={{ flex: 1, width: "100%" }} /> ); } - -export function PdfPreview(props: { readonly uri: string }) { - const [attempt, setAttempt] = useState(0); - return ( - setAttempt((value) => value + 1)} - /> - ); -} 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 ab43945dd73ea..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,17 +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); - setActiveFaviconRequest(faviconRequest); - return endRequest; + return beginProjectFaviconRequest(faviconRequest); }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => @@ -178,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 ( @@ -202,7 +183,7 @@ function ProjectFaviconImage(props: { /> )} - {requestIsActive ? ( + {faviconRequest !== null ? ( { + const rememberWidth = ({ width }: { width: number }) => { setButtonWidth((previous) => Math.max(previous, width)); - }, []); + }; return ( ; dismissVideo(identifier: string): Promise; + addListener( + event: "videoPlaybackChange", + listener: (event: { identifier: string; isPlaying: boolean }) => void, + ): { remove(): void }; }>("T3NativeControls"); function NativeVideoPreview(props: { @@ -34,71 +43,88 @@ function NativeVideoPreview(props: { const onRequestClose = useEffectEvent(props.onRequestClose); const environmentId = source.type === "media" && "environmentId" in source ? source.environmentId : null; - const resource = source.type === "media" && "resource" in source ? source.resource : null; const preparedConnection = usePreparedConnection(environmentId); - const assetUrl = useAssetUrlState(environmentId, resource); - const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource)); const name = source.type === "media" ? source.name : source.attachment.name; - // The first minted URL is kept so a background refresh does not restart playback. - const resolvedUrl = - source.type === "media" - ? mediaVideoPreviewUri(source, assetUrl._tag === "Success" ? assetUrl.url : null) - : null; - const [playbackUrl, setPlaybackUrl] = useState(resolvedUrl); - const loadError = - resource !== null && playbackUrl === null - ? preparedConnection._tag === "None" + // Mints a fresh URL for each presentation: AVKit retains the URL and offers no retry, + // so a cached link that expired while the app was suspended would fail to play. + const resolvePlaybackUrl = useEffectEvent(async (): Promise => { + if (source.type !== "media") throw new Error("Could not load this video."); + if (!("resource" in source)) return source.uri; + const url = await refreshAssetUrl(source.environmentId, source.resource); + const playbackUrl = mediaVideoPreviewUri(source, url); + if (playbackUrl !== null) return playbackUrl; + throw new Error( + preparedConnection._tag === "None" ? "Reconnect to this environment and open the video again." - : assetUrl._tag === "Failure" - ? "Could not load this video. Check the connection and try again." - : null - : null; + : "Could not load this video. Check the connection and try again.", + ); + }); + const reauthorize = useEffectEvent(() => { + if (source.type === "media" && "resource" in source) { + void refreshAssetUrl(source.environmentId, source.resource); + } + }); - useEffect(() => Keyboard.dismiss(), []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- presents the native fullscreen video and owns its audio session and file lease until cleanup useEffect(() => { - fullscreenVideoOpened(); - return () => fullscreenVideoClosed(); - }, []); - useEffect(() => { - if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl); - }, [playbackUrl, resolvedUrl]); - useEffect(() => { - if (!loadError) return; - Dialog.alert("Could not open video", loadError); - onRequestClose(); - }, [loadError]); - - useEffect(() => { - if (localAttachment === null && playbackUrl === null) return; const controller = new AbortController(); + let token: ReturnType | null = null; + const release = () => { + if (token) void releaseAudioSession(token).catch(() => {}); + token = null; + }; + const acquire = () => { + const next = acquireAudioSession("playback", () => { + release(); + void NativeControls.dismissVideo(identifier).catch(() => {}); + }); + if (!isAudioSessionOwner(next)) { + void NativeControls.dismissVideo(identifier).catch(() => {}); + return null; + } + token = next; + return next; + }; + const subscription = NativeControls.addListener("videoPlaybackChange", (event) => { + if (event.identifier !== identifier || controller.signal.aborted) return; + if (event.isPlaying) { + if (!token) acquire(); + } else release(); + }); let ready = false; + fullscreenVideoOpened(); void (async () => { const file = localAttachment !== null ? await loadLocalAttachmentPreview(localAttachment, controller.signal) : null; if (localAttachment !== null && !file) return; + const uri = file?.uri ?? (await resolvePlaybackUrl()); if (controller.signal.aborted) { file?.dispose(); return; } + const current = acquire(); + if (!current || !(await activateAudioSession(current, true)) || controller.signal.aborted) { + release(); + file?.dispose(); + if (!controller.signal.aborted) onRequestClose(); + return; + } ready = true; const presented = await settlePromise(() => - NativeControls.presentVideo( - file?.uri ?? playbackUrl!, - name, - source.sourceIdentifier ?? "", - identifier, - ), + NativeControls.presentVideo(uri, name, source.sourceIdentifier ?? "", identifier), ); // Native completion follows dismissal, so local playback keeps its file lease. file?.dispose(); + release(); if (AsyncResult.isFailure(presented)) throw squashAtomCommandFailure(presented); if (!controller.signal.aborted) onRequestClose(); })().catch((error: unknown) => { + release(); if (controller.signal.aborted) return; - // AVKit gives no retry, so re-mint now; the cached URL may simply have expired. - if (ready) void refreshAssetUrl(); + // AVKit gives no retry, so re-mint now; the minted URL may already be unusable. + if (ready) reauthorize(); Dialog.alert( "Could not open video", ready @@ -111,9 +137,12 @@ function NativeVideoPreview(props: { }); return () => { controller.abort(); + fullscreenVideoClosed(); + subscription.remove(); + release(); void NativeControls.dismissVideo(identifier).catch(() => undefined); }; - }, [localAttachment, name, source.sourceIdentifier, playbackUrl, identifier]); + }, [localAttachment, name, source.sourceIdentifier, identifier]); return null; } @@ -122,13 +151,10 @@ export function VideoPreviewModal(props: { readonly source: VideoPreviewSource | null; readonly onRequestClose: () => void; }) { - const isFocused = useIsFocused(); - const hasSource = props.source !== null; - const onRequestClose = useEffectEvent(props.onRequestClose); - useEffect(() => { - if (!isFocused && hasSource) onRequestClose(); - }, [isFocused, hasSource]); - - if (!props.source || !isFocused) return null; + usePreviewPresentation( + props.source === null ? null : videoPreviewKey(props.source), + props.onRequestClose, + ); + if (!props.source) return null; return ; } diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index 7feae6d930061..4e63851dcd3eb 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -1,11 +1,10 @@ -import { useIsFocused } from "@react-navigation/native"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEffect, useEffectEvent, useState } from "react"; -import { Keyboard, Modal, Pressable, View } from "react-native"; +import { Modal, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; -import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { useLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { usePreviewPresentation, videoPreviewKey } from "../lib/previewPresentation"; import { useMediaActions } from "../state/mediaActions"; import { type MediaActionsSource } from "../lib/mediaActionsSource"; import { @@ -56,38 +55,9 @@ function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { const { attachment } = source; - const [uri, setUri] = useState(null); - const [error, setError] = useState(null); - // Keep the file lease across metadata updates while Android is playing it. - const loadAttachment = useEffectEvent((signal: AbortSignal) => - loadLocalAttachmentPreview(attachment, signal), - ); - const { id: attachmentId, fileUri } = attachment; - useEffect(() => { - setUri(null); - setError(null); - const controller = new AbortController(); - const loading = loadAttachment(controller.signal); - void loading.then( - (file) => { - if (file === null) return; - if (controller.signal.aborted) file.dispose(); - else setUri(file.uri); - }, - (cause: unknown) => { - if (!controller.signal.aborted) { - setError(cause instanceof Error ? cause.message : "Could not load this video."); - } - }, - ); - return () => { - controller.abort(); - void loading.then( - (file) => file?.dispose(), - () => undefined, - ); - }; - }, [attachmentId, fileUri]); + const preview = useLocalAttachmentPreview(attachment); + const { uri } = preview; + const error = preview.error ? preview.error.message || "Could not load this video." : null; const mimeType = videoMimeType(attachment) ?? attachment.mimeType; return { uri, @@ -135,7 +105,6 @@ function OpenVideoPreviewModal(props: { const insets = useSafeAreaInsets(); const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose); - useEffect(() => Keyboard.dismiss(), []); return ( void; }) { - const isFocused = useIsFocused(); - const hasSource = props.source !== null; - useEffect(() => { - if (!isFocused && hasSource) props.onRequestClose(); - }, [isFocused, hasSource, props.onRequestClose]); + usePreviewPresentation( + props.source === null ? null : videoPreviewKey(props.source), + props.onRequestClose, + ); const { source } = props; - if (source === null || !isFocused) return null; + if (source === null) return null; return source.type === "local" ? ( ) : ( diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx index cfb8ceeb2aca2..6f6fea83063ca 100644 --- a/apps/mobile/src/components/VideoThumbnailImage.tsx +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -1,38 +1,53 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as Effect from "effect/Effect"; +import { Atom, AsyncResult } from "effect/reactivity"; import { Image } from "expo-image"; import { useIsFocused } from "@react-navigation/native"; import type { VideoThumbnail } from "expo-video"; -import { useEffect, useState } from "react"; import { StyleSheet } from "react-native"; import type { DraftComposerFileAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; +interface VideoThumbnailTarget { + readonly cacheKey: string; + readonly source: string | DraftComposerFileAttachment; +} + +// Plain-object keys compare structurally (Effect `Equal`/`Hash`). +const videoThumbnailAtom = Atom.family((target: VideoThumbnailTarget) => + Atom.make( + Effect.promise((signal) => + loadVideoThumbnail( + target.cacheKey, + async (sourceSignal) => + typeof target.source === "string" + ? { uri: target.source, dispose: () => undefined } + : loadLocalAttachmentPreview(target.source, sourceSignal), + signal, + ), + ), + ).pipe(Atom.withLabel(`video-thumbnail:${target.cacheKey}`)), +); + +const noVideoThumbnail = Atom.make(AsyncResult.initial()).pipe( + Atom.withLabel("video-thumbnail:none"), +); + export function VideoThumbnailImage(props: { readonly cacheKey: string; readonly source: string | DraftComposerFileAttachment | null; readonly contentFit?: "cover" | "contain"; }) { const { cacheKey, source } = props; + // Extraction runs only while the screen is focused; leaving it interrupts the load. const isFocused = useIsFocused(); - const [loaded, setLoaded] = useState<{ key: string; thumbnail: VideoThumbnail } | null>(null); - const thumbnail = loaded?.key === cacheKey ? loaded.thumbnail : cachedVideoThumbnail(cacheKey); - - useEffect(() => { - if (!source || !isFocused) return; - const controller = new AbortController(); - void loadVideoThumbnail( - cacheKey, - async (signal) => - typeof source === "string" - ? { uri: source, dispose: () => undefined } - : loadLocalAttachmentPreview(source, signal), - controller.signal, - ).then((thumbnail) => { - if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); - }); - return () => controller.abort(); - }, [cacheKey, source, isFocused]); + const loaded = useAtomValue( + source && isFocused ? videoThumbnailAtom({ cacheKey, source }) : noVideoThumbnail, + ); + const thumbnail = + (loaded._tag === "Success" ? loaded.value : null) ?? cachedVideoThumbnail(cacheKey); return thumbnail ? ( (null); const [overlay, setOverlay] = useState(null); const windowHinge = use(WindowHingeContext); - const anchorRef = useRef(null); + // State, not a ref: `open` reaches render-prop children, and the compiler + // bails out of the whole component when a ref might be read during render. + const [anchorNode, setAnchorNode] = useState(null); const overlayRef = useRef(null); const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); - const close = useCallback(() => { + const close = () => { setAnchor(null); setPath([]); setOverlay(null); setRootHeight(null); - }, []); + }; - const open = useCallback(() => { - anchorRef.current?.measureInWindow((x, y, width, height) => { + const open = () => { + anchorNode?.measureInWindow((x, y, width, height) => { setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); }); - }, [keyboardVisible]); + }; - const measureOverlay = useCallback(() => { + const measureOverlay = () => { overlayRef.current?.measureInWindow((x, y, width, height) => { setOverlay({ x, y, width, height }); setRootHeight(height); }); - }, []); + }; - const submenuDepth = path.length; - useEffect(() => { - if (anchor === null || !anchor.keyboardWasVisible) { - return; + // Only the in-window (keyboard) presentation needs this; the native popup + // handles back itself. + useHardwareBack(anchor?.keyboardWasVisible === true, () => { + if (path.length > 0) { + setPath((current) => current.slice(0, -1)); + } else { + close(); } - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - if (submenuDepth > 0) { - setPath((current) => current.slice(0, -1)); - } else { - close(); - } - return true; - }); - return () => subscription.remove(); - }, [anchor, close, submenuDepth]); + return true; + }); const parent = path[path.length - 1] ?? null; const levelActions = (parent?.subactions ?? props.actions).filter( @@ -125,31 +123,33 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); const placeable = local !== null && rootHeight !== null; - const onPressItem = useCallback( - (action: MenuAction) => { - if ((action.subactions?.length ?? 0) > 0) { - setPath((current) => [...current, action]); - return; - } - close(); - if (action.id !== undefined) { - props.onPressAction?.({ - nativeEvent: { event: action.id }, - } as Parameters>[0]); - } - }, - [close, props.onPressAction], - ); + const onPressItem = (action: MenuAction) => { + if ((action.subactions?.length ?? 0) > 0) { + setPath((current) => [...current, action]); + return; + } + close(); + if (action.id !== undefined) { + props.onPressAction?.({ + nativeEvent: { event: action.id }, + } as Parameters>[0]); + } + }; return ( <> {typeof props.children === "function" ? ( - + {props.children(open)} ) : ( + {inFormSheet ? ( + + ) : null} 0); const onSearchChange = search?.onChangeText; - const closeSearch = useCallback(() => { + const closeSearch = () => { onSearchChange?.(""); setSearchOpen(false); Keyboard.dismiss(); - }, [onSearchChange]); - useEffect(() => { - if (!searching || search?.mode === "inline") return; - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - closeSearch(); - return true; - }); - return () => subscription.remove(); - }, [closeSearch, searching, search?.mode]); + }; + useHardwareBack(searching && search?.mode !== "inline", () => { + closeSearch(); + return true; + }); const menuView = props.menus?.map((menu) => ( [] as Array<() => void>); + vi.mock("@expo/ui/jetpack-compose", () => { const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; - const Button = (props: { readonly children?: ReactNode; readonly onClick?: () => void }) => ( - - ); + const Button = (props: { readonly children?: ReactNode; readonly onClick?: () => void }) => { + if (props.onClick) renderedClicks.push(props.onClick); + return ; + }; return { AlertDialog: Object.assign(Slot, { Title: Slot, @@ -51,6 +56,7 @@ function mountHost() { afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); + renderedClicks.length = 0; document.body.innerHTML = ""; }); @@ -65,3 +71,34 @@ it("keeps presenting dialogs after an older root's Host unmounts", () => { expect(current.container.textContent).toContain("Saved"); }); + +it("presents a dialog requested before any Host mounted", () => { + Dialog.alert("Could not import shared content", undefined, [{ text: "Dismiss" }]); + + const { container } = mountHost(); + + expect(container.textContent).toContain("Could not import shared content"); +}); + +it("keeps a dialog raised while the activity is replaced", () => { + const previous = mountHost(); + act(() => Dialog.alert("Shared content imported", undefined, [{ text: "OK" }])); + const current = mountHost(); + roots.splice(roots.indexOf(previous.root), 1); + act(() => previous.root.unmount()); + + expect(current.container.textContent).toContain("Shared content imported"); +}); + +it("ignores a late action from a dialog that a newer one replaced", () => { + const { container } = mountHost(); + const olderAction = vi.fn(); + act(() => Dialog.alert("Older", undefined, [{ text: "OK", onPress: olderAction }])); + const olderClick = renderedClicks.at(-1)!; + act(() => Dialog.alert("Newer", undefined, [{ text: "OK" }])); + + act(() => olderClick()); + + expect(olderAction).toHaveBeenCalledOnce(); + expect(container.textContent).toContain("Newer"); +}); diff --git a/apps/mobile/src/components/ui/Dialog.android.tsx b/apps/mobile/src/components/ui/Dialog.android.tsx index bc93254c0f35d..af05275b7f270 100644 --- a/apps/mobile/src/components/ui/Dialog.android.tsx +++ b/apps/mobile/src/components/ui/Dialog.android.tsx @@ -14,7 +14,7 @@ import { import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useSyncExternalStore } from "react"; import { themeColorWithContrast } from "../../lib/mobileTheme"; import type { Alert, AlertButton } from "react-native"; import type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types"; @@ -33,63 +33,78 @@ type DialogRequest = | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } | { readonly kind: "text-input"; readonly request: TextInputDialogRequest }; -let presentRequest: ((request: DialogRequest) => void) | null = null; +type PresentedDialog = DialogRequest & { readonly id: number }; + +// App services raise dialogs before the root mounts, so the request lives in +// the module and the Host renders whatever is current. One dialog shows at a +// time: a new request replaces the presented one. +let presented: PresentedDialog | null = null; +let nextPresentationId = 0; +const listeners = new Set<() => void>(); + +function setPresented(next: PresentedDialog | null) { + presented = next; + for (const listener of listeners) listener(); +} + +function submitRequest(dialog: DialogRequest) { + setPresented({ ...dialog, id: ++nextPresentationId }); +} + +/** Closes the dialog only if it is still the one the action came from. */ +function dismiss(id: number) { + if (presented?.id === id) setPresented(null); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getPresented() { + return presented; +} export function alert(...[title, message, buttons, options]: Parameters): void { const actions = buttons?.length ? buttons.slice(0, 3) : [{ text: "OK" }]; const normalized = actions.map((action, index) => index === actions.length - 1 ? { ...action, text: action.text || "OK" } : action, ); - presentRequest?.({ + submitRequest({ kind: "alert", request: { title, message, buttons: normalized, options }, }); } export function confirm(request: ConfirmDialogRequest): void { - presentRequest?.({ kind: "confirm", request }); + submitRequest({ kind: "confirm", request }); } export function prompt(request: TextInputDialogRequest): void { - presentRequest?.({ kind: "text-input", request }); + submitRequest({ kind: "text-input", request }); } export function Host() { - const [presented, setPresented] = useState<(DialogRequest & { readonly id: number }) | null>( - null, - ); - const [inputValue, setInputValue] = useState(""); - const nextPresentationId = useRef(0); - useEffect(() => { - const present = (request: DialogRequest) => { - setInputValue(request.kind === "text-input" ? request.request.initialValue : ""); - setPresented({ ...request, id: ++nextPresentationId.current }); - }; - presentRequest = present; - return () => { - // When Android replaces the activity, the new root's Host registers before the old root - // unmounts. Clearing unconditionally would leave the live Host without dialogs. - if (presentRequest === present) presentRequest = null; - }; - }, []); + const presented = useSyncExternalStore(subscribe, getPresented, getPresented); - const handleCancel = useCallback(() => { - setPresented(null); - if (presented?.kind === "alert") presented.request.options?.onDismiss?.(); - else presented?.request.onCancel?.(); - }, [presented]); + const handleCancel = () => { + if (!presented) return; + dismiss(presented.id); + if (presented.kind === "alert") presented.request.options?.onDismiss?.(); + else presented.request.onCancel?.(); + }; - const handleConfirm = useCallback( - (nativeInputValue?: string) => { - setPresented(null); - if (presented?.kind === "confirm") { - presented.request.onConfirm(); - } else if (presented?.kind === "text-input") { - presented.request.onConfirm(nativeInputValue ?? inputValue); - } - }, - [inputValue, presented], - ); + const handleConfirm = (nativeInputValue?: string) => { + if (!presented) return; + dismiss(presented.id); + if (presented.kind === "confirm") { + presented.request.onConfirm(); + } else if (presented.kind === "text-input") { + presented.request.onConfirm(nativeInputValue ?? presented.request.initialValue); + } + }; if (presented?.kind === "alert") { return ( @@ -103,7 +118,7 @@ export function Host() { alertActions={presented.request.buttons ?? [{ text: "OK" }]} dismissable={presented.request.options?.cancelable ?? false} onAlertAction={(action) => { - setPresented(null); + dismiss(presented.id); action.onPress?.(); }} onCancel={handleCancel} @@ -120,7 +135,6 @@ export function Host() { presented.kind === "text-input" ? presented.request.initialValue : undefined } inputPlaceholder={presented.kind === "text-input" ? presented.request.placeholder : undefined} - onInputChange={setInputValue} onCancel={handleCancel} onConfirm={handleConfirm} /> @@ -212,7 +226,6 @@ function DialogPresentation(props: DialogPresentationProps) { singleLine value={inputState} selection={inputSelection} - onValueChange={props.onInputChange} textStyle={inputTypography} keyboardOptions={{ imeAction: "done" }} keyboardActions={{ @@ -267,7 +280,6 @@ interface DialogPresentationProps { >; readonly inputInitialValue?: string; readonly inputPlaceholder?: string; - readonly onInputChange?: (value: string) => void; readonly onCancel: () => void; readonly onConfirm: (inputValue?: string) => void; } diff --git a/apps/mobile/src/components/ui/GlassSurface.tsx b/apps/mobile/src/components/ui/GlassSurface.tsx index 05bf0532e460e..8af080fc19cc7 100644 --- a/apps/mobile/src/components/ui/GlassSurface.tsx +++ b/apps/mobile/src/components/ui/GlassSurface.tsx @@ -25,7 +25,8 @@ interface GlassSurfaceProps extends ViewProps { readonly ref?: Ref; readonly children: ReactNode; readonly glassEffectStyle?: "clear" | "regular" | "none"; - readonly tintColor?: ColorValue; + // null leaves UIKit's material untinted instead of applying the theme tint. + readonly tintColor?: ColorValue | null; readonly tintColorClassName?: string; readonly chrome?: "default" | "none"; readonly fallbackColor?: ColorValue; @@ -71,7 +72,7 @@ export function GlassSurface({ className, )} glassEffectStyle={glassEffectStyle} - tintColor={tintColor === undefined ? undefined : String(tintColor)} + tintColor={tintColor == null ? undefined : String(tintColor)} tintColorClassName={ tintColorClassName ?? (tintColor === undefined ? "accent-glass-tint" : undefined) } 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")} > >; + /* oxlint-disable react/refs -- Cloning the trigger installs event callbacks; the refs are accessed when those callbacks run. */ children = cloneElement(child, { onTouchStart: (event) => { // onPressIn can repeat within one gesture when a finger leaves and re-enters the row. @@ -74,6 +75,7 @@ export function Menu(props: MenuProps) { child.props.onPress?.(event); }, }); + /* oxlint-enable react/refs */ menuProps.onMenuInteractionStart = () => { menuPress.current.isPreparing = true; props.onMenuInteractionStart?.(); diff --git a/apps/mobile/src/components/ui/NativeThemeHost.android.tsx b/apps/mobile/src/components/ui/NativeThemeHost.android.tsx index ad31d4c9c49b2..ad6998029fd29 100644 --- a/apps/mobile/src/components/ui/NativeThemeHost.android.tsx +++ b/apps/mobile/src/components/ui/NativeThemeHost.android.tsx @@ -1,5 +1,5 @@ import { Host } from "@expo/ui/jetpack-compose"; -import { useMemo, type ComponentProps } from "react"; +import { type ComponentProps } from "react"; import { createNativeThemeColors } from "../../lib/nativeTheme"; import { useControlTheme } from "./useControlTheme"; @@ -7,9 +7,6 @@ export function NativeThemeHost( props: Omit, "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 a7939cfa3075a..b5cd51e6c9058 100644 --- a/apps/mobile/src/components/ui/OverlayPortal.tsx +++ b/apps/mobile/src/components/ui/OverlayPortal.tsx @@ -1,9 +1,8 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState, useSyncExternalStore } from "react"; import { View } from "react-native"; // RN Modals move native window focus and close the keyboard. Keep composer menus in-window. type Entries = ReadonlyMap; -type Listener = (entries: Entries) => void; let nextKey = 0; @@ -11,24 +10,43 @@ function allocateKey() { return nextKey++; } const entries = new Map(); -const listeners = new Set(); +const listeners = new Set<() => void>(); +let snapshot: Entries = new Map(); function emit() { - const snapshot = new Map(entries); + snapshot = new Map(entries); for (const listener of listeners) { - listener(snapshot); + listener(); } } +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot() { + return snapshot; +} + export function OverlayPortal(props: { readonly children: ReactNode }) { const [key] = useState(allocateKey); - // Re-project every render so open menus reflect content and theme changes. + // RN has no createPortal, so projecting into the out-of-tree host has to + // 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(); }); + // 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); @@ -41,14 +59,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { } export function OverlayPortalHost() { - const [current, setCurrent] = useState(() => new Map()); - - useEffect(() => { - listeners.add(setCurrent); - return () => { - listeners.delete(setCurrent); - }; - }, []); + const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); if (current.size === 0) { return null; 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/SliderRow.android.tsx b/apps/mobile/src/components/ui/SliderRow.android.tsx index ad774efa21261..d9ab444f2cbf1 100644 --- a/apps/mobile/src/components/ui/SliderRow.android.tsx +++ b/apps/mobile/src/components/ui/SliderRow.android.tsx @@ -1,7 +1,7 @@ import { Slider } from "@expo/ui/jetpack-compose"; import { fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import * as Haptics from "expo-haptics"; -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { View } from "react-native"; import { AppText as Text } from "./AppText"; @@ -12,11 +12,8 @@ import type { SliderRowProps } from "./SliderRow.types"; export function SliderRow(props: SliderRowProps) { const { colors } = useControlTheme(); - const draft = useRef(props.value); - const dragging = useRef(false); - useEffect(() => { - if (!dragging.current) draft.current = props.value; - }, [props.value]); + // The value of the drag in progress; null between drags. + const draft = useRef(null); const commit = (value: number) => { if (props.disabled) return; const next = Math.min( @@ -66,12 +63,12 @@ export function SliderRow(props: SliderRowProps) { inactiveTrackColor: colors["--color-secondary"], }} onValueChange={(value) => { - dragging.current = true; draft.current = value; }} onValueChangeFinished={() => { - dragging.current = false; - commit(draft.current); + const value = draft.current; + draft.current = null; + if (value !== null) commit(value); }} /> diff --git a/apps/mobile/src/components/ui/SliderRow.tsx b/apps/mobile/src/components/ui/SliderRow.tsx index fcb837129da9f..4e993363df06a 100644 --- a/apps/mobile/src/components/ui/SliderRow.tsx +++ b/apps/mobile/src/components/ui/SliderRow.tsx @@ -6,7 +6,7 @@ import { tint, } from "@expo/ui/swift-ui/modifiers"; import * as Haptics from "expo-haptics"; -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { View } from "react-native"; import { AppText } from "./AppText"; @@ -19,11 +19,9 @@ export type { SliderRowProps } from "./SliderRow.types"; export function SliderRow(props: SliderRowProps) { const { colors } = useControlTheme(); - const draft = useRef(props.value); + // The value of the drag in progress; null until the drag moves. + const draft = useRef(null); const editing = useRef(false); - useEffect(() => { - if (!editing.current) draft.current = props.value; - }, [props.value]); const commit = (value: number) => { if (props.disabled) return; const next = Math.min( @@ -60,13 +58,15 @@ export function SliderRow(props: SliderRowProps) { accessibilityValue(props.valueLabel), ]} onValueChange={(value) => { - draft.current = value; // Accessibility adjustments do not begin a touch editing session. - if (!editing.current) commit(value); + if (editing.current) draft.current = value; + else commit(value); }} onEditingChanged={(isEditing) => { editing.current = isEditing; - if (!isEditing) commit(draft.current); + const value = draft.current; + draft.current = null; + if (!isEditing && value !== null) commit(value); }} /> diff --git a/apps/mobile/src/components/ui/Toast.tsx b/apps/mobile/src/components/ui/Toast.tsx index 2daa0873b65f4..2483ac9fa1285 100644 --- a/apps/mobile/src/components/ui/Toast.tsx +++ b/apps/mobile/src/components/ui/Toast.tsx @@ -1,36 +1,72 @@ -import { MOBILE_SHAPE } from "../../lib/designTokens"; -import { useEffect } from "react"; -import { AccessibilityInfo, Platform, View, useWindowDimensions } from "react-native"; +import { MOBILE_MOTION, MOBILE_SHAPE } from "../../lib/designTokens"; +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, { + cancelAnimation, + Easing, + FadeOut, + ReduceMotion, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, + type EntryAnimationsValues, +} from "react-native-reanimated"; import { AppText } from "./AppText"; import { Button } from "./Button"; import { GlassSurface } from "./GlassSurface"; +import { useControlTheme } from "./useControlTheme"; import { IconButton } from "./IconButton"; import { AppActivityIndicator } from "./AppActivityIndicator"; import { SymbolView } from "./AppSymbol"; -export interface ToastProps { +export type ToastProps = { readonly message: string; readonly description?: string; + readonly singleLine?: boolean; readonly tone?: "default" | "success" | "danger"; readonly busy?: boolean; readonly action?: { readonly label: string; readonly onPress: () => void }; - readonly onDismiss?: () => void; -} +} & ( + | { readonly dismissMode: "swipe"; readonly onDismiss: () => void } + | { readonly dismissMode?: "button"; readonly onDismiss?: () => void } +); export function Toast(props: ToastProps) { + return props.dismissMode === "swipe" ? ( + + ) : ( + + ); +} + +function ToastContent(props: ToastProps & { readonly glassReady?: boolean }) { const { fontScale } = useWindowDimensions(); - // A one-line message keeps its action on the same row. Descriptions and - // accessibility text sizes need the full width, so the action moves below. - const inlineAction = !props.description && fontScale < 1.5; + const { colorScheme } = useControlTheme(); + const borderRadius = Platform.OS === "ios" ? 31 : MOBILE_SHAPE.toast; + // Explicit single-line notices reserve the action's width and truncate the text. + const inlineAction = props.singleLine || (!props.description && fontScale < 1.5); + const message = + props.singleLine && props.description + ? `${props.message}: ${props.description}` + : props.message; 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 9a57cbc96cbf2..1edb92f0d556b 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -9,13 +9,9 @@ import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { - threadSearchMatchKey, - type EnvironmentThreadSearchMatch, -} from "@t3tools/client-runtime/state/thread-search"; +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, @@ -54,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"; @@ -319,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({ @@ -336,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); @@ -426,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, @@ -448,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, @@ -476,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); - // 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; @@ -505,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]); @@ -594,9 +574,6 @@ export function HomeScreen(props: HomeScreenProps) { timeLabel={item.timeLabel} showTrailingDivider={item.showTrailingDivider} project={projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null} - projectTitle={v2ProjectTitleByProjectKey.get( - scopedProjectKey(thread.environmentId, thread.projectId), - )} providerInstance={resolveProviderInstance(thread)} providers={providersByEnvironmentId.get(thread.environmentId)} environmentLabel={ @@ -626,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={ @@ -731,6 +709,7 @@ export function HomeScreen(props: HomeScreenProps) { {listHeader} {Platform.OS === "ios" && workspace ? ( ({ key: scope.key, label: scope.title }))} selectedEnvironmentId={props.selectedEnvironmentId} diff --git a/apps/mobile/src/features/home/ThreadListFilters.tsx b/apps/mobile/src/features/home/ThreadListFilters.tsx index 93f9e7f68cd2a..a7d6894c35d91 100644 --- a/apps/mobile/src/features/home/ThreadListFilters.tsx +++ b/apps/mobile/src/features/home/ThreadListFilters.tsx @@ -12,7 +12,10 @@ export function ThreadListFilters( | "selectedProjectKey" | "onEnvironmentChange" | "onProjectChange" - >, + > & { + /** List padding the scroller bleeds through so pills start where row content does. */ + readonly gutter: number; + }, ) { const selectedEnvironment = props.environments.find( (environment) => environment.environmentId === props.selectedEnvironmentId, @@ -24,7 +27,8 @@ export function ThreadListFilters( (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.android.tsx b/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx deleted file mode 100644 index d1caf4e664b78..0000000000000 --- a/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useLayoutEffect, useState } from "react"; -import { StyleSheet, View } from "react-native"; -import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; -import Animated, { - cancelAnimation, - Easing, - FadeOut, - ReduceMotion, - runOnJS, - useAnimatedStyle, - useSharedValue, - withTiming, - type EntryAnimationsValues, -} from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Toast } from "../../components/ui/Toast"; -import { MOBILE_MOTION } from "../../lib/designTokens"; -import { - dismissThreadUndoNotice, - readThreadUndoNotice, - useThreadUndoNotice, -} from "./thread-undo-notice"; - -const SWIPE_TIMING = { duration: 160, reduceMotion: ReduceMotion.System }; -const ENTER_TIMING = { - duration: MOBILE_MOTION.enter, - easing: Easing.out(Easing.cubic), - reduceMotion: ReduceMotion.System, -}; -const NOTICE_EXITING = FadeOut.duration(MOBILE_MOTION.exit).reduceMotion(ReduceMotion.System); -const noticeEntering = (values: EntryAnimationsValues) => { - "worklet"; - return { - initialValues: { originY: values.targetOriginY + values.targetHeight + 12 }, - animations: { originY: withTiming(values.targetOriginY, ENTER_TIMING) }, - }; -}; - -export function ThreadUndoNotice(props: { readonly visible: boolean }) { - const notice = useThreadUndoNotice(); - // Replacing a notice must also cancel the native gesture tracking the old one. - const [instance, setInstance] = useState({ notice, key: 0 }); - if (instance.notice !== notice) setInstance({ notice, key: instance.key + 1 }); - if (!props.visible || notice === null) return null; - return ; -} - -function SwipeableUndoNotice(props: { - readonly notice: NonNullable>; -}) { - const { notice } = props; - const insets = useSafeAreaInsets(); - const translation = useSharedValue(0); - const width = useSharedValue(0); - - useLayoutEffect( - () => () => { - cancelAnimation(translation); - }, - [translation], - ); - - const dismiss = () => { - // A newer action can replace the notice while the swipe is animating out. - if (readThreadUndoNotice() === notice) dismissThreadUndoNotice(); - }; - const gesture = usePanGesture({ - activeOffsetX: [-10, 10], - failOffsetY: [-12, 12], - onUpdate: (event) => { - translation.set(event.translationX); - }, - onDeactivate: (event) => { - if ( - !event.canceled && - (Math.abs(event.translationX) > width.value * 0.25 || - (Math.abs(event.velocityX) > 800 && event.translationX * event.velocityX > 0)) - ) { - const direction = Math.sign(event.translationX || event.velocityX); - translation.set( - withTiming(direction * (width.value + 24), SWIPE_TIMING, (finished) => { - if (finished) runOnJS(dismiss)(); - }), - ); - } else { - translation.set(withTiming(0, SWIPE_TIMING)); - } - }, - }); - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translation.value }], - })); - - // Clip the entrance above navigation; the notice intentionally covers the anchored FAB. - return ( - { - width.set(event.nativeEvent.layout.width); - }} - > - - - {/* Keep the dismiss target separate so TalkBack can also focus Undo. */} - { - if (nativeEvent.actionName === "dismiss") dismiss(); - }} - pointerEvents="none" - style={StyleSheet.absoluteFill} - /> - void notice.undo() }} - /> - - - - ); -} diff --git a/apps/mobile/src/features/home/ThreadUndoNotice.tsx b/apps/mobile/src/features/home/ThreadUndoNotice.tsx index 578dc356e6edb..364873a3eadeb 100644 --- a/apps/mobile/src/features/home/ThreadUndoNotice.tsx +++ b/apps/mobile/src/features/home/ThreadUndoNotice.tsx @@ -1,17 +1,80 @@ -import { View } from "react-native"; +import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; +import { useState } from "react"; +import { useKeyboardState } from "react-native-keyboard-controller"; +import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Toast } from "../../components/ui/Toast"; -import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics"; -import { dismissThreadUndoNotice, useThreadUndoNotice } from "./thread-undo-notice"; +import { + useDeviceType, + useDuoBottomBarInset, + useNativeLayoutMetrics, + useNativeColumnLayoutMetrics, +} from "../../native/native-layout-metrics"; +import { workspaceSearchBottomClearance } from "../workspace/workspace-tabs-state"; +import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { + dismissThreadUndoNotice, + readThreadUndoNotice, + useThreadUndoNotice, +} from "./thread-undo-notice"; export function ThreadUndoNotice(props: { readonly visible: boolean }) { + const floatingTabsEnabled = useAndroidFloatingTabs(); const notice = useThreadUndoNotice(); const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const rootMetrics = useNativeLayoutMetrics(); + const workspace = useWorkspaceTabs(); + const deviceType = useDeviceType(); + const keyboardHeight = useKeyboardState((keyboard) => keyboard.height); + const horizontalInsets = + Platform.OS === "ios" + ? deviceType === "phone" + ? { left: 29, right: 23 } + : { left: 20, right: 20 } + : { left: 12, right: 12 }; + // The Duo's native tab pill extends above the 48-point toolbar reservation. + const workspacePadding = + useDuoBottomBarInset() === undefined + ? deviceType === "phone" && keyboardHeight === 0 + ? 32 + : 16 + : 8; + // Replacing a notice must also cancel the native gesture tracking the old one. + const [instance, setInstance] = useState({ notice, key: 0 }); + if (instance.notice !== notice) setInstance({ notice, key: instance.key + 1 }); if (!props.visible || notice === null) return null; + const foldedResting = + rootMetrics?.hasHinge && + rootMetrics.horizontalSizeClass === "compact" && + workspace?.state.tabs.threads.searchMode === "resting"; + const bottom = + Platform.OS === "ios" + ? foldedResting + ? Math.max(rootMetrics.safeArea.bottom, keyboardHeight) + 8 + : rootMetrics?.hasHinge && rootMetrics.verticalBarEdge !== "none" + ? // The column's safe area already includes UIKit's search toolbar in this posture. + workspaceSearchBottomClearance({ + bottomInset: rootMetrics.safeArea.bottom, + bottomBarInset: rootMetrics.bottomBarInset, + keyboardHeight, + }) - + 16 + + 8 + : Math.max( + insets.bottom, + columnMetrics?.safeArea.bottom ?? 0, + (workspace?.bottomClearance ?? 0) - workspacePadding, + ) + 8 + : floatingTabsEnabled && keyboardHeight === 0 + ? Math.max(insets.bottom + 12, workspace?.bottomClearance ?? 0) + : insets.bottom + 12; return ( { + // A newer action can replace the notice while the swipe is animating out. + if (readThreadUndoNotice() === notice) dismissThreadUndoNotice(); + }} /> ); diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index b00fac55e18c3..58cf4138483a2 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,5 +1,5 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; -import { createContext, use, useEffect, useMemo, useState, type ReactNode } from "react"; +import { createContext, use, useEffect, useState, type ReactNode } from "react"; import { Animated, Platform, Pressable, View } from "react-native"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; @@ -11,6 +11,7 @@ import { CompactBrandTitle, getCompactBrandHeaderOptions, } from "../../components/CompactBrandTitle"; +import { useDeadlinePassed } from "../../lib/clock"; import { useWorkspaceState } from "../../state/workspace"; import { workspaceConnectionStatusPresentation, @@ -37,29 +38,24 @@ export function WorkspaceConnectionStatusProvider(props: { readonly children: Re const { state } = useWorkspaceState(); const presentation = workspaceConnectionStatusPresentation(state); const hasStatus = presentation !== null; - const [phase, setPhase] = useState<"waiting" | "fading" | "shown">("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 e75b29a8805ac..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 @@ -506,7 +511,11 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { rightThreshold={actionsWidth * 0.42} simultaneousWith={props.simultaneousWith} > - {props.children(close)} + { + /* oxlint-disable react/refs -- The render callback receives a close/open handler; only invoking that handler accesses the ref. */ + props.children(close) + /* oxlint-enable react/refs */ + } @@ -707,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 615b931acbfa0..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( @@ -504,6 +541,10 @@ export function CommandPalette(props: { results.findIndex((item) => item.key === selection), ); const selectedKey = results[selectedIndex]?.key; + // 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. @@ -514,26 +555,31 @@ export function CommandPalette(props: { }, [selectedIndex, selectedKey]); const dismissed = useRef(false); - const handleDismissed = useCallback(() => { + const dismissFallback = useRef | null>(null); + const handleDismissed = () => { + if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); + dismissFallback.current = null; if (dismissed.current) return; dismissed.current = true; // Present navigation sheets only after UIKit has dismissed this modal. props.onClose(); pendingAction.current?.(); - }, [props]); - - // iOS may drop onDismiss during presentation or competing sheets; a fallback clears stale palette state. - useEffect(() => { - if (visible) return; - const fallback = setTimeout(handleDismissed, 400); - return () => clearTimeout(fallback); - }, [visible, handleDismissed]); + }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the dismiss fallback timer on unmount + useEffect( + () => () => { + if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); + }, + [], + ); function close(run?: () => void) { if (closing.current) return; closing.current = true; pendingAction.current = run ?? null; setVisible(false); + // iOS may drop onDismiss during presentation or competing sheets; a fallback clears stale palette state. + dismissFallback.current = setTimeout(handleDismissed, 400); } function onCommand(command: HardwareKeyboardCommand) { @@ -603,6 +649,8 @@ export function CommandPalette(props: { onChangeText={(value) => { setQuery(value); setSelection(null); + // The follow effect stays idle when the top row and its key are unchanged, + // so a touch-scrolled list would otherwise keep its old offset. listRef.current?.scrollToOffset({ offset: 0, animated: false }); }} returnKeyType="go" diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index eeb06c4e2e303..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) { @@ -119,6 +121,7 @@ export function HardwareKeyboardCommandProvider({ if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference"); } return [...commands]; + // oxlint-disable-next-line react/memo-dependencies -- registrationVersion invalidates the snapshot of the external keyboard-command registry. }, [activeThreadRef, pathname, registrationVersion, navigation]); const onCommand = useCallback( @@ -132,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 872f1f521f79a..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,38 +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(); - } - }, [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), ); } @@ -163,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); @@ -189,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; @@ -271,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 @@ -282,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; @@ -324,6 +327,7 @@ function AdaptiveWorkspaceLayoutContent( deriveWorkspacePaneLayout({ layout, viewportWidth: width, + nativeWorkspace, primarySidebarPreferredVisible: showPrimarySidebar, auxiliaryPanePreferredVisible, auxiliaryPaneRole, @@ -334,6 +338,7 @@ function AdaptiveWorkspaceLayoutContent( auxiliaryPaneRole, auxiliaryPanePreferredWidth, layout, + nativeWorkspace, showPrimarySidebar, width, ], @@ -341,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; @@ -475,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, }, @@ -488,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); @@ -521,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, + }), ); }; @@ -583,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, @@ -676,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)); @@ -720,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, })); @@ -743,13 +792,30 @@ function AdaptiveWorkspaceLayoutContent( }); }; + // Rotating flips the overlay; until the new size reaches JS, predict the reset above. + const sidebarOverRotates = + nativeWorkspace && nativeMetrics?.hasHinge === true && layout.usesSplitView; + // The current orientation's panes already account for an inspector that needs the sidebar's room. + const sidebarInLandscape = baseLayout.primarySidebarOverlaysContent + ? primarySidebarPinned || primarySidebarPreferredVisible + : panes.primarySidebarVisible; + const sidebarInPortrait = + primarySidebarPinned || + (baseLayout.primarySidebarOverlaysContent + ? overlaySidebar.visible + : isWorkspaceListPath(pathname)); + const sidebarByOrientation = useMemo( + () => + sidebarOverRotates ? { landscape: sidebarInLandscape, portrait: sidebarInPortrait } : null, + [sidebarInLandscape, sidebarInPortrait, sidebarOverRotates], + ); + const contextValue = { layout, panes, fileInspector, primarySidebarSearchQuery, startNewTask: handleStartNewTask, - deferThreadSelectionUntil: deferredThreadSelection.waitFor, selectDetail: handleSelectDetail, activateAuxiliaryPaneRole, registerWorkspaceInspector, @@ -762,6 +828,7 @@ function AdaptiveWorkspaceLayoutContent( setAuxiliaryPaneWidth, composerKeyboardActive, setComposerEditing, + sidebarByOrientation, }; if (nativeWorkspace) { @@ -780,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 4f5f2033e029a..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; @@ -41,6 +40,11 @@ interface AdaptiveWorkspaceContextValue { * sidebar and rail step aside and the thread header drops to one line. */ readonly composerKeyboardActive: boolean; + /** + * Whether the sidebar shows in each orientation, where it floats over the content in portrait + * (the unfolded Duo). The native split switches between them as a rotation starts. + */ + readonly sidebarByOrientation: { readonly landscape: boolean; readonly portrait: boolean } | null; /** The thread composer reports its focus so the layout can make room for typing. */ readonly setComposerEditing: (editing: boolean) => void; } @@ -62,7 +66,6 @@ export const AdaptiveWorkspaceContext = createContext undefined, - deferThreadSelectionUntil: () => undefined, activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, @@ -73,6 +76,7 @@ export const AdaptiveWorkspaceContext = createContext undefined, setAuxiliaryPaneWidth: () => undefined, composerKeyboardActive: false, + sidebarByOrientation: null, setComposerEditing: () => 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 5a42b61d2594c..e2697f4e4d238 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -19,18 +19,11 @@ import { type NativeStackNavigatorProps, type NativeStackTypeBag, } from "@react-navigation/native-stack"; -import { - Fragment, - use, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ComponentProps, -} from "react"; -import { View } from "react-native"; +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"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { WorkspaceTabsProvider, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; @@ -42,6 +35,7 @@ import { setNativelyPoppingWorkspaceRoute } from "../workspace/workspace-detail- import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +import { WorkspacePaneDivider } from "./workspace-pane-divider"; import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios"; import { @@ -103,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 ( layout.usesSplitView && detail[0] && isSettingsRoute(detail[0].name) ? detail[0] : undefined; // Split screens must remain mounted while UIKit animates a detached route away. @@ -259,21 +259,18 @@ function WorkspaceColumns( ThreadId.make(threadParams.threadId), ) : null; - const handleNativeDismiss = useCallback( - (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); - } - }, - [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, @@ -290,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 }; @@ -302,7 +300,13 @@ function WorkspaceColumns( { @@ -326,14 +330,17 @@ function WorkspaceColumns( openWorkspaceTab(tab ?? "threads", { columns: true }); }} preferredDisplayMode={ - // UIKit applies the mode as a collapsed split expands, before the new width reaches JS, so a - // collapsed split already carries the sidebar the expanded one will show. - (layout.usesSplitView ? panes.primarySidebarVisible : panes.primarySidebarWhenExpanded) || - (!layout.usesSplitView && !activeDetailKey) - ? layout.primarySidebarOverlaysContent - ? "oneOverSecondary" - : "oneBesideSecondary" - : "secondaryOnly" + sidebarByOrientation + ? sidebarDisplayMode(sidebarByOrientation.landscape, false) + : sidebarDisplayMode( + // UIKit applies the mode as a collapsed split expands, before the new width reaches + // JS, so a collapsed split already carries the sidebar the expanded one will show. + (layout.usesSplitView + ? panes.primarySidebarVisible + : panes.primarySidebarWhenExpanded) || + (!layout.usesSplitView && !activeDetailKey), + layout.primarySidebarOverlaysContent, + ) } onDisplayModeWillChange={({ nativeEvent }) => { if (!layout.primarySidebarOverlaysContent) return; @@ -351,6 +358,10 @@ function WorkspaceColumns( maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0), preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, minimumSecondaryColumnWidth: 320, + // UIKit can retain its automatic width despite a new preference; constrain both edges. + minimumInspectorColumnWidth: inspectorWidth, + maximumInspectorColumnWidth: inspectorWidth, + preferredInspectorColumnWidthOrFraction: inspectorWidth, }} > @@ -423,7 +434,45 @@ function WorkspaceColumns( {/* Inspector panes host their own native stacks, which UIKit insets for the Duo's vertical bar wherever they sit, so each pane insets its own content instead. */} - {inspector.render?.()} + + {inspector.render?.()} + {inspector.visible ? ( + + + + + { + resizeStartWidth.current = inspectorWidth; + }} + onResizeBy={(delta) => { + const width = Math.max( + 260, + Math.min(viewportWidth * 0.6, resizeStartWidth.current + delta), + ); + setInspectorWidthFraction(width / viewportWidth); + }} + /> + + + + + ) : null} + @@ -441,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 ; @@ -604,3 +661,8 @@ export function createWorkspaceStackNavigator< >(config?: Config): TypedNavigator { return createNavigatorFactory(WorkspaceStackNavigator)(config); } + +function sidebarDisplayMode(visible: boolean, overlay: boolean) { + if (!visible) return "secondaryOnly"; + return overlay ? "oneOverSecondary" : "oneBesideSecondary"; +} 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 9a44aadc05251..4c003008d1d62 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import Animated, { + cubicBezier, runOnJS, useAnimatedStyle, + useReducedMotion, useSharedValue, withTiming, type SharedValue, @@ -12,6 +14,9 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspacePaneDivider } from "./workspace-pane-divider"; +// CSS form of WORKSPACE_PANE_TIMING's Easing.inOut(Easing.cubic). +const PANE_EASING = cubicBezier(0.65, 0, 0.35, 1); + /** Pass layout props to avoid an import cycle with AdaptiveWorkspaceLayout. */ export function WorkspaceInspectorPane(props: { readonly pathname: string; @@ -32,18 +37,17 @@ export function WorkspaceInspectorPane(props: { const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0); const { renderedInspectorWidth } = props; - const renderedContentWidth = useSharedValue(inspectorWidth ?? 0); + 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.value = withTiming( - inspectorVisible ? 1 : 0, - WORKSPACE_PANE_TIMING, - (finished) => { + inspectorProgress.set( + withTiming(inspectorVisible ? 1 : 0, WORKSPACE_PANE_TIMING, (finished) => { if (finished === true && !inspectorVisible && onClosed !== undefined) { runOnJS(onClosed)(); } - }, + }), ); const targetWidth = inspectorVisible ? (inspectorWidth ?? 0) : 0; renderedInspectorWidth.set( @@ -58,15 +62,6 @@ export function WorkspaceInspectorPane(props: { resizing, ]); - useEffect(() => { - const targetWidth = inspectorWidth ?? 0; - if (!inspectorVisible || resizing) { - renderedContentWidth.value = targetWidth; - return; - } - renderedContentWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); - }, [inspectorVisible, inspectorWidth, renderedContentWidth, resizing]); - const inspectorStyle = useAnimatedStyle( () => ({ opacity: inspectorProgress.value, @@ -75,7 +70,14 @@ export function WorkspaceInspectorPane(props: { }), [], ); - const inspectorContentStyle = useAnimatedStyle(() => ({ width: renderedContentWidth.value }), []); + // A hidden or resizing pane takes its width at once; a visible one eases with the column. + const inspectorContentStyle = { + width: inspectorWidth ?? 0, + transitionProperty: "width", + transitionDuration: + !inspectorVisible || resizing || reduceMotion ? 0 : WORKSPACE_PANE_TIMING.duration, + transitionTimingFunction: PANE_EASING, + } as const; const beginResize = () => { resizeStartWidth.current = inspectorWidth ?? 0; setResizing(true); diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx index 8b2c75089d1b1..5fa62279b1410 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -56,7 +56,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { return ( 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/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts index 3ca4bf9a3d61c..a0fa9e85f6de9 100644 --- a/apps/mobile/src/features/observability/tracing.test.ts +++ b/apps/mobile/src/features/observability/tracing.test.ts @@ -27,6 +27,7 @@ it.effect("does not let OTLP serialization failures alter application effects", }, { appVariant: "test", + serviceInstanceId: "test-instance", serviceVersion: "1.2.3", }, ).pipe(Layer.provide(layerRemoteHttpClient(fetchFn))); diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts index e2b8ee0f16883..fbdb14fc78509 100644 --- a/apps/mobile/src/features/observability/tracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -1,4 +1,5 @@ import Constants from "expo-constants"; +import { type ReleaseChannel, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import * as RelayTracing from "@t3tools/shared/relayTracing"; import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; @@ -11,9 +12,16 @@ export interface TracingConfig { export interface TracingResource { readonly serviceVersion?: string; + readonly serviceInstanceId: string; readonly appVariant: string; } +const RELEASE_CHANNEL_BY_VARIANT: Readonly> = { + development: "dev", + preview: "preview", + production: "stable", +}; + export function resolveTracingConfig(): TracingConfig | null { const config = resolveCloudPublicConfig(); if (!hasTracingPublicConfig(config)) { @@ -24,16 +32,23 @@ export function resolveTracingConfig(): TracingConfig | null { } export function layerFromConfig(config: TracingConfig | null, resource: TracingResource) { + const channel = + typeof __DEV__ !== "undefined" && __DEV__ + ? "dev" + : RELEASE_CHANNEL_BY_VARIANT[resource.appVariant]; return RelayTracing.layer(config, { serviceName: "t3code-mobile", serviceVersion: resource.serviceVersion, runtime: "react-native", + serviceInstanceId: resource.serviceInstanceId, client: `mobile-${resource.appVariant}`, + attributes: channel && releaseResourceAttributes(channel), }); } export const layer = layerFromConfig(resolveTracingConfig(), { serviceVersion: Constants.expoConfig?.version, + serviceInstanceId: Constants.sessionId, appVariant: typeof Constants.expoConfig?.extra?.appVariant === "string" ? Constants.expoConfig.extra.appVariant diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index fe2cb546a3ca2..b251310ef12f9 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -2,25 +2,59 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeModule from "node:module"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { workspaceDetailRouter } from "../workspace/workspace-detail-selection"; +import type { NavigationState } from "@react-navigation/native"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { isValidElement, type ReactNode } from "react"; +import { act, createElement, isValidElement, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ scopes: new Set(), baseDirectory: "", projects: [] as string[], + existingProjects: [] as { + environmentId: string; + id: string; + title: string; + workspaceRoot: string; + }[], + createResult: null as Promise | null, + createNewResult: null as Promise | null, + startCloneResult: null as Promise | null, + connectionStates: {} as Record, + newProjectsRoot: null as string | null, + waitForProject: vi.fn(), + dispatch: vi.fn(), + alert: vi.fn(), + navigationState: undefined as NavigationState | undefined, + environments: [{ environmentId: "environment", environmentLabel: "Environment" }], })); -vi.mock("react", async (importOriginal) => ({ - ...(await importOriginal()), - useCallback: (callback: unknown) => callback, - useMemo: (factory: () => unknown) => factory(), - useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], - useRef: (current: unknown) => ({ current }), - useEffect: () => {}, +function loadRouters() { + const requireNavigation = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), + ); + return requireNavigation("@react-navigation/routers") as { + StackRouter: typeof import("@react-navigation/native").StackRouter; + StackActions: typeof import("@react-navigation/native").StackActions; + CommonActions: typeof import("@react-navigation/native").CommonActions; + }; +} + +vi.mock("../workspace/useSelectWorkspaceDetail", async () => { + const { selectWorkspaceDetail } = await import("../workspace/workspace-detail-selection"); + return { + useSelectWorkspaceDetail: () => (detail: Parameters[0]) => + state.dispatch(selectWorkspaceDetail(detail)), + }; +}); +vi.mock("../../native/StackHeader", () => ({ + nativeHeaderScrollEdgeEffects: () => undefined, })); vi.mock("../../components/ui/NavigationRow", () => ({ NavigationRow: "NavigationRow" })); vi.mock("../../components/ui/Button", () => ({ Button: "Button" })); @@ -30,15 +64,30 @@ vi.mock("../../components/ui/ThemedSwitch", () => ({ ThemedSwitch: "Switch" })); vi.mock("react-native", () => ({ Platform: { OS: "ios" }, ActivityIndicator: "ActivityIndicator", - Alert: { alert: () => {} }, + Alert: { alert: state.alert }, Pressable: "Pressable", ScrollView: "ScrollView", View: "View", })); vi.mock("@react-navigation/native", () => ({ - useNavigation: () => ({ dispatch: () => {} }), + useNavigation: () => ({ + dispatch: state.dispatch, + getState: () => + state.navigationState ?? { + index: 4, + routes: [ + { key: "home", name: "Home" }, + { key: "draft", name: "Thread", params: { draft: {} } }, + { key: "picker", name: "NewTaskProject" }, + { key: "add", name: "AddProject" }, + { key: "local", name: "AddProjectLocal" }, + ], + }, + }), CommonActions: { reset: (input: unknown) => input }, - StackActions: {}, + StackActions: { + push: (name: string, params: unknown) => ({ type: "PUSH", payload: { name, params } }), + }, })); vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }), @@ -56,13 +105,15 @@ vi.mock("../../state/session", () => ({ readEnvironmentScope: (_environmentId: string, scope: string) => state.scopes.has(scope), })); vi.mock("../../state/entities", () => ({ - useProjects: () => [], + useProjects: () => state.existingProjects, + waitForProject: state.waitForProject, useServerConfigs: () => new Map([ [ "environment", { environment: { platform: { os: "linux" }, capabilities: {} }, + newProjectsRoot: state.newProjectsRoot, settings: { addProjectBaseDirectory: state.baseDirectory }, }, ], @@ -71,12 +122,15 @@ vi.mock("../../state/entities", () => ({ vi.mock("../../state/use-remote-environment-registry", () => ({ useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }), useRemoteConnectionStatus: () => ({ - connectedEnvironments: [{ environmentId: "environment", connectionState: "connected" }], + connectedEnvironments: state.environments.map((environment) => ({ + environmentId: environment.environmentId, + connectionState: state.connectionStates[environment.environmentId] ?? "connected", + })), }), useSavedRemoteConnections: () => ({ - savedConnectionsById: { - connection: { environmentId: "environment", environmentLabel: "Environment" }, - }, + savedConnectionsById: Object.fromEntries( + state.environments.map((environment) => [environment.environmentId, environment]), + ), }), })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); @@ -88,49 +142,100 @@ vi.mock("../../state/presentation", () => ({ vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { - cloneRepository: async ({ input }: { input: { destinationPath: string } }) => { + discovery: (input: unknown) => input, + 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); }, }, })); vi.mock("../../state/projects", () => ({ projectEnvironment: { - create: async ({ input }: { input: { workspaceRoot: string } }) => { + createNew: () => state.createNewResult, + create: ({ input }: { input: { workspaceRoot: string } }) => { if (!state.scopes.has(AuthOrchestrationOperateScope)) { - return AsyncResult.failure(Cause.fail(new Error("Project creation denied"))); + return Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("Project creation denied"))), + ); } state.projects.push(input.workspaceRoot); - return AsyncResult.success(undefined); + return state.createResult ?? Promise.resolve(AsyncResult.success(undefined)); }, }, })); -import { AddProjectDestinationScreen } from "./AddProjectScreen"; +vi.mock("../../state/use-composer-drafts", () => ({ + waitForComposerDraftsLoaded: async () => {}, + createNewTaskDraft: () => "prompt-draft", + setComposerDraftText: () => {}, +})); + +import { + AddProjectNewScreen, + AddProjectDestinationScreen, + AddProjectLocalFolderScreen, + AddProjectSourceScreen, +} from "./AddProjectScreen"; +import { AddProjectSourceRoute } from "./AddProjectSourceRoute"; -function findAction(node: ReactNode, label: string): (() => unknown) | null { +function findAction( + node: ReactNode, + label: string, + includeDisabled = false, +): (() => unknown) | null { if (Array.isArray(node)) { for (const child of node) { - const action = findAction(child, label); + const action = findAction(child, label, includeDisabled); if (action) return action; } return null; } - if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) { + if ( + !isValidElement<{ + label?: string; + title?: string; + onPress?: () => unknown; + disabled?: boolean; + children?: ReactNode; + }>(node) + ) { return null; } - if (node.props.label === label) return node.props.onPress ?? null; - return findAction(node.props.children, label); + if ((node.props.label ?? node.props.title) === label) { + return node.props.disabled && !includeDisabled ? null : (node.props.onPress ?? null); + } + return findAction(node.props.children, label, includeDisabled); +} + +const mountedRoots: ReturnType[] = []; +afterEach(() => { + for (const root of mountedRoots.splice(0)) act(() => root.unmount()); +}); +function renderScreen(render: () => ReactNode) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + mountedRoots.push(root); + let screen: ReactNode; + function Observer() { + screen = render(); + return null; + } + act(() => root.render(createElement(Observer))); + return screen; } function cloneAction() { const action = findAction( - AddProjectDestinationScreen({ - environmentId: "environment", - remoteUrl: "https://example.com/repo.git", - repositoryName: "repo", - }), + renderScreen(() => + AddProjectDestinationScreen({ + environmentId: "environment", + remoteUrl: "https://example.com/repo.git", + repositoryName: "repo", + }), + ), "Clone project", + true, ); if (!action) throw new Error("Clone action missing"); return action; @@ -143,6 +248,7 @@ describe("clone project permissions", () => { ); state.scopes = new Set([AuthSourceControlWriteScope]); state.projects = []; + state.createResult = null; }); afterEach(async () => { @@ -169,3 +275,484 @@ describe("clone project permissions", () => { }, ); }); + +function mountProjectFlow( + withDraft: boolean, + withChooser = true, + withPullRequest = false, + screenName = "AddProjectLocal", +) { + const { StackRouter, StackActions, CommonActions } = loadRouters(); + const base = StackRouter({}); + const router = { ...base, ...workspaceDetailRouter(base) }; + const options = { + routeNames: [ + "Home", + "Thread", + "PullRequest", + "PullRequestFile", + "NewTaskProject", + "AddProject", + "AddProjectLocal", + "AddProjectNew", + "AddProjectDestination", + ], + routeParamList: {}, + routeGetIdList: {}, + }; + let current = router.getInitialState(options); + const home = current.routes[0]!; + const apply = (action: Parameters[1]) => { + const next = router.getStateForAction(current, action, options); + if (!next || next.stale !== false) throw new Error("Navigation action rejected"); + current = next; + state.navigationState = current; + }; + if (withPullRequest) { + apply(StackActions.push("PullRequest", { number: 123 })); + apply(StackActions.push("PullRequestFile", { path: "file.ts" })); + } + const underlyingRoutes = current.routes; + if (withDraft) { + apply(StackActions.push("Thread", { draft: { projectId: "previous-project" } })); + } + const draftKey = withDraft ? current.routes[current.index]!.key : null; + for (const name of [...(withChooser ? ["NewTaskProject"] : []), "AddProject", screenName]) { + apply(StackActions.push(name)); + } + state.navigationState = current; + state.dispatch.mockImplementation((action) => + apply( + action.type === "PUSH" || action.type === "SELECT_WORKSPACE_DETAIL" + ? action + : CommonActions.reset(action), + ), + ); + return { + home, + draftKey, + underlyingRoutes, + flowRoutes: current.routes.slice(underlyingRoutes.length), + getState: () => current, + back: () => apply(StackActions.pop()), + setParams: (params: object) => apply(CommonActions.setParams(params)), + push: (name: string) => apply(StackActions.push(name)), + }; +} + +describe("add project source environment", () => { + it("blocks local add on a requested unavailable host until reconnect or manual choice", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + 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")); + let screen: ReactNode; + function SourceObserver() { + screen = AddProjectSourceScreen({ environmentId: "environment" }); + return null; + } + try { + for (const phase of ["available", "offline", "error"] as const) { + state.connectionStates = { environment: phase }; + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + expect(state.dispatch).not.toHaveBeenCalled(); + } + state.connectionStates = {}; + await act(() => root.render(createElement(SourceObserver))); + const reconnectedLocal = findAction(screen, "Local folder"); + if (!reconnectedLocal) throw new Error("Reconnected local folder action missing"); + reconnectedLocal(); + expect(state.dispatch).toHaveBeenLastCalledWith({ + type: "PUSH", + payload: { name: "AddProjectLocal", params: { environmentId: "environment" } }, + }); + state.connectionStates = { environment: "offline" }; + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + const select = findAction(screen, "Another environment"); + if (!select) throw new Error("Connected environment selection missing"); + await act(() => select()); + const local = findAction(screen, "Local folder"); + if (!local) throw new Error("Manually selected local folder action missing"); + local(); + expect(state.dispatch).toHaveBeenLastCalledWith({ + type: "PUSH", + payload: { name: "AddProjectLocal", params: { environmentId: "another-environment" } }, + }); + } finally { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); + + it("keeps a manual host selection when the requested host finishes connecting", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + const navigation = mountProjectFlow(false, true, false, "AddProject"); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + state.connectionStates = { environment: "connecting" }; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function SourceObserver() { + screen = AddProjectSourceScreen({ environmentId: "environment" }); + return null; + } + try { + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + expect(navigation.getState().routes.at(-1)?.name).toBe("AddProject"); + const select = findAction(screen, "Another environment"); + if (!select) throw new Error("Connected environment selection missing"); + await act(() => select()); + state.connectionStates = {}; + await act(() => root.render(createElement(SourceObserver))); + const local = findAction(screen, "Local folder"); + if (!local) throw new Error("Local folder action missing"); + local(); + expect(navigation.getState().routes.at(-1)).toMatchObject({ + name: "AddProjectLocal", + params: { environmentId: "another-environment" }, + }); + } finally { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); + + it("follows warm environment requests while preserving manual selection between requests", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + const navigation = mountProjectFlow(false, true, false, "AddProject"); + 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")); + let actions: { local: () => unknown; selectEnvironment: () => unknown } | undefined; + function SourceRouteObserver(props: { environmentId?: string | string[] }) { + const route = AddProjectSourceRoute({ route: { params: props } }); + const screen = AddProjectSourceScreen(route.props); + const local = findAction(screen, "Local folder"); + const selectEnvironment = findAction(screen, "Environment"); + if (!local || !selectEnvironment) throw new Error("Source actions missing"); + actions = { local, selectEnvironment }; + return null; + } + const openLocalFolder = (environmentId: string) => { + state.dispatch.mockClear(); + actions!.local(); + expect(navigation.getState().routes.at(-1)).toMatchObject({ + name: "AddProjectLocal", + params: { environmentId }, + }); + navigation.back(); + }; + try { + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "environment" })), + ); + openLocalFolder("environment"); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "another-environment" })), + ); + openLocalFolder("another-environment"); + await act(() => actions!.selectEnvironment()); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: ["another-environment"] })), + ); + openLocalFolder("environment"); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "environment" })), + ); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "another-environment" })), + ); + openLocalFolder("another-environment"); + } finally { + await act(() => root.unmount()); + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); +}); + +describe("local project navigation", () => { + function setup() { + state.navigationState = undefined; + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.baseDirectory = "/tmp/t3-mobile-audit-fixture"; + state.projects = []; + state.dispatch.mockReset(); + state.alert.mockReset(); + state.waitForProject.mockReset(); + const created = Promise.withResolvers>>(); + const projected = Promise.withResolvers(); + state.createResult = created.promise; + state.waitForProject.mockReturnValue(projected.promise); + const submit = findAction( + renderScreen(() => AddProjectLocalFolderScreen({ environmentId: "environment" })), + "Add project", + ); + if (!submit) throw new Error("Add project action missing"); + return { created, projected, submit }; + } + + it("keeps navigation in place and explains a project projection timeout", async () => { + const { created, projected, submit } = setup(); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + projected.resolve(null); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(state.dispatch).not.toHaveBeenCalled(); + expect(state.alert).toHaveBeenCalledExactlyOnceWith( + "Project added", + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + }); + + it("does not replace a reopened flow when the previous submission finishes", async () => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(false); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + navigation.back(); + navigation.push("AddProjectLocal"); + const remaining = navigation.getState(); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + }); + + it("does not replace a warm-linked flow sharing the original route key", async () => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(false); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + const originalKey = navigation.getState().routes.at(-1)!.key; + navigation.setParams({ environmentId: "other-environment" }); + const remaining = navigation.getState(); + expect(remaining.routes.at(-1)!.key).toBe(originalKey); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + }); + + it("does not redirect a reopened flow when an earlier new project finishes", async () => { + const { projected } = setup(); + state.newProjectsRoot = "/projects"; + const created = Promise.withResolvers(); + const waitingForProject = Promise.withResolvers(); + state.createNewResult = created.promise; + state.waitForProject.mockImplementation(() => { + waitingForProject.resolve(); + return projected.promise; + }); + const navigation = mountProjectFlow(false, true, true, "AddProjectNew"); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function NewProjectObserver() { + screen = AddProjectNewScreen({ environmentId: "environment" }); + return null; + } + function enterName(node: ReactNode): boolean { + if (Array.isArray(node)) return node.some(enterName); + if ( + !isValidElement<{ + placeholder?: string; + onChangeText?: (text: string) => void; + children?: ReactNode; + }>(node) + ) + return false; + if (node.props.placeholder === "Project name") { + node.props.onChangeText!("new-project"); + return true; + } + return enterName(node.props.children); + } + try { + await act(() => root.render(createElement(NewProjectObserver))); + await act(() => { + expect(enterName(screen)).toBe(true); + }); + const submit = findAction(screen, "Create project"); + if (!submit) throw new Error("Create project action missing"); + await act(async () => { + submit(); + created.resolve( + AsyncResult.success({ + projectId: "project", + workspaceRoot: "/projects/new-project", + }), + ); + await waitingForProject.promise; + }); + navigation.back(); + navigation.push("AddProjectNew"); + const remaining = navigation.getState(); + await act(async () => { + projected.resolve({ environmentId: "environment", id: "project" }); + await projected.promise; + }); + + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + } finally { + await act(() => root.unmount()); + state.newProjectsRoot = null; + state.createNewResult = null; + } + }); + + it.each([ + { origin: "Thread", withDraft: true, withChooser: true }, + { origin: "NewTaskProject", withDraft: false, withChooser: true }, + { origin: "AddProject", withDraft: false, withChooser: false }, + ])( + "waits for projection then selects the draft from $origin", + async ({ withDraft, withChooser }) => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(withDraft, withChooser, true); + const before = navigation.getState(); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(navigation.getState()).toBe(before); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + expect(state.dispatch).toHaveBeenCalledTimes(1); + expect(navigation.getState().routes.map((route) => route.name)).toEqual(["Home", "Thread"]); + expect(navigation.getState().routes[1]?.params).toEqual({ + draft: { + environmentId: "environment", + projectId: "project", + title: "t3-mobile-audit-fixture", + }, + }); + navigation.back(); + expect(navigation.getState().routes).toEqual([navigation.home]); + }, + ); +}); + +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 6de42ccd7a1bc..218d01041561d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,5 @@ +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import * as Dialog from "../../components/ui/Dialog"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { NavigationRow } from "../../components/ui/NavigationRow"; @@ -56,7 +58,6 @@ import { SourceControlProviderKind, } from "@t3tools/contracts"; import { - CommonActions, StackActions, useNavigation, type NavigationState, @@ -86,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, @@ -109,7 +111,6 @@ interface EnvironmentOption { readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; - readonly supportsCloneTracking: boolean; } const environmentOptionOrder = Order.mapInput( @@ -291,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: { @@ -326,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 ( @@ -343,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(); @@ -370,7 +377,9 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; } -function useEnvironmentOptions(): ReadonlyArray { +function useEnvironmentOptions( + selectedEnvironmentId: EnvironmentId | null = null, +): ReadonlyArray { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); const { connectedEnvironments } = useRemoteConnectionStatus(); @@ -392,29 +401,33 @@ function useEnvironmentOptions(): ReadonlyArray { connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, - supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true, }; }); return Arr.sort( - options.filter((environment) => canCreateProjectInEnvironment(environment.connectionState)), + options.filter( + (environment) => + canCreateProjectInEnvironment(environment.connectionState) || + environment.environmentId === selectedEnvironmentId, + ), environmentOptionOrder, ); })(); } -function useSelectedEnvironment(): { +function useSelectedEnvironment(requestedEnvironmentId: EnvironmentId | null): { readonly environmentOptions: ReadonlyArray; readonly selectedEnvironment: EnvironmentOption | null; readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; } { - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); - const environmentOptions = useEnvironmentOptions(); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(requestedEnvironmentId); + const [previousRequest, setPreviousRequest] = useState(requestedEnvironmentId); + if (previousRequest !== requestedEnvironmentId) { + setPreviousRequest(requestedEnvironmentId); + setSelectedEnvironmentId(requestedEnvironmentId); + } + const environmentOptions = useEnvironmentOptions(selectedEnvironmentId); const selectedEnvironment = - environmentOptions.find( - (environment) => - environment.environmentId === selectedEnvironmentId && - canCreateProjectInEnvironment(environment.connectionState), - ) ?? + environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? environmentOptions.find((environment) => canCreateProjectInEnvironment(environment.connectionState), ) ?? @@ -501,10 +514,10 @@ function SourceControlRow(props: { ); } -export function AddProjectSourceScreen() { +export function AddProjectSourceScreen(props: { readonly environmentId?: string | string[] }) { const navigation = useNavigation(); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = - useSelectedEnvironment(); + useSelectedEnvironment(stringParam(props.environmentId) as EnvironmentId | null); const canWriteSourceControl = useEnvironmentScope( selectedEnvironment?.environmentId ?? null, AuthSourceControlWriteScope, @@ -513,9 +526,10 @@ export function AddProjectSourceScreen() { selectedEnvironment?.environmentId ?? null, AuthOrchestrationOperateScope, ); - const canCloneProject = canWriteSourceControl && canCreateProject; + const isConnected = canCreateProjectInEnvironment(selectedEnvironment?.connectionState); + const canCloneProject = isConnected && canWriteSourceControl && canCreateProject; const discoveryState = useEnvironmentQuery( - selectedEnvironment === null + selectedEnvironment === null || !isConnected ? null : sourceControlEnvironment.discovery({ environmentId: selectedEnvironment.environmentId, @@ -528,7 +542,7 @@ export function AddProjectSourceScreen() { {selectedEnvironment === null ? : null} - {environmentOptions.length > 1 ? ( + {environmentOptions.length > 1 || (selectedEnvironment !== null && !isConnected) ? ( <> Environments @@ -579,6 +593,7 @@ export function AddProjectSourceScreen() { } isFirst={selectedEnvironment.newProjectsRoot === null} - disabled={!canCreateProject} + disabled={!isConnected || !canCreateProject} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -650,27 +665,37 @@ export function AddProjectSourceScreen() { ); } -function openNewTaskDraft( - navigation: { - dispatch: (action: ReturnType) => void; - getState: () => NavigationState | undefined; - }, - params: { - environmentId: EnvironmentId; - projectId: ProjectId; - title: string; - cloning?: "1"; - draftId?: string; - }, -) { +function getActiveRoute(navigation: { + getState: () => NavigationState | undefined; +}) { const state = navigation.getState(); - if (!state) return; - const threadIndex = state.routes.findLastIndex((route) => route.name === "Thread"); - const routes = [ - ...state.routes.slice(0, threadIndex < 0 ? state.index : threadIndex), - { key: uuidv4(), name: "Thread", params: { draft: params } }, - ]; - navigation.dispatch(CommonActions.reset({ ...state, index: routes.length - 1, routes })); + return state?.routes[state.index]; +} + +function useOpenNewTaskDraft() { + const navigation = useNavigation>(); + const selectDetail = useSelectWorkspaceDetail(); + return ( + params: { + environmentId: EnvironmentId; + projectId: ProjectId; + title: string; + cloning?: "1"; + draftId?: string; + }, + originRoute: NavigationState["routes"][number] | undefined, + ) => { + const activeRoute = getActiveRoute(navigation); + // A warm link can change the task without replacing its native route key. + if ( + !originRoute || + activeRoute?.key !== originRoute.key || + activeRoute.name !== originRoute.name || + activeRoute.params !== originRoute.params + ) + return; + selectDetail({ name: "Thread", params: { draft: params } }); + }; } async function draftWithPrompt( @@ -685,11 +710,12 @@ async function draftWithPrompt( } function useCreateProject(environment: EnvironmentOption | null) { + const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const projects = useProjects(); - return async (workspaceRoot: string) => { + return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { if ( !environment || !canCreateProjectInEnvironment(environment.connectionState) || @@ -709,11 +735,14 @@ function useCreateProject(environment: EnvironmentOption | null) { }); if (existing) { Dialog.alert("Project already exists", existing.title); - openNewTaskDraft(navigation, { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, - }); + openNewTaskDraft( + { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + }, + originRoute, + ); return; } @@ -730,11 +759,27 @@ function useCreateProject(environment: EnvironmentOption | null) { if (AsyncResult.isFailure(result)) { return result; } - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title: inferProjectTitleFromPath(workspaceRoot), - }); + // A successful command can precede its project in the client store. The + // draft treats a missing project as a reason to return to the chooser. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + Dialog.alert( + "Project added", + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + return result; + } + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + }, + originRoute, + ); return result; }; } @@ -943,6 +988,7 @@ function FolderBrowser(props: { } export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) { + const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const environmentOptions = useEnvironmentOptions().filter( (option) => option.newProjectsRoot !== null, @@ -952,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, }); @@ -1017,6 +1067,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s const submittingRef = useRef(false); const submit = async () => { if (!environment || trimmedName.length === 0 || submittingRef.current) return; + const originRoute = getActiveRoute(navigation); submittingRef.current = true; setError(null); setIsSubmitting(true); @@ -1049,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, + ); } }); } @@ -1067,11 +1124,14 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s ); return; } - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title: trimmedName, - }); + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title: trimmedName, + }, + originRoute, + ); }; return ( @@ -1262,11 +1322,13 @@ export function AddProjectDestinationScreen(props: { readonly repositoryName?: string | string[]; readonly prompt?: string | string[]; }) { - const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { + const openNewTaskDraft = useOpenNewTaskDraft(); + 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); @@ -1279,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); @@ -1313,34 +1374,36 @@ export function AddProjectDestinationScreen(props: { return; } + 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, + 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 { + 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.", ); - 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(navigation, { + } else { + openNewTaskDraft( + { environmentId: environment.environmentId, projectId, title, @@ -1351,25 +1414,9 @@ export function AddProjectDestinationScreen(props: { () => ({}), ) : {}), - }); - } - } - setIsSubmitting(false); - return; - } - const cloneResult = await cloneRepository({ - environmentId: environment.environmentId, - input: { - remoteUrl, - destinationPath: resolved.path, - }, - }); - if (AsyncResult.isFailure(cloneResult)) { - setError(errorMessage(Cause.squash(cloneResult.cause))); - } else { - const createResult = await createProject(cloneResult.value.cwd); - if (createResult && AsyncResult.isFailure(createResult)) { - setError(errorMessage(Cause.squash(createResult.cause))); + }, + originRoute, + ); } } setIsSubmitting(false); diff --git a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx index 2dc85606be739..d69e9141fdf9f 100644 --- a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx @@ -1,5 +1,12 @@ +import type { StaticScreenProps } from "@react-navigation/native"; import { AddProjectSourceScreen } from "./AddProjectScreen"; -export function AddProjectSourceRoute() { - return ; +type AddProjectSourceRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectSourceRoute({ + route, +}: StaticScreenProps) { + return ; } 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/PullRequestFiles.tsx b/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx index f231479b09842..b03a35e0096ce 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx @@ -1,12 +1,7 @@ import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; -import { - StackActions, - useNavigation, - useRoute, - type StaticScreenProps, -} from "@react-navigation/native"; +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { PullRequestChangeBreakdown } from "@t3tools/contracts"; import { useState } from "react"; import { Platform, View } from "react-native"; @@ -36,6 +31,7 @@ import { } from "./usePullRequestDiff"; import { usePullRequestFileFilter } from "./usePullRequestFileFilter"; import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed"; +import { pullRequestFileJumpScope, requestPullRequestFileJump } from "./pullRequestFileJump"; const BREAKDOWN_LABELS = { code: "Code", @@ -403,7 +399,7 @@ function DirectoryRow(props: { export function PullRequestFilesSheet({ route }: StaticScreenProps) { const params = route.params; const navigation = useNavigation(); - const fileSelectionId = useRoute().key; + const jumpScope = pullRequestFileJumpScope(params); const files = useChangedFiles(params); const { diff, filter, viewed, parsed, loading } = files; const [collapsed, setCollapsed] = useState>(() => new Set()); @@ -463,15 +459,16 @@ export function PullRequestFilesSheet({ route }: StaticScreenProps + onPress={() => { + requestPullRequestFileJump(jumpScope, item.file.path); navigation.dispatch( StackActions.popTo("PullRequestFile", { ...params, path: item.file.path, - fileSelectionId, + focus: undefined, }), - ) - } + ); + }} /> )} 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/PullRequestInlineCommentScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx index 7a8152e01dc09..d0b7a5ae88019 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx @@ -33,7 +33,7 @@ export function PullRequestInlineCommentScreen({ route }: StaticScreenProps refreshDetail()); + useFocusEffect(refreshDetail); const permissions = usePullRequestPermissions(params, detail.data); const stale = detail.data && detail.data.headSha !== params.headSha; const pending = useRef(false); 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 8f97b9c22899d..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"; @@ -22,6 +22,8 @@ import { type FilePreviewItem, } from "../../components/FilePreviewModal"; +export const PullRequestMediaVisibleContext = createContext(true); + export const PullRequestMediaContext = createContext<{ readonly environmentId: EnvironmentId; readonly cwd: string; @@ -30,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 { @@ -54,6 +53,7 @@ export function PullRequestMedia(props: { readonly compact?: boolean; }) { const media = usePullRequestMediaUrl(props.url); + const visible = useContext(PullRequestMediaVisibleContext); const context = useContext(PullRequestMediaContext); const withGallery = useFilePreviewGallery(); const [image, setImage] = useState(null); @@ -86,7 +86,7 @@ export function PullRequestMedia(props: { setVideo({ @@ -105,6 +105,8 @@ export function PullRequestMedia(props: { unavailable={media.unavailable} name={name} thumbnailKey={props.url} + thumbnailVisible={visible} + visible={visible} resolvePlaybackUri={media.resolvePlaybackUri} /> )} diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx index becd820242ef9..bb923689d4266 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx @@ -68,7 +68,7 @@ export function PullRequestReviewSheet({ const target = pullRequestTarget(params); const result = useEnvironmentQuery(composerPullRequests.detail(target)); const { refresh: refreshDetail } = result; - useFocusEffect(() => refreshDetail()); + useFocusEffect(refreshDetail); const permissions = usePullRequestPermissions(params, result.data); const submission = useActionSubmission(params); const review = useAtomCommand(composerPullRequests.submitReview, { reportFailure: false }); 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("overview"); - const requestedSection = useRef(section); + const [chosenSection, setSection] = useState<"overview" | "activity" | "guide">("overview"); + const requestedSection = useRef(chosenSection); const [listAnimationsReady, setListAnimationsReady] = useState(false); const [descriptionExpanded, setDescriptionExpanded] = useState(false); const filesContent = usePullRequestFilesSection(params); const guidesEnabled = useReviewGuidesEnabled(params); + // The guide tab goes away while guides are off and overview stands in. The pick is kept, so + // guides coming back (a settings reload, a reconnect) return to the guide the user chose. + const shown = (tab: typeof chosenSection) => + !guidesEnabled && tab === "guide" ? "overview" : tab; + const section = shown(chosenSection); const [activityFilter, setActivityFilter] = useState(DEFAULT_ACTIVITY_FILTER); const activityContent = usePullRequestActivityRows(params, activityFilter); const scrollOffsets = useRef>({}); - const currentScrollOffset = useRef(-insets.top); + // The offset of the tab last shown, and which tab that was: a tab standing in for a hidden guide + // has its own offset, which only its next scroll would record here. + const currentScrollOffset = useRef<{ tab: typeof section; offset: number }>({ + tab: "overview", + offset: -barInsets.top, + }); const bodyY = useRef(0); const [headerHeight, setHeaderHeight] = useState(0); const [viewportHeight, setViewportHeight] = useState(0); @@ -370,26 +385,21 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { if (next === section) return; - scrollOffsets.current[section] = currentScrollOffset.current; - const headerEnd = Math.max(-insets.top, bodyY.current - insets.top); - const offset = pullRequestTabOffset( - currentScrollOffset.current, - scrollOffsets.current[next], - headerEnd, - ); + const from = shown(requestedSection.current); + const current = + currentScrollOffset.current.tab === from + ? currentScrollOffset.current.offset + : (scrollOffsets.current[from] ?? -barInsets.top); + scrollOffsets.current[section] = current; + const headerEnd = Math.max(-barInsets.top, bodyY.current - barInsets.top); + const offset = pullRequestTabOffset(current, scrollOffsets.current[next], headerEnd); requestedSection.current = next; scrollOffsets.current[next] = offset; - currentScrollOffset.current = offset; - scrollViews.current[next]?.scrollToOffset({ offset, animated: false }); + currentScrollOffset.current = { tab: next, offset }; + // LegendList clamps to its measured rows, which leave out the short-tab minHeight below. + scrollViews.current[next]?.getNativeScrollRef().scrollTo({ y: offset, animated: false }); setSection(next); }; - useEffect(() => { - if (!guidesEnabled && section === "guide") { - requestedSection.current = "overview"; - currentScrollOffset.current = scrollOffsets.current.overview ?? -insets.top; - setSection("overview"); - } - }, [guidesEnabled, section, insets.top]); const tabContent = activityContent; const filesY = useRef(0); const descriptionY = useRef(0); @@ -399,7 +409,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps refreshDetail()); + useFocusEffect(refreshDetail); const pr = usePullRequestLiveChecks(params, result.data); const summary = params.initialSummary; const identity = pr ?? summary; @@ -435,7 +445,7 @@ 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; @@ -659,7 +669,8 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { const offset = event.nativeEvent.contentOffset.y; scrollOffsets.current[tab] = offset; - if (requestedSection.current === tab) currentScrollOffset.current = offset; + if (shown(requestedSection.current) === tab) + currentScrollOffset.current = { tab, offset }; }} className="flex-1" renderScrollComponent={ @@ -668,7 +679,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps {tab === "overview" && identity ? ( + {pr ? ( { - if (requestedSection.current === "overview") jumpToFiles(); + if (shown(requestedSection.current) === "overview") + jumpToFiles(); }); } }} @@ -794,7 +814,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps - {item.content} + + {item.content} + )} recycleItems={false} @@ -852,7 +874,6 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps refreshDetail()); + useFocusEffect(refreshDetail); const { loadMore } = result; const parsed = filter.parsed; const reviewDraft = usePullRequestReviewDraft(route.params); @@ -978,40 +999,36 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { const [collapsedIds, setCollapsedIds] = useState(EMPTY_IDS); const [visibleFileId, setVisibleFileId] = useState(null); const nativeView = useRef(null); - // Native applies the entry destination before its first viewport. Imperative - // jumps below are for subsequent selections and filter changes only. - const jumpStarted = useRef(true); - const lastSelection = useRef({ path: route.params.path, id: route.params.fileSelectionId }); - useEffect(() => { - if ( - lastSelection.current.path === route.params.path && - lastSelection.current.id === route.params.fileSelectionId - ) - return; - lastSelection.current = { path: route.params.path, id: route.params.fileSelectionId }; - if (route.params.focus) navigation.setParams({ focus: undefined }); - setJumpPath(route.params.path); + // The filter the current jump was made under. Hiding or showing test files replaces the rows, + // so a jump made under the other filter has to run again. Native applies the entry + // destination before its first viewport, so the first one counts as made. + const jumpedUnder = useRef(filter.hideTestFiles); + const hasTestFiles = + result.parsed.kind === "files" && + result.parsed.files.some((file) => isPullRequestTestFile(file.path)); + const jumpStarted = () => + jumpedUnder.current !== null && (!hasTestFiles || jumpedUnder.current === filter.hideTestFiles); + const scrollToFile = (fileId: string) => { + if (!nativeView.current) return; + jumpedUnder.current = filter.hideTestFiles; + void nativeView.current.scrollToFile(fileId, false).catch(() => { + jumpedUnder.current = null; + }); + }; + const jumpScope = pullRequestFileJumpScope(route.params); + const jumpToFile = useEffectEvent((path: string) => { + setJumpPath(path); setSelectedRowId(null); - jumpStarted.current = false; - const file = data.files.find((entry) => entry.path === route.params.path); - if (!file || !nativeView.current) return; + jumpedUnder.current = null; + const file = data.files.find((entry) => entry.path === path); + if (!file) return; setCollapsedIds((ids) => ids.filter((id) => id !== file.id)); - jumpStarted.current = true; - void nativeView.current.scrollToFile(file.id, false).catch(() => { - jumpStarted.current = false; - }); - }, [route.params.path, route.params.fileSelectionId, route.params.focus, data.files, navigation]); - const appliedFilter = useRef(filter.hideTestFiles); - useEffect(() => { - if (appliedFilter.current === filter.hideTestFiles) return; - appliedFilter.current = filter.hideTestFiles; - if ( - result.parsed.kind === "files" && - result.parsed.files.some((file) => isPullRequestTestFile(file.path)) - ) { - jumpStarted.current = false; - } - }, [filter.hideTestFiles, result.parsed]); + scrollToFile(file.id); + }); + // Subscribed for the whole screen, so a pick made while the diff is still loading is kept for + // the first draw. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to pull request file jump requests for the screen's lifetime + useEffect(() => subscribePullRequestFileJump(jumpScope, (path) => jumpToFile(path)), [jumpScope]); const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences(); const bridge = useNativeReviewDiffBridge({ @@ -1025,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]); @@ -1036,6 +1054,9 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { isIos: Platform.OS === "ios", nativeView, jumpStarted, + setJumpStarted: (started) => { + jumpedUnder.current = started ? filter.hideTestFiles : null; + }, onDebug: bridge.onDebug, loadMore, }); @@ -1192,7 +1213,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { onPress={() => { result.refresh(); setDiffHead({ scopeKey, sha: detail.data?.headSha ?? null }); - jumpStarted.current = false; + jumpedUnder.current = null; setSelectedRowId(null); setCollapsedIds(EMPTY_IDS); }} 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 be38aef05044a..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]); @@ -207,7 +208,6 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps ({ + query: { + data: null as { run: { _tag: "Running" | "Completed" } } | null, + isPending: false, + refresh: vi.fn<() => void>(), + }, + otherQuery: { + data: null as { run: { _tag: "Running" | "Completed" } } | null, + isPending: false, + refresh: vi.fn<() => void>(), + }, +})); + +vi.mock("@react-navigation/native", async () => { + const { createRequire } = await import("node:module"); + const { realpathSync } = await import("node:fs"); + const require = createRequire(import.meta.url); + const nativeRequire = createRequire( + realpathSync(require.resolve("@react-navigation/native/package.json")), + ); + // Use the shipped focus hook without importing the React Native view layer. + const { NavigationContext, useFocusEffect }: typeof import("@react-navigation/native") = + nativeRequire("@react-navigation/core"); + return { NavigationContext, useFocusEffect }; +}); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => (atom === "settings" ? { reviewGuidesEnabled: true } : true), +})); +vi.mock("../../../state/server", () => ({ + serverEnvironment: { settingsValueAtom: () => "settings" }, +})); +vi.mock("../../../state/pull-requests", () => ({ + composerPullRequests: { + reviewGuide: (target: { number: number; environmentId: string }) => + target.environmentId === "environment" && target.number === 1 ? 1 : 2, + generateReviewGuide: { permissionAtom: () => "permission" }, + }, +})); +vi.mock("../../../state/query", () => ({ + useEnvironmentQuery: (number: number) => ({ ...(number === 1 ? mocks.query : mocks.otherQuery) }), +})); +vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../usePullRequestConnected", () => ({ usePullRequestConnected: () => true })); +vi.mock("../usePullRequestDiff", () => ({ + pullRequestTarget: (params: unknown) => params, +})); + +import { useReviewGuide } from "./useReviewGuide"; + +function createNavigation() { + const listeners = { focus: new Set<() => void>(), blur: new Set<() => void>() }; + let focused = true; + return { + value: { + isFocused: () => focused, + addListener: (event: keyof typeof listeners, listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + } as unknown as NavigationProp, + focus(next: boolean) { + focused = next; + act(() => { + for (const listener of listeners[next ? "focus" : "blur"]) listener(); + }); + }, + }; +} + +function setup() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.query = { data: null, isPending: false, refresh: vi.fn() }; + mocks.otherQuery = { data: null, isPending: false, refresh: vi.fn() }; + const root = createRoot(document.createElement("div")); + const navigation = createNavigation(); + const params = { + environmentId: "environment", + projectId: "project", + repository: "repo", + number: 1, + }; + function Probe({ number, environmentId }: { number: number; environmentId: string }) { + useReviewGuide({ ...params, number, environmentId }); + return null; + } + return { + render(number = 1, environmentId = params.environmentId) { + act(() => + root.render( + + + , + ), + ); + }, + focus: navigation.focus, + [Symbol.dispose]() { + act(() => root.unmount()); + }, + }; +} + +it("refreshes on focus and PR changes without refreshing again for query rerenders", () => { + using screen = setup(); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + + mocks.query = { ...mocks.query, isPending: true }; + screen.render(); + mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + + screen.focus(false); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + + screen.render(2); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + screen.render(2); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); +}); + +it("preserves the polling deadline across rerenders and stops polling on blur or completion", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using screen = setup(); + mocks.query.data = { run: { _tag: "Running" } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + + act(() => vi.advanceTimersByTime(4_000)); + mocks.query = { ...mocks.query, isPending: true, data: { run: { _tag: "Running" } } }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(1_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + + screen.focus(false); + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(4); + + mocks.query = { ...mocks.query, isPending: false, data: { run: { _tag: "Completed" } } }; + screen.render(); + const refreshesOnCompletion = mocks.query.refresh.mock.calls.length; + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(refreshesOnCompletion); +}); + +it("retires the old environment's polling on target change and clears polling on unmount", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + { + using screen = setup(); + mocks.query.data = { run: { _tag: "Running" } }; + mocks.otherQuery.data = { run: { _tag: "Running" } }; + screen.render(); + act(() => vi.advanceTimersByTime(4_000)); + + screen.render(1, "other-environment"); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(1_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(4_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); + } + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); +}); 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/pullRequestDiffDebug.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.test.ts index 030e3ef439545..23f5ba4726d62 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.test.ts @@ -35,7 +35,6 @@ it.each([true, false])( const scheduler = createReviewDiffHighlightScheduler(sourceRequest); const scrollToFile = vi.fn(async () => {}); const loadMore = vi.fn(); - const jumpStarted = { current: true }; const onDebug: NonNullable = ({ nativeEvent }) => { if (nativeEvent.message === "visible-range") scheduler.update({ @@ -50,7 +49,8 @@ it.each([true, false])( focusIndex: -1, isIos, nativeView: { current: { scrollToFile, scrollToTop: vi.fn(async () => {}) } }, - jumpStarted, + jumpStarted: () => true, + setJumpStarted: () => {}, onDebug, loadMore, }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.ts index c82aa9deeb6d6..b6db266c38aa5 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffDebug.ts @@ -25,7 +25,8 @@ export function handlePullRequestDiffDebugEvent( readonly focusIndex: number; readonly isIos: boolean; readonly nativeView: RefObject; - readonly jumpStarted: RefObject; + readonly jumpStarted: () => boolean; + readonly setJumpStarted: (started: boolean) => void; readonly onDebug: NonNullable; readonly loadMore: () => void; }, @@ -46,14 +47,14 @@ export function handlePullRequestDiffDebugEvent( if (drawn) input.onDebug(event); if ( input.targetFileId && - !input.jumpStarted.current && + !input.jumpStarted() && input.nativeView.current && (!input.isIos || decoded) ) { - input.jumpStarted.current = true; + input.setJumpStarted(true); if (input.focusIndex >= 0) return; void input.nativeView.current.scrollToFile(input.targetFileId, false).catch(() => { - input.jumpStarted.current = false; + input.setJumpStarted(false); }); return; } diff --git a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts index 3ed5fe6b1835f..808bfa1b485ec 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; import { pullRequestFallbackEnvironment } from "./pullRequestEnvironmentFallback"; const project = (environmentId: string, id: string, canonicalKey: string) => @@ -20,24 +22,35 @@ const target = { repository: "pingdotgg/t3code", host: "github.com", }; +const environment = ( + environmentId: string, + connectionState: EnvironmentConnectionPhase = "connected", + isEnabled = true, +) => ({ environmentId: EnvironmentId.make(environmentId), connectionState, isEnabled }); describe("pullRequestFallbackEnvironment", () => { it("moves to a connected copy of the repository when the original is down", () => { expect( - pullRequestFallbackEnvironment(target, projects, new Set(["desktop", "server"])), + pullRequestFallbackEnvironment(target, projects, [ + environment("desktop"), + environment("server"), + ]), ).toEqual({ environmentId: "server", projectId: "t3-copy" }); }); it("stays put while the original is connected or nothing else holds the repository", () => { - expect(pullRequestFallbackEnvironment(target, projects, new Set(["laptop", "server"]))).toBe( - null, - ); - expect(pullRequestFallbackEnvironment(target, projects, new Set(["desktop"]))).toBe(null); + expect( + pullRequestFallbackEnvironment(target, projects, [ + environment("laptop"), + environment("server"), + ]), + ).toBe(null); + expect(pullRequestFallbackEnvironment(target, projects, [environment("desktop")])).toBe(null); }); it("derives the host from the original project when the route has none", () => { const { host: _host, ...hostless } = target; - expect(pullRequestFallbackEnvironment(hostless, projects, new Set(["server"]))).toEqual({ + expect(pullRequestFallbackEnvironment(hostless, projects, [environment("server")])).toEqual({ environmentId: "server", projectId: "t3-copy", }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts index 7c1d234f92577..bfa31edfea756 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts @@ -1,4 +1,5 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import type { EnvironmentConnectionSummary } from "@t3tools/client-runtime/state/presentation"; export type PullRequestEnvironmentTarget = { readonly environmentId: string; @@ -10,9 +11,28 @@ export type PullRequestEnvironmentTarget = { export function pullRequestFallbackEnvironment( target: PullRequestEnvironmentTarget, projects: ReadonlyArray, - connectedEnvironmentIds: ReadonlySet, + environments: ReadonlyArray< + Pick + >, ) { - if (connectedEnvironmentIds.has(target.environmentId)) return null; + const requested = environments.find( + (environment) => environment.environmentId === target.environmentId, + ); + // Pending connections retain the requested target until an explicit failure, even before + // its project hydrates. Available includes the period before connection startup. + if ( + requested?.isEnabled && + (requested.connectionState === "connected" || + requested.connectionState === "available" || + requested.connectionState === "connecting" || + requested.connectionState === "reconnecting") + ) + return null; + const connectedEnvironmentIds = new Set( + environments + .filter((environment) => environment.isEnabled && environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); const own = projects.find( (project) => project.environmentId === target.environmentId && project.id === target.projectId, ); diff --git a/apps/mobile/src/features/pull-requests/pullRequestFileJump.test.ts b/apps/mobile/src/features/pull-requests/pullRequestFileJump.test.ts new file mode 100644 index 0000000000000..fb5db9a41e978 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestFileJump.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; +import { requestPullRequestFileJump, subscribePullRequestFileJump } from "./pullRequestFileJump"; + +describe("pull request file jumps", () => { + it("delivers a pick to the screens of its scope, every time it is made", () => { + const seen: string[] = []; + const other: string[] = []; + const stop = subscribePullRequestFileJump("a", (path) => seen.push(path)); + const stopOther = subscribePullRequestFileJump("b", (path) => other.push(path)); + requestPullRequestFileJump("a", "src/x.ts"); + requestPullRequestFileJump("a", "src/x.ts"); + stop(); + requestPullRequestFileJump("a", "src/y.ts"); + stopOther(); + expect(seen).toEqual(["src/x.ts", "src/x.ts"]); + expect(other).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestFileJump.ts b/apps/mobile/src/features/pull-requests/pullRequestFileJump.ts new file mode 100644 index 0000000000000..f5d0423edc852 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestFileJump.ts @@ -0,0 +1,26 @@ +import type { PullRequestDiffParams } from "./usePullRequestDiff"; +import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; + +// The changed-files sheet hands its pick to the diff screen beneath it. Picking the file the +// screen already shows still jumps back to it, which a route param that did not change could not +// say. A screen that is not mounted yet opens at its `path` param instead. +type Listener = (path: string) => void; +const listeners = new Map>(); + +export function pullRequestFileJumpScope(params: PullRequestDiffParams): string { + return JSON.stringify([pullRequestReviewKey(params), params.commit ?? null]); +} + +export function requestPullRequestFileJump(scope: string, path: string): void { + for (const listener of listeners.get(scope) ?? []) listener(path); +} + +export function subscribePullRequestFileJump(scope: string, listener: Listener): () => void { + let scoped = listeners.get(scope); + if (!scoped) listeners.set(scope, (scoped = new Set())); + scoped.add(listener); + return () => { + scoped.delete(listener); + if (scoped.size === 0) listeners.delete(scope); + }; +} 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 59a0f723f7720..f7b1a7a1bf832 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts @@ -8,17 +8,23 @@ import { import { appendPullRequestListPage, DEFAULT_PR_LIST_CONTROLS, + EMPTY_PULL_REQUEST_STATS_CACHE, filterPullRequestList, mergePullRequestListEntries, mergeEnvironmentPullRequestPages, nextPullRequestListInput, PR_LIST_PAGE_SIZE, + patchPullRequestListEntry, pullRequestFilterChoices, pullRequestLabelColors, pullRequestListHead, pullRequestListInput, scopedPullRequestListKey, + shownPullRequestListPages, pullRequestListRowsAreEqual, + withAnsweredPullRequestStats, + withListedPullRequests, + withShownPullRequestStats, withoutScratchProjectScope, } from "./pullRequestList"; @@ -115,6 +121,14 @@ describe("mobile PR list pagination", () => { mergePullRequestListEntries([entry(1)], [entry(2, { host: "enterprise.test" }), entry(2)]), ).toHaveLength(3); }); + it("patches a row that only the list head supplies, and keeps it while that head is current", () => { + const head = page([entry(2), entry(1)], { repo: "first" }); + const patched = patchPullRequestListEntry(null, head, entry(1), { state: "merged" }, 5); + expect(shownPullRequestListPages(patched, head)?.entries.map((row) => row.state)).toEqual([ + "open", + "merged", + ]); + }); it("refreshes the head without dropping older loaded pages or rewinding their cursor", () => { const first = pullRequestListHead(null, page([entry(3)], { repo: "first" })); const continued = appendPullRequestListPage(first, page([entry(2)], { repo: "second" }), { @@ -387,3 +401,74 @@ describe("pullRequestFilterChoices", () => { expect(rows[50]).toBe("label-from-host"); }); }); + +describe("shown PR diff stats", () => { + const cups = EnvironmentId.make("cups"); + const stat = (number: number, additions: number, deletions: number) => ({ + projectId: "project" as PullRequestListEntry["projectId"], + repository: "org/repo", + number, + additions, + deletions, + }); + const rows = [ + { ...entry(1), environmentId: cups }, + { ...entry(2, { additions: 4, deletions: 1 }), environmentId: cups }, + ]; + + it("keeps the same list when the answers change no row", () => { + expect(withShownPullRequestStats(rows, [])).toBe(rows); + expect(withShownPullRequestStats(rows, [{ environmentId: cups, stats: [stat(1, 0, 0)] }])).toBe( + rows, + ); + expect(withShownPullRequestStats(rows, [{ environmentId: cups, stats: [stat(2, 9, 9)] }])).toBe( + rows, + ); + }); + + it("fills only rows without stats, in their own environment", () => { + const filled = withShownPullRequestStats(rows, [ + { environmentId: EnvironmentId.make("mbp"), stats: [stat(1, 7, 7)] }, + { environmentId: cups, stats: [stat(1, 3, 2), stat(2, 9, 9)] }, + ]); + expect(filled.map(({ additions, deletions }) => [additions, deletions])).toEqual([ + [3, 2], + [4, 1], + ]); + 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 8040c12d6d84d..fcb41ac8ec861 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -3,9 +3,11 @@ import { ProjectId, resolvePullRequestAuthorFilter, type EnvironmentId, + type PullRequestDiffStat, type PullRequestListEntry, type PullRequestListInput, type PullRequestListResult, + type PullRequestListStatsInput, } from "@t3tools/contracts"; import type { PullRequestListControls } from "../../persistence/mobile-preferences"; @@ -45,6 +47,103 @@ export type EnvironmentPullRequestListEntry = PullRequestListEntry & { export const scopedPullRequestListKey = (entry: EnvironmentPullRequestListEntry) => `${entry.environmentId}:${pullRequestListKey(entry)}`; +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. + */ +export function withShownPullRequestStats( + entries: readonly EnvironmentPullRequestListEntry[], + answers: readonly { + readonly environmentId: EnvironmentId; + readonly stats: readonly PullRequestDiffStat[]; + }[], +): readonly EnvironmentPullRequestListEntry[] { + const shown = new Map( + answers.flatMap(({ environmentId, stats }) => + stats.map((row) => [`${environmentId}:${pullRequestStatKey(row)}`, row] as const), + ), + ); + if (!shown.size) return entries; + let changed = false; + const next = entries.map((entry) => { + const stat = shown.get(`${entry.environmentId}:${pullRequestStatKey(entry)}`); + if (!stat || entry.additions + entry.deletions !== 0) return entry; + if (stat.additions + stat.deletions === 0) return entry; + changed = true; + return { ...entry, additions: stat.additions, deletions: stat.deletions }; + }); + return changed ? next : entries; +} + export function pullRequestListRowsAreEqual( previous: EnvironmentPullRequestListEntry, next: EnvironmentPullRequestListEntry, @@ -143,6 +242,34 @@ export function pullRequestListHead( }; } +/** The recorded pages with the current list head on top; before any answer is recorded, the head alone. */ +export function shownPullRequestListPages( + recorded: PullRequestListPages | null, + head: PullRequestListResult | null, +): PullRequestListPages | null { + return head ? pullRequestListHead(recorded, head) : recorded; +} + +export function patchPullRequestListEntry( + recorded: PullRequestListPages | null, + head: PullRequestListResult | null, + entry: PullRequestListEntry, + patch: Partial, + observedAt: number, +): PullRequestListPages | null { + const pages = shownPullRequestListPages(recorded, head); + return ( + pages && { + ...pages, + entries: pages.entries.map((row) => + pullRequestListKey(row) === pullRequestListKey(entry) + ? { ...row, ...patch, observedAt } + : row, + ), + } + ); +} + export function pullRequestListInput( controls: PullRequestListControls, query: string, 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.test.tsx b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx new file mode 100644 index 0000000000000..1500f429bb606 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx @@ -0,0 +1,143 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import { EnvironmentId } from "@t3tools/contracts"; +import { act, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +import type { WorkspaceEnvironment } from "../../state/workspaceModel"; +import type { PullRequestEnvironmentTarget } from "./pullRequestEnvironmentFallback"; + +const mocks = vi.hoisted(() => ({ + environments: [] as ReadonlyArray, + isReady: true, + projects: [] as ReadonlyArray, + navigation: { setParams: vi.fn<(params: Partial) => void>() }, +})); + +vi.mock("@react-navigation/native", () => ({ useNavigation: () => mocks.navigation })); +vi.mock("../../state/entities", () => ({ useProjects: () => mocks.projects })); +vi.mock("../../state/workspace", () => ({ + useWorkspaceEnvironments: () => mocks.environments, + useConnectionsReady: () => mocks.isReady, +})); + +import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback"; + +function environment( + environmentId: string, + connectionState: EnvironmentConnectionPhase, + isEnabled = true, +): WorkspaceEnvironment { + return { + environmentId: EnvironmentId.make(environmentId), + environmentLabel: environmentId, + displayUrl: "", + isRelayManaged: false, + isEnabled, + connectionState, + connectionError: null, + connectionErrorTraceId: null, + }; +} + +function setup() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.isReady = true; + mocks.environments = [environment("requested", "connecting"), environment("copy", "connected")]; + mocks.projects = [ + { environmentId: "requested", id: "original" }, + { environmentId: "copy", id: "copy-project" }, + ].map( + (project) => + ({ + ...project, + repositoryIdentity: { canonicalKey: "github.com/owner/repo" }, + }) as unknown as EnvironmentProject, + ); + const container = document.createElement("div"); + const root = createRoot(container); + mocks.navigation.setParams = vi.fn(); + function Probe() { + const [params, updateParams] = useState({ + environmentId: "requested", + projectId: "original", + repository: "owner/repo", + host: "github.com", + }); + useEffect(() => { + mocks.navigation.setParams.mockImplementation((next) => + updateParams((previous) => ({ ...previous, ...next })), + ); + }, []); + usePullRequestEnvironmentFallback(params); + return {`${params.environmentId}/${params.projectId}`}; + } + return { + render() { + act(() => root.render()); + }, + get target() { + return container.textContent; + }, + [Symbol.dispose]() { + act(() => root.unmount()); + }, + }; +} + +it("keeps the requested target while the catalog initializes or its enabled connection is pending", () => { + using screen = setup(); + mocks.isReady = false; + mocks.environments = [environment("copy", "connected")]; + screen.render(); + expect(screen.target).toBe("requested/original"); + expect(mocks.navigation.setParams).not.toHaveBeenCalled(); + + mocks.isReady = true; + for (const phase of ["available", "connecting", "reconnecting", "connected"] as const) { + mocks.environments = [environment("requested", phase), environment("copy", "connected")]; + screen.render(); + expect(screen.target).toBe("requested/original"); + expect(mocks.navigation.setParams).not.toHaveBeenCalled(); + } +}); + +it("falls back to a connected repository copy when the requested environment cannot connect", () => { + for (const state of ["missing", "disabled", "offline", "error", "unsupported"] as const) { + using screen = setup(); + mocks.environments = [ + ...(state === "missing" + ? [] + : [ + environment( + "requested", + state === "disabled" ? "available" : state, + state !== "disabled", + ), + ]), + environment("copy", "connected"), + ]; + screen.render(); + expect(screen.target).toBe("copy/copy-project"); + expect(mocks.navigation.setParams).toHaveBeenCalledExactlyOnceWith({ + environmentId: "copy", + projectId: "copy-project", + }); + } +}); + +it("does not replace a connecting target with a connected repository copy before its project hydrates", () => { + using screen = setup(); + const projects = mocks.projects; + mocks.projects = projects.filter((project) => project.environmentId !== "requested"); + screen.render(); + expect(screen.target).toBe("requested/original"); + expect(mocks.navigation.setParams).not.toHaveBeenCalled(); + + mocks.projects = projects; + mocks.environments = [environment("requested", "connected"), environment("copy", "connected")]; + screen.render(); + expect(screen.target).toBe("requested/original"); + expect(mocks.navigation.setParams).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts index 7fb13669642f0..71b3304f7e2d2 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts @@ -1,8 +1,8 @@ import { useNavigation } from "@react-navigation/native"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import { useProjects } from "../../state/entities"; -import { useWorkspaceEnvironments } from "../../state/workspace"; +import { useConnectionsReady, useWorkspaceEnvironments } from "../../state/workspace"; import { pullRequestFallbackEnvironment, type PullRequestEnvironmentTarget, @@ -11,14 +11,10 @@ import { export function usePullRequestEnvironmentFallback(params: PullRequestEnvironmentTarget) { const navigation = useNavigation(); const projects = useProjects(); - const connected = useWorkspaceEnvironments() - .filter((environment) => environment.connectionState === "connected") - .map((environment) => String(environment.environmentId)) - .join("\n"); - const fallback = useMemo( - () => pullRequestFallbackEnvironment(params, projects, new Set(connected.split("\n"))), - [params, projects, connected], - ); + const isReady = useConnectionsReady(); + const environments = useWorkspaceEnvironments(); + 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 5656e1d18ab5f..5bc3cf730b520 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -1,155 +1,199 @@ -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, PullRequestDiffStat, PullRequestListInput } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/reactivity"; -import { use, useCallback, useEffect, useMemo, useRef, useState, useLayoutEffect } from "react"; -import { composerPullRequests } from "../../state/pull-requests"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; +import { use, useMemo, useState } from "react"; +import { environmentCatalog } from "../../connection/catalog"; +import { + composerPullRequests, + pullRequestListPagesFamily, + pullRequestListPagesKey, + pullRequestListStatsFamily, +} from "../../state/pull-requests"; import { appendPullRequestListPage, mergeEnvironmentPullRequestPages, nextPullRequestListInput, + patchPullRequestListEntry, pullRequestListHead, - pullRequestListKey, + shownPullRequestListPages, + pullRequestStatKey as statKey, scopedPullRequestListKey, + withShownPullRequestStats, type EnvironmentPullRequestListEntry, type PullRequestListPages, } from "./pullRequestList"; -const pagesFamily = Atom.family((_key: string) => - Atom.make(null).pipe(Atom.setIdleTTL(5 * 60_000)), -); -const statsFamily = Atom.family((_environmentId: string) => - Atom.make>(new Map()).pipe(Atom.setIdleTTL(5 * 60_000)), -); -const statKey = (row: Pick) => - `${row.projectId}:${row.repository}:${row.number}`; - type ListTarget = { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput }; +type Read = (atom: Atom.Atom) => A; + +/** The head the list query holds now, cached or answered. */ +function currentHead(read: Read, target: ListTarget) { + return AsyncResult.getOrElse(read(composerPullRequests.list(target)), () => null); +} + +function currentPages(read: Read, target: ListTarget): PullRequestListPages | null { + return shownPullRequestListPages( + read(pullRequestListPagesFamily(pullRequestListPagesKey(target))), + currentHead(read, target), + ); +} + const sourcesFamily = Atom.family((key: string) => { const targets: readonly ListTarget[] = JSON.parse(key); return Atom.make((get) => - targets.map((target) => { - const result = get(composerPullRequests.list(target)); - const head = Option.getOrNull(AsyncResult.value(result)); - const cached = get(pagesFamily(JSON.stringify(target))); - return { - target, - result, - pages: head ? pullRequestListHead(cached, head) : cached, - stats: get(statsFamily(target.environmentId)), - }; - }), + targets.map((target) => ({ + target, + result: get(composerPullRequests.list(target)), + pages: currentPages(get, target), + stats: get(pullRequestListStatsFamily(target.environmentId)), + })), ); }); +function continuations(registry: AtomRegistry.AtomRegistry, targets: readonly ListTarget[]) { + return targets.flatMap((target) => { + const pages = currentPages((atom) => registry.get(atom), target); + const input = pages && nextPullRequestListInput(pages, target.input); + return pages && input ? [{ target, pages, input }] : []; + }); +} + +async function loadNextPages( + registry: AtomRegistry.AtomRegistry, + targets: readonly ListTarget[], + retry: boolean, + signal: AbortSignal, +) { + const responses = await Promise.allSettled( + continuations(registry, targets).map(async ({ target, pages, input }) => { + const response = await executeAtomQuery( + registry, + composerPullRequests.list({ ...target, input }), + { + signal, + refresh: retry, + reportFailure: false, + }, + ); + if (signal.aborted) return; + if (response._tag === "Failure") throw squashAtomCommandFailure(response); + if (response.value.errors.length) + throw new Error(response.value.errors.map((error) => error.message).join("\n")); + const pagesAtom = pullRequestListPagesFamily(pullRequestListPagesKey(target)); + const next = appendPullRequestListPage( + currentPages((atom) => registry.get(atom), target) ?? pages, + response.value, + input, + ); + registry.set(pagesAtom, next); + if ( + input.cursors && + Object.keys(next.nextCursors).length && + JSON.stringify(Object.entries(input.cursors).sort()) === + JSON.stringify(Object.entries(next.nextCursors).sort()) + ) + throw new Error("Couldn't advance the list. Try again."); + }), + ); + return requestErrors(responses); +} + +async function refreshHeads( + registry: AtomRegistry.AtomRegistry, + targets: readonly ListTarget[], + signal: AbortSignal, +) { + const responses = await Promise.allSettled( + targets.map(async (target) => { + await composerPullRequests.invalidate.run(registry, { + environmentId: target.environmentId, + input: {}, + }); + if (signal.aborted) return; + const response = await executeAtomQuery(registry, composerPullRequests.list(target), { + refresh: true, + signal, + reportFailure: false, + }); + if (signal.aborted) return; + if (response._tag === "Failure") throw squashAtomCommandFailure(response); + registry.set( + pullRequestListPagesFamily(pullRequestListPagesKey(target)), + pullRequestListHead(null, response.value), + ); + }), + ); + return requestErrors(responses); +} + +// One load of each kind per list scope. The atoms are not kept alive, so leaving a scope (a +// filter change, unmount) interrupts its load and aborts the requests. +const pageLoadFamily = Atom.family((scopeKey: string) => + Atom.fn((retry: boolean, get) => + Effect.promise((signal) => loadNextPages(get.registry, JSON.parse(scopeKey), retry, signal)), + ), +); +const refreshLoadFamily = Atom.family((scopeKey: string) => + Atom.fn((_: void, get) => + Effect.promise((signal) => refreshHeads(get.registry, JSON.parse(scopeKey), signal)), + ), +); + +// 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; + export function usePullRequestList(targets: readonly ListTarget[]) { const registry = use(RegistryContext); const scopeKey = JSON.stringify(targets); - const activeScope = useRef(scopeKey); const sources = useAtomValue(sourcesFamily(scopeKey)); - const requests = useRef<{ page?: AbortController; refresh?: AbortController }>({}); - const [loading, setLoading] = useState({ - key: scopeKey, - page: false, - refresh: false, - error: null as string | null, + const pageAtom = pageLoadFamily(scopeKey); + const refreshAtom = refreshLoadFamily(scopeKey); + const page = useAtomValue(pageAtom); + const refresh = useAtomValue(refreshAtom); + // 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); }); - const state = loading.key === scopeKey ? loading : { page: false, refresh: false, error: null }; - useLayoutEffect(() => { - activeScope.current = scopeKey; - return () => { - requests.current.page?.abort(); - requests.current.refresh?.abort(); - requests.current = {}; - }; - }, [scopeKey, activeScope]); - useEffect(() => { - for (const { target, pages } of sources) - if (pages) registry.set(pagesFamily(JSON.stringify(target)), pages); - }, [registry, sources]); - const loadMore = useCallback( - async (retry = false) => { - if (requests.current.page || requests.current.refresh || (!retry && state.error)) return; - const continuations = targets.flatMap((target) => { - const pagesAtom = pagesFamily(JSON.stringify(target)); - const pages = registry.get(pagesAtom); - const input = pages && nextPullRequestListInput(pages, target.input); - return pages && input ? [{ target, pagesAtom, pages, input }] : []; - }); - if (!continuations.length) return; - const controller = new AbortController(); - requests.current.page = controller; - setLoading({ key: scopeKey, page: true, refresh: false, error: null }); - const responses = await Promise.allSettled( - continuations.map(async ({ target, pagesAtom, pages, input }) => { - const response = await executeAtomQuery( - registry, - composerPullRequests.list({ ...target, input }), - { - signal: controller.signal, - refresh: retry, - reportFailure: false, - }, - ); - if (controller.signal.aborted || activeScope.current !== scopeKey) return; - if (response._tag === "Failure") throw squashAtomCommandFailure(response); - if (response.value.errors.length) - throw new Error(response.value.errors.map((error) => error.message).join("\n")); - const next = appendPullRequestListPage( - registry.get(pagesAtom) ?? pages, - response.value, - input, - ); - registry.set(pagesAtom, next); - if ( - input.cursors && - Object.keys(next.nextCursors).length && - JSON.stringify(Object.entries(input.cursors).sort()) === - JSON.stringify(Object.entries(next.nextCursors).sort()) - ) - throw new Error("Couldn't advance the list. Try again."); - }), - ); - if (requests.current.page === controller) delete requests.current.page; - if (!controller.signal.aborted && activeScope.current === scopeKey) - setLoading({ key: scopeKey, page: false, refresh: false, error: requestErrors(responses) }); - }, - [targets, registry, scopeKey, state.error, activeScope], - ); + // 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) => { + 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 = async () => { - if (!targets.length || requests.current.refresh) return; - requests.current.page?.abort(); - delete requests.current.page; - const controller = new AbortController(); - requests.current.refresh = controller; - setLoading({ key: scopeKey, page: false, refresh: true, error: null }); - const responses = await Promise.allSettled( - targets.map(async (target) => { - await composerPullRequests.invalidate.run(registry, { - environmentId: target.environmentId, - input: {}, - }); - if (controller.signal.aborted) return; - const response = await executeAtomQuery(registry, composerPullRequests.list(target), { - refresh: true, - signal: controller.signal, - reportFailure: false, - }); - if (controller.signal.aborted || activeScope.current !== scopeKey) return; - if (response._tag === "Failure") throw squashAtomCommandFailure(response); - registry.set( - pagesFamily(JSON.stringify(target)), - pullRequestListHead(null, response.value), - ); - }), - ); - if (requests.current.refresh === controller) delete requests.current.refresh; - if (!controller.signal.aborted && activeScope.current === scopeKey) - setLoading({ key: scopeKey, page: false, refresh: false, error: requestErrors(responses) }); + const onRefresh = () => { + if (!targets.length || registry.get(refreshAtom).waiting) return; + registry.set(pageAtom, Atom.Reset); + registry.set(refreshAtom, undefined); }; const data = useMemo( @@ -163,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; @@ -180,29 +224,34 @@ 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 + // without it, so a cached answer shown offline does not unmount the query that holds it. + const withoutCachedOverlay = new Map( + data.entries.map((row) => [scopedPullRequestListKey(row), row]), + ); const statsKey = JSON.stringify( targets.flatMap(({ environmentId }) => { const refs = visibleRows - .filter((row) => row.environmentId === environmentId && row.additions + row.deletions === 0) + .filter((row) => { + const base = withoutCachedOverlay.get(scopedPullRequestListKey(row)) ?? row; + return row.environmentId === environmentId && base.additions + base.deletions === 0; + }) .map(({ projectId, host, repository, number }) => ({ projectId, host, @@ -223,37 +272,34 @@ export function usePullRequestList(targets: readonly ListTarget[]) { ); }, [statsKey]); const counts = useAtomValue(statsAtom); - useEffect(() => { - for (const { environmentId, result } of counts) { - if (result._tag !== "Success" || result.waiting) continue; - registry.update(statsFamily(environmentId), (previous) => { - const next = new Map(previous); - for (const row of result.value.stats) next.set(statKey(row), row); - return next; - }); - } - }, [registry, counts]); - - const updateEntry = useCallback( - (entry: EnvironmentPullRequestListEntry, patch: Partial) => { - const target = targets.find((target) => target.environmentId === entry.environmentId); - if (!target) return; - registry.update(pagesFamily(JSON.stringify(target)), (previous) => - previous - ? { - ...previous, - entries: previous.entries.map((row) => - pullRequestListKey(row) === pullRequestListKey(entry) - ? { ...row, ...patch, observedAt: Date.now() } - : row, - ), - } - : previous, - ); - registry.refresh(composerPullRequests.list(target)); - }, - [registry, targets], + // Server answers land in pullRequestListStatsFamily; these cover a cached answer, which is + // 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, shown.answers), + [data.entries, shown.answers], ); + + 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 }) => { @@ -269,10 +315,13 @@ export function usePullRequestList(targets: readonly ListTarget[]) { }), [sources], ); - const listing = useMemo(() => ({ ...data, errors: [...data.errors, ...errors] }), [data, errors]); + const listing = useMemo( + () => ({ ...data, entries, errors: [...data.errors, ...errors] }), + [data, entries, errors], + ); return { data: listing, - entries: data.entries, + entries, error: errors.map((error) => error.message).join("\n") || null, isPending: sources.some(({ result }) => result.waiting) && !data.entries.length, continued: data.continued, @@ -280,9 +329,9 @@ export function usePullRequestList(targets: readonly ListTarget[]) { onRefresh, onViewableItemsChanged, updateEntry, - refreshing: state.refresh, - loadingMore: state.page, - loadMoreError: state.error, + refreshing: refresh.waiting, + loadingMore: page.waiting, + loadMoreError: loadError, hasMore: sources.some( ({ pages, target }) => pages && nextPullRequestListInput(pages, target.input), ), @@ -304,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 01596bd623447..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( @@ -219,23 +213,27 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { }} > {snippetTokens - ? snippetTokens.map((line, lineIndex) => ( - - {lineIndex > 0 ? "\n" : ""} - {line.map((token, tokenIndex) => ( - - {token.content} - - ))} - - )) + ? snippetTokens.map( + (line, lineIndex) => ( + /* oxlint-disable react/no-array-index-key -- Highlight tokens are stateless positions within this immutable snippet. */ + {lineIndex > 0 ? "\n" : ""} + {line.map((token, tokenIndex) => ( + + {token.content} + + ))} + + ) /* oxlint-enable react/no-array-index-key */, + ) : props.comment.diff.trim()} diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index 8e2e5f185e139..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"; @@ -30,6 +30,8 @@ import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRenderin import { highlightReviewSelectedLines } from "./highlightReviewSelectedLines"; const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; +const NO_LINES: ReturnType = []; +const NO_HIGHLIGHTS: ReturnType = {}; type ReviewCommentComposerSheetProps = StaticScreenProps<{ readonly environmentId: EnvironmentId; @@ -49,21 +51,18 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp const [attachments, setAttachments] = useState>([]); const [previewFile, setPreviewFile] = useState(null); - const selectedLines = useMemo( - () => (target ? getSelectedReviewCommentLines(target) : []), - [target], - ); - const highlightedLinesById = useMemo( - () => - target - ? highlightReviewSelectedLines({ - filePath: target.filePath, - lines: selectedLines, - theme: selectedTheme, - }) - : {}, - [selectedLines, selectedTheme, target], - ); + const { selectedLines, highlightedLinesById } = useMemo(() => { + if (!target) return { selectedLines: NO_LINES, highlightedLinesById: NO_HIGHLIGHTS }; + const lines = getSelectedReviewCommentLines(target); + return { + selectedLines: lines, + highlightedLinesById: highlightReviewSelectedLines({ + filePath: target.filePath, + lines, + theme: selectedTheme, + }), + }; + }, [selectedTheme, target]); const firstLine = selectedLines[0] ?? null; const lastLine = selectedLines[selectedLines.length - 1] ?? null; const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; @@ -83,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 { @@ -113,7 +112,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp } } - const handleSubmit = useCallback(() => { + const handleSubmit = () => { if (!target || !environmentId || !threadId || commentText.trim().length === 0) { return; } @@ -126,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 6e864e2f3f5da..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,9 +444,6 @@ export function ReviewSheet(props: ReviewSheetProps) { const bottomContentInset = Platform.OS === "ios" ? safeArea.bottom + 8 : Math.max(insets.bottom, 18) + 18; - useEffect(() => { - showAuxiliaryPane("inspector"); - }, [environmentId, showAuxiliaryPane, threadId]); const { error, reviewSections, @@ -461,7 +451,6 @@ export function ReviewSheet(props: ReviewSheetProps) { refreshSelectedSection, selectSection, isSelectedSectionPending, - diffPreviewRevision, } = useReviewSections({ enabled: isEnvironmentReady, environmentId, @@ -485,7 +474,6 @@ export function ReviewSheet(props: ReviewSheetProps) { environmentId, cwd: selectedThreadCwd, selectedSection, - revision: diffPreviewRevision, draftMessage, }); // Expo may return null before registration or in older binaries; use the fallback. @@ -721,6 +709,7 @@ export function ReviewSheet(props: ReviewSheetProps) { > {listHeader} + {/* oxlint-disable-next-line react/static-components -- The resolver returns a cached native component with stable identity. */} { expect(comment?.sectionTitle).toBe("Changes > 5"); }); }); + +describe("review comment selection store", () => { + const changes = { id: "git:branch-range", content: "hash-1" }; + const scope = reviewCommentSelectionScope(1, "env:thread", changes)!; + const otherScope = reviewCommentSelectionScope(1, "env:thread", { + id: "turn:2", + content: "diff", + })!; + const target = makeTarget(); + const pending = { + sectionId: target.sectionId, + sectionTitle: target.sectionTitle, + filePath: target.filePath, + lines: target.lines, + lineIndex: 0, + rowId: "row-1", + }; + + it("keeps a long-pressed range start when replacing an earlier target", () => { + setReviewCommentTarget(scope, target); + startReviewCommentRange(scope, pending); + + expect(getReviewCommentSelection()).toEqual({ scope, target: null, pending }); + }); + + it("keeps the range start after a same-line range end and drops it on a plain tap", () => { + startReviewCommentRange(scope, pending); + const sameLine = { ...target, startIndex: 0, endIndex: 0 }; + completeReviewCommentRange(scope, sameLine); + expect(getReviewCommentSelection().pending).toEqual(pending); + + setReviewCommentTarget(scope, sameLine); + expect(getReviewCommentSelection().pending).toBeNull(); + }); + + it("clears the target and the range start together", () => { + startReviewCommentRange(scope, pending); + clearReviewCommentTarget(); + + expect(getReviewCommentSelection()).toEqual({ scope: null, target: null, pending: null }); + }); + + it("reads as empty from another thread or section", () => { + setReviewCommentTarget(scope, target); + + expect(selectReviewCommentSelection(getReviewCommentSelection(), scope).target).toBe(target); + expect(selectReviewCommentSelection(getReviewCommentSelection(), otherScope).target).toBeNull(); + expect(selectReviewCommentSelection(getReviewCommentSelection(), null).target).toBeNull(); + }); + + it("reads as empty in a reopened review sheet", () => { + setReviewCommentTarget(scope, target); + const reopenedScope = reviewCommentSelectionScope(2, "env:thread", changes); + + expect( + selectReviewCommentSelection(getReviewCommentSelection(), reopenedScope).target, + ).toBeNull(); + }); + + it("reads as empty when the section disappears and comes back over a different diff", () => { + setReviewCommentTarget(scope, target); + // The working tree went clean, so the default showed; the section later returns. + const returned = reviewCommentSelectionScope(1, "env:thread", { + ...changes, + content: "hash-2", + }); + const unchanged = reviewCommentSelectionScope(1, "env:thread", { ...changes }); + + expect(selectReviewCommentSelection(getReviewCommentSelection(), returned).target).toBeNull(); + expect(selectReviewCommentSelection(getReviewCommentSelection(), unchanged).target).toBe( + target, + ); + }); + + it("drops the selection when the section changes, so switching back starts empty", () => { + setReviewCommentTarget(scope, target); + setReviewSelectedSectionId("env:thread", "turn:2"); + setReviewSelectedSectionId("env:thread", "git:branch-range"); + + expect(selectReviewCommentSelection(getReviewCommentSelection(), scope).target).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index c8917b4af00db..4e3b22407f5a0 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -36,43 +36,136 @@ export type ReviewCommentMessageSegment = readonly comment: ReviewInlineComment; }; -let currentTarget: ReviewCommentTarget | null = null; +/** The first line of a range picked with a long press, waiting for its other end. */ +export interface PendingReviewCommentRange extends Pick< + ReviewCommentTarget, + "sectionId" | "sectionTitle" | "filePath" | "lines" +> { + readonly lineIndex: number; + readonly rowId: string; +} + +/** The sheet, thread and section a selection was made in, and the diff it was made over. */ +export interface ReviewCommentSelectionScope { + readonly key: string; + /** Compared by `===`: a git section's diff hash, or a turn's diff as answered. */ + readonly content: string | null; +} + +export interface ReviewCommentSelection { + readonly scope: ReviewCommentSelectionScope | null; + readonly target: ReviewCommentTarget | null; + readonly pending: PendingReviewCommentRange | null; +} + +const EMPTY_SELECTION: ReviewCommentSelection = { scope: null, target: null, pending: null }; +let selection = EMPTY_SELECTION; const listeners = new Set<() => void>(); const REVIEW_COMMENT_BLOCK_PATTERN = /]*)>\s*([\s\S]*?)<\/review_comment>/g; const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g; const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g; -function emitChange() { +function setSelection(next: ReviewCommentSelection) { + selection = next; listeners.forEach((listener) => listener()); } -export function subscribeReviewCommentTarget(listener: () => void): () => void { +function subscribeReviewCommentSelection(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } -export function getReviewCommentTarget(): ReviewCommentTarget | null { - return currentTarget; +export function getReviewCommentSelection(): ReviewCommentSelection { + return selection; } -export function setReviewCommentTarget(target: ReviewCommentTarget | null) { - currentTarget = target; - emitChange(); +let lastReviewSheetId = 0; + +/** A fresh id per mounted review sheet, so reopening the sheet starts without a selection. */ +export function nextReviewSheetId(): number { + lastReviewSheetId += 1; + return lastReviewSheetId; +} + +export function reviewCommentSelectionScope( + sheetId: number, + threadKey: string | null, + section: { readonly id: string; readonly content: string | null } | null, +): ReviewCommentSelectionScope | null { + return threadKey === null || section === null + ? null + : { key: JSON.stringify([sheetId, threadKey, section.id]), content: section.content }; +} + +function isSameScope( + a: ReviewCommentSelectionScope | null, + b: ReviewCommentSelectionScope | null, +): boolean { + return a !== null && b !== null && a.key === b.key && a.content === b.content; +} + +export function setReviewCommentTarget( + scope: ReviewCommentSelectionScope, + target: ReviewCommentTarget, +) { + setSelection({ scope, target, pending: null }); +} + +/** + * Keeps the pending range: when both ends are the same line the target is a single line, and + * the range still waits for a different end. + */ +export function completeReviewCommentRange( + scope: ReviewCommentSelectionScope, + target: ReviewCommentTarget, +) { + setSelection({ + scope, + target, + pending: isSameScope(selection.scope, scope) ? selection.pending : null, + }); +} + +export function startReviewCommentRange( + scope: ReviewCommentSelectionScope, + pending: PendingReviewCommentRange, +) { + setSelection({ scope, target: null, pending }); } export function clearReviewCommentTarget() { - currentTarget = null; - emitChange(); + setSelection(EMPTY_SELECTION); +} + +/** A selection made in another sheet, thread or section, or over another diff, reads as empty. */ +export function selectReviewCommentSelection( + current: ReviewCommentSelection, + scope: ReviewCommentSelectionScope | null, +): ReviewCommentSelection { + return isSameScope(current.scope, scope) ? current : EMPTY_SELECTION; +} + +export function useReviewCommentSelection( + scope: ReviewCommentSelectionScope | null, +): ReviewCommentSelection { + return selectReviewCommentSelection( + useSyncExternalStore( + subscribeReviewCommentSelection, + getReviewCommentSelection, + getReviewCommentSelection, + ), + scope, + ); } export function useReviewCommentTarget(): ReviewCommentTarget | null { return useSyncExternalStore( - subscribeReviewCommentTarget, - getReviewCommentTarget, - getReviewCommentTarget, - ); + subscribeReviewCommentSelection, + getReviewCommentSelection, + getReviewCommentSelection, + ).target; } export function getSelectedReviewCommentLines( @@ -85,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/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 39d27de3d6d05..ef366dc33d409 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -1,6 +1,36 @@ import { describe, expect, it } from "vite-plus/test"; -import { applyReviewDiffMetadata, buildReviewParsedDiff } from "./reviewModel"; +import { + applyReviewDiffMetadata, + buildReviewParsedDiff, + resolveReviewSelectedSectionId, + type ReviewSectionItem, +} from "./reviewModel"; + +function makeSection(id: string): ReviewSectionItem { + return { + id, + kind: id.startsWith("turn:") ? "turn" : "branch-range", + title: id, + subtitle: null, + diff: null, + isLoading: false, + }; +} + +describe("resolveReviewSelectedSectionId", () => { + const turn = makeSection("turn:2"); + const changes = makeSection("git:branch-range"); + + it("shows Changes before anything is picked, not the first section", () => { + expect(resolveReviewSelectedSectionId([turn, changes], null)).toBe("git:branch-range"); + }); + + it("shows the default while the picked section loads and returns to the pick once it does", () => { + expect(resolveReviewSelectedSectionId([changes], "turn:2")).toBe("git:branch-range"); + expect(resolveReviewSelectedSectionId([turn, changes], "turn:2")).toBe("turn:2"); + }); +}); describe("buildReviewParsedDiff", () => { it("keeps UI identities compact, stable, and isolated from parser cache keys", () => { diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index a5b077e139a20..bc0e23b336582 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -499,11 +499,22 @@ export function getReadyReviewCheckpoints( ); } +export function reviewTurnDiffsBySectionId( + checkpoints: ReadonlyArray>, + diffByTurnCount: Readonly>, +): Readonly> { + return Object.fromEntries( + checkpoints.map((checkpoint) => [ + getReviewSectionIdForCheckpoint(checkpoint), + diffByTurnCount[checkpoint.checkpointTurnCount], + ]), + ); +} + export function buildReviewSectionItems(input: { readonly checkpoints: ReadonlyArray; readonly gitSections: ReadonlyArray; readonly turnDiffById: Readonly>; - readonly loadingTurnIds: Readonly>; readonly loadingGitSections: boolean; }): ReadonlyArray { const turnItems = getReadyReviewCheckpoints(input.checkpoints).map( @@ -515,7 +526,7 @@ export function buildReviewSectionItems(input: { title: checkpointTitle(checkpoint), subtitle: checkpointSubtitle(checkpoint), diff: input.turnDiffById[id] ?? null, - isLoading: input.loadingTurnIds[id] === true, + isLoading: false, }; }, ); @@ -556,6 +567,20 @@ export function getDefaultReviewSectionId( return (sections.find((section) => section.id === CHANGES_SECTION_ID) ?? sections[0])?.id ?? null; } +/** + * The store holds only the user's pick. While that section is missing (still loading, or the + * working tree went clean) the default shows, and the pick returns once the section does: the + * thread-feed entry relies on this, as it selects a section before the sections have loaded. + */ +export function resolveReviewSelectedSectionId( + sections: ReadonlyArray, + storedSectionId: string | null, +): string | null { + return storedSectionId !== null && sections.some((section) => section.id === storedSectionId) + ? storedSectionId + : getDefaultReviewSectionId(sections); +} + export function buildReviewParsedDiff( diff: string | null | undefined, cacheScope: string, 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/reviewState.ts b/apps/mobile/src/features/review/reviewState.ts index 4242f5525e822..55a43a64fa45a 100644 --- a/apps/mobile/src/features/review/reviewState.ts +++ b/apps/mobile/src/features/review/reviewState.ts @@ -1,57 +1,25 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; +import { clearReviewCommentTarget } from "./reviewCommentSelection"; import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; -const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze>([]); -const EMPTY_REVIEW_TURN_DIFFS = Object.freeze>>({}); -const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze>>({}); -const EMPTY_REVIEW_ASYNC_STATE = Object.freeze({ - loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS, - error: null, -}); const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze< Readonly | undefined>> >({}); -const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:review:git-sections:null"), -); -const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:review:turn-diffs:null"), -); const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:review:selected-section-id:null"), ); -const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:review:async-state:null"), -); const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( Atom.keepAlive, Atom.withLabel("mobile:review:section-file-ids:null"), ); -const reviewGitSectionsByThreadKeyAtom = Atom.family((threadKey: string) => - Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( - Atom.keepAlive, - Atom.withLabel(`mobile:review:git-sections:${threadKey}`), - ), -); - -const reviewTurnDiffByThreadKeyAtom = Atom.family((threadKey: string) => - Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( - Atom.keepAlive, - Atom.withLabel(`mobile:review:turn-diffs:${threadKey}`), - ), -); - const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) => Atom.make(null).pipe( Atom.keepAlive, @@ -59,13 +27,6 @@ const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) = ), ); -const reviewAsyncStateByThreadKeyAtom = Atom.family((threadKey: string) => - Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`mobile:review:async-state:${threadKey}`), - ), -); - const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( Atom.keepAlive, @@ -104,10 +65,7 @@ const reviewParsedDiffCacheAtom = Atom.make(() => ({ export interface ReviewCacheForThread { readonly threadKey: string | null; - readonly gitSections: ReadonlyArray; - readonly turnDiffById: Readonly>; readonly selectedSectionId: string | null; - readonly asyncState: ReviewAsyncState; readonly expandedFileIdsBySection: Readonly | undefined>>; readonly revealedLargeFileIdsBySection: Readonly< Record | undefined> @@ -115,11 +73,6 @@ export interface ReviewCacheForThread { readonly viewedFileIdsBySection: Readonly | undefined>>; } -export interface ReviewAsyncState { - readonly loadingTurnIds: Readonly>; - readonly error: string | null; -} - function buildThreadKey(input: { readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId; @@ -138,20 +91,11 @@ export function useReviewCacheForThread(input: { readonly threadId?: ThreadId; }): ReviewCacheForThread { const threadKey = buildThreadKey(input); - const gitSections = useAtomValue( - threadKey ? reviewGitSectionsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_GIT_SECTIONS_ATOM, - ); - const turnDiffById = useAtomValue( - threadKey ? reviewTurnDiffByThreadKeyAtom(threadKey) : EMPTY_REVIEW_TURN_DIFFS_ATOM, - ); const selectedSectionId = useAtomValue( threadKey ? reviewSelectedSectionIdByThreadKeyAtom(threadKey) : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM, ); - const asyncState = useAtomValue( - threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM, - ); const expandedFileIdsBySection = useAtomValue( threadKey ? reviewExpandedFileIdsByThreadKeyAtom(threadKey) @@ -168,74 +112,20 @@ export function useReviewCacheForThread(input: { return { threadKey, - gitSections, - turnDiffById, selectedSectionId, - asyncState, expandedFileIdsBySection, revealedLargeFileIdsBySection, viewedFileIdsBySection, }; } -export function setReviewGitSections( - threadKey: string, - sections: ReadonlyArray, -): void { - appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections); -} - -export function setReviewTurnDiff(threadKey: string, sectionId: string, diff: string): void { - const atom = reviewTurnDiffByThreadKeyAtom(threadKey); - const current = appAtomRegistry.get(atom); - appAtomRegistry.set(atom, { - ...current, - [sectionId]: diff, - }); -} - export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void { + // A line selection belongs to the section it was made in; switching away drops it, so + // switching back does not bring back a range over lines that may have changed. + clearReviewCommentTarget(); appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId); } -function updateReviewAsyncState( - threadKey: string, - update: (current: ReviewAsyncState) => ReviewAsyncState, -): void { - const atom = reviewAsyncStateByThreadKeyAtom(threadKey); - appAtomRegistry.set(atom, update(appAtomRegistry.get(atom))); -} - -export function setReviewTurnDiffLoading( - threadKey: string, - sectionId: string, - isLoading: boolean, -): void { - updateReviewAsyncState(threadKey, (current) => { - const loadingTurnIds = { ...current.loadingTurnIds }; - if (isLoading) { - loadingTurnIds[sectionId] = true; - } else { - delete loadingTurnIds[sectionId]; - } - return { - ...current, - loadingTurnIds, - }; - }); -} - -export function setReviewAsyncError(threadKey: string, error: string | null): void { - updateReviewAsyncState(threadKey, (current) => ({ - ...current, - error, - })); -} - -export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState { - return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey)); -} - export function updateReviewExpandedFileIds( threadKey: string, sectionId: string, 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 6c454d94a6b81..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, @@ -48,6 +48,9 @@ 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), @@ -57,6 +60,7 @@ export function useNativeReviewDiffHighlighting(input: { const previousRange = scheduler.reset(); highlightedRowIdsRef.current = new Set(); pendingPatchesRef.current = []; + // oxlint-disable-next-line react/set-state-in-effect -- Resetting the native highlighter clears the viewport and token-patch snapshots together. setViewport({ scope: viewportScope, range: null }); setTokensPatchJson(createEmptyTokenPatch(resetKey)); // A token reset need not produce another native range event. Reuse only an @@ -65,6 +69,8 @@ export function useNativeReviewDiffHighlighting(input: { return () => scheduler.cancel(); }, [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; @@ -176,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", { @@ -190,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/useReviewCommentSelectionController.ts b/apps/mobile/src/features/review/useReviewCommentSelectionController.ts index 923369a5e9ec5..4a68af28ca831 100644 --- a/apps/mobile/src/features/review/useReviewCommentSelectionController.ts +++ b/apps/mobile/src/features/review/useReviewCommentSelectionController.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useState } from "react"; import type { NativeSyntheticEvent } from "react-native"; import { useNavigation } from "@react-navigation/native"; import * as Arr from "effect/Array"; @@ -6,26 +6,22 @@ import { pipe } from "effect/Function"; import * as Result from "effect/Result"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import { buildReviewCommentTarget, clearReviewCommentTarget, + completeReviewCommentRange, formatReviewSelectedRangeLabel, getSelectedReviewCommentLines, + nextReviewSheetId, + reviewCommentSelectionScope, setReviewCommentTarget, - useReviewCommentTarget, + startReviewCommentRange, + useReviewCommentSelection, } from "./reviewCommentSelection"; -import type { - NativeReviewDiffData, - NativeReviewDiffCommentTarget, -} from "./nativeReviewDiffAdapter"; +import type { NativeReviewDiffData } from "./nativeReviewDiffAdapter"; import type { ReviewSectionItem } from "./reviewModel"; -interface PendingNativeCommentSelection extends NativeReviewDiffCommentTarget { - readonly sectionId: string; - readonly sectionTitle: string; - readonly rowId: string; -} - export function useReviewCommentSelectionController(input: { readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId; @@ -34,11 +30,24 @@ export function useReviewCommentSelectionController(input: { }) { const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input; const navigation = useNavigation(); - const activeCommentTarget = useReviewCommentTarget(); - const [pendingNativeCommentSelection, setPendingNativeCommentSelection] = - useState(null); + const [sheetId] = useState(nextReviewSheetId); + // Scoping the selection to this sheet, thread, section and diff drops it when the sheet + // reopens, either changes, or a section comes back with different lines. + const scope = reviewCommentSelectionScope( + sheetId, + environmentId && threadId ? scopedThreadKey(environmentId, threadId) : null, + selectedSection === null + ? null + : { + id: selectedSection.id, + content: selectedSection.source?.diffHash ?? selectedSection.diff, + }, + ); + const { target: activeCommentTarget, pending: pendingRange } = useReviewCommentSelection(scope); + const hasRangeTarget = + activeCommentTarget !== null && activeCommentTarget.startIndex !== activeCommentTarget.endIndex; - const openReviewCommentSheet = useCallback(() => { + const openReviewCommentSheet = () => { if (!environmentId || !threadId) { return; } @@ -47,152 +56,84 @@ export function useReviewCommentSelectionController(input: { environmentId, threadId, }); - }, [environmentId, navigation, threadId]); + }; - const selectedRowIds = useMemo(() => { - if ( - activeCommentTarget && - activeCommentTarget.sectionTitle === selectedSection?.title && - activeCommentTarget.startIndex !== activeCommentTarget.endIndex - ) { - return pipe( + const selectedRowIds = hasRangeTarget + ? pipe( getSelectedReviewCommentLines(activeCommentTarget), Arr.filterMap((line) => { const rowId = nativeReviewDiffData.rowIdByCommentLineId.get(line.id); return rowId ? Result.succeed(rowId) : Result.failVoid; }), - ); - } - - return pendingNativeCommentSelection ? [pendingNativeCommentSelection.rowId] : []; - }, [ - activeCommentTarget, - nativeReviewDiffData.rowIdByCommentLineId, - pendingNativeCommentSelection, - selectedSection?.title, - ]); + ) + : pendingRange + ? [pendingRange.rowId] + : []; - const selectionAction = useMemo(() => { - if ( - activeCommentTarget && - activeCommentTarget.sectionTitle === selectedSection?.title && - activeCommentTarget.startIndex !== activeCommentTarget.endIndex - ) { - return { + const selectionAction = hasRangeTarget + ? { title: `Comment on ${formatReviewSelectedRangeLabel(activeCommentTarget)}`, onOpenComment: openReviewCommentSheet, - }; + } + : pendingRange + ? { title: "Select range end", onOpenComment: null } + : null; + + const onPressLine = ( + event: NativeSyntheticEvent<{ + readonly rowId?: string; + readonly gesture?: "tap" | "longPress"; + }>, + ) => { + if (!selectedSection || scope === null) { + return; } - if ( - pendingNativeCommentSelection && - pendingNativeCommentSelection.sectionTitle === selectedSection?.title - ) { - return { - title: "Select range end", - onOpenComment: null, - }; + const { rowId, gesture } = event.nativeEvent; + if (!rowId) { + return; } - return null; - }, [ - activeCommentTarget, - openReviewCommentSheet, - pendingNativeCommentSelection, - selectedSection?.title, - ]); - - useEffect(() => { - clearReviewCommentTarget(); - setPendingNativeCommentSelection(null); - }, [selectedSection?.id]); - - useEffect(() => { - if (activeCommentTarget === null) { - setPendingNativeCommentSelection(null); + const target = nativeReviewDiffData.commentTargetsByRowId.get(rowId); + if (!target) { + return; } - }, [activeCommentTarget]); - - const onPressLine = useCallback( - ( - event: NativeSyntheticEvent<{ - readonly rowId?: string; - readonly gesture?: "tap" | "longPress"; - }>, - ) => { - if (!selectedSection) { - return; - } - - const { rowId, gesture } = event.nativeEvent; - if (!rowId) { - return; - } - const target = nativeReviewDiffData.commentTargetsByRowId.get(rowId); - if (!target) { - return; - } - - if (gesture === "longPress") { - clearReviewCommentTarget(); - setPendingNativeCommentSelection({ - ...target, - sectionId: selectedSection.id, - sectionTitle: selectedSection.title, - rowId, - }); - return; - } - - if ( - pendingNativeCommentSelection && - pendingNativeCommentSelection.sectionTitle === selectedSection.title && - pendingNativeCommentSelection.filePath === target.filePath - ) { - setReviewCommentTarget( - buildReviewCommentTarget( - { - sectionTitle: pendingNativeCommentSelection.sectionTitle, - sectionId: pendingNativeCommentSelection.sectionId, - filePath: pendingNativeCommentSelection.filePath, - lines: pendingNativeCommentSelection.lines, - }, - pendingNativeCommentSelection.lineIndex, - target.lineIndex, - ), - ); - return; - } - - setPendingNativeCommentSelection(null); - setReviewCommentTarget({ - sectionTitle: selectedSection.title, + if (gesture === "longPress") { + startReviewCommentRange(scope, { sectionId: selectedSection.id, + sectionTitle: selectedSection.title, filePath: target.filePath, lines: target.lines, - startIndex: target.lineIndex, - endIndex: target.lineIndex, + lineIndex: target.lineIndex, + rowId, }); - openReviewCommentSheet(); - }, - [ - nativeReviewDiffData.commentTargetsByRowId, - openReviewCommentSheet, - pendingNativeCommentSelection, - selectedSection, - ], - ); + return; + } + + if (pendingRange && pendingRange.filePath === target.filePath) { + completeReviewCommentRange( + scope, + buildReviewCommentTarget(pendingRange, pendingRange.lineIndex, target.lineIndex), + ); + return; + } - const clearSelection = useCallback(() => { - clearReviewCommentTarget(); - setPendingNativeCommentSelection(null); - }, []); + setReviewCommentTarget(scope, { + sectionTitle: selectedSection.title, + sectionId: selectedSection.id, + filePath: target.filePath, + lines: target.lines, + startIndex: target.lineIndex, + endIndex: target.lineIndex, + }); + openReviewCommentSheet(); + }; return { selectedRowIds, selectionAction, onPressLine, - clearSelection, + clearSelection: clearReviewCommentTarget, }; } diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index 7ffd694077f70..6ccc5ae3f495c 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -1,9 +1,8 @@ -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; 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([ @@ -170,6 +162,8 @@ export function useReviewDiffData(input: { environmentId, input: { cacheKey: scope, + // Newer preview revisions refresh patches read under an older one. + preview: { cwd }, request: { cwd, ...(lazySource.baseRef ? { baseRef: lazySource.baseRef } : {}), @@ -203,24 +197,6 @@ export function useReviewDiffData(input: { [queries, parsedQuery], ), ); - const previousPreview = useRef({ - scope, - revision: input.revision, - queries: [] as typeof queries, - }); - useEffect(() => { - const previous = previousPreview.current; - previousPreview.current = { scope, revision: input.revision, queries }; - for (const query of queries) { - if (!query) continue; - const changed = previous.scope === scope && previous.revision !== input.revision; - const cached = - !previous.queries.includes(query) && - registry.get(query)._tag !== "Initial" && - !registry.get(query).waiting; - if (changed || cached) registry.refresh(query); - } - }, [input.revision, queries, registry, scope]); const loadVisibleFile = useCallback( (fileId: string | null, retry = false) => { const index = @@ -254,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/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index d7c91941abc20..4fd506732e5d3 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,34 +1,36 @@ +import { useAtomValue } from "@effect/atom-react"; import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; -import { environmentSession } from "../../state/session"; -import { useCallback, useEffect, useMemo } from "react"; -import * as DateTime from "effect/DateTime"; +import { Atom } from "effect/reactivity"; -import { - deriveThreadCheckpointSummaries, - type ThreadCheckpointSummary, -} from "@t3tools/client-runtime/state/thread-checkpoints"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { deriveThreadCheckpointSummaries } from "@t3tools/client-runtime/state/thread-checkpoints"; +import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@t3tools/contracts"; import { useCheckpointDiff } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; import { useEnvironmentPresentation } from "../../state/presentation"; -import { reviewEnvironment } from "../../state/review"; +import { lastReviewGitSectionsAtom, reviewEnvironment } from "../../state/review"; +import { lastReviewTurnDiffsAtom } from "../../state/reviewTurnDiffs"; +import { environmentSession } from "../../state/session"; import { useSelectedThreadProjection } from "../../state/use-thread-detail"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { buildReviewSectionItems, - getDefaultReviewSectionId, getReadyReviewCheckpoints, getReviewSectionIdForCheckpoint, + resolveReviewSelectedSectionId, + reviewTurnDiffsBySectionId, + type ReviewSectionItem, } from "./reviewModel"; -import { - setReviewAsyncError, - setReviewGitSections, - setReviewSelectedSectionId, - setReviewTurnDiff, - setReviewTurnDiffLoading, - type ReviewCacheForThread, -} from "./reviewState"; +import { setReviewSelectedSectionId, type ReviewCacheForThread } from "./reviewState"; + +const NO_GIT_SECTIONS_ATOM = Atom.make>([]).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:review:git-sections:null"), +); +const NO_TURN_DIFFS_ATOM = Atom.make>>({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:review:turn-diffs:null"), +); export function useReviewSections(input: { readonly enabled?: boolean; @@ -59,97 +61,41 @@ export function useReviewSections(input: { }) : null, ); - const { loadingTurnIds } = reviewCache.asyncState; - - useEffect(() => { - if (reviewCache.threadKey && diffPreview.data) { - setReviewGitSections(reviewCache.threadKey, diffPreview.data.sources); - } - }, [diffPreview.data, reviewCache.threadKey]); - - const readyCheckpoints = useMemo( - () => - getReadyReviewCheckpoints( - selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection), - ), - [selectedThread], - ); - const checkpointBySectionId = useMemo( - () => - Object.fromEntries( - readyCheckpoints.map((checkpoint) => [ - getReviewSectionIdForCheckpoint(checkpoint), - checkpoint, - ]), - ) as Record, - [readyCheckpoints], + const lastGitSections = useAtomValue( + environmentId !== undefined && selectedThreadCwd !== null + ? lastReviewGitSectionsAtom(environmentId, selectedThreadCwd) + : NO_GIT_SECTIONS_ATOM, ); - const reviewSections = useMemo(() => { - const sections = buildReviewSectionItems({ - checkpoints: readyCheckpoints, - gitSections: - canReadFiles || fileAccess.isPending - ? (diffPreview.data?.sources ?? reviewCache.gitSections) - : [], - turnDiffById: reviewCache.turnDiffById, - loadingTurnIds, - loadingGitSections: fileAccess.isPending || diffPreview.isPending, - }); - // Keep the selected section while its grant loads, without displaying cached host files. - return fileAccess.isPending - ? sections.map((section) => - section.kind === "turn" ? section : { ...section, diff: null, isLoading: true }, - ) - : sections; - }, [ - canReadFiles, - diffPreview.data?.sources, - diffPreview.isPending, - fileAccess.isPending, - loadingTurnIds, - readyCheckpoints, - reviewCache.gitSections, - reviewCache.turnDiffById, - ]); - const selectedSection = useMemo( - () => - reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ?? - reviewSections[0] ?? - null, - [reviewCache.selectedSectionId, reviewSections], + + const lastTurnDiffs = useAtomValue( + environmentId !== undefined && threadId !== undefined + ? lastReviewTurnDiffsAtom(environmentId, threadId) + : NO_TURN_DIFFS_ATOM, ); - const fallbackSectionId = useMemo( - () => getDefaultReviewSectionId(reviewSections), - [reviewSections], + + const readyCheckpoints = getReadyReviewCheckpoints( + selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection), ); - const selectedSectionIdExists = useMemo( - () => - reviewCache.selectedSectionId - ? reviewSections.some((section) => section.id === reviewCache.selectedSectionId) - : false, - [reviewCache.selectedSectionId, reviewSections], + const checkpointBySectionId = new Map( + readyCheckpoints.map((checkpoint) => [getReviewSectionIdForCheckpoint(checkpoint), checkpoint]), ); - - useEffect(() => { - if ( - reviewSections.length > 0 && - reviewCache.threadKey && - (!reviewCache.selectedSectionId || !selectedSectionIdExists) - ) { - setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId); - } - }, [ - fallbackSectionId, + // Turns show their last answered diff; the active turn's live query below overrides it. + // Section ids and the selection do not depend on the live query, so they are resolved first. + const cachedSections = buildReviewSectionItems({ + checkpoints: readyCheckpoints, + gitSections: + canReadFiles || fileAccess.isPending ? (diffPreview.data?.sources ?? lastGitSections) : [], + turnDiffById: reviewTurnDiffsBySectionId(readyCheckpoints, lastTurnDiffs), + loadingGitSections: fileAccess.isPending || diffPreview.isPending, + }); + const selectedSectionId = resolveReviewSelectedSectionId( + cachedSections, reviewCache.selectedSectionId, - reviewCache.threadKey, - reviewSections.length, - selectedSectionIdExists, - ]); + ); - let activeCheckpoint = readyCheckpoints[0] ?? null; - if (selectedSection?.kind === "turn") { - activeCheckpoint = checkpointBySectionId[selectedSection.id] ?? activeCheckpoint; - } + const selectedCheckpoint = + selectedSectionId === null ? undefined : checkpointBySectionId.get(selectedSectionId); + const activeCheckpoint = selectedCheckpoint ?? readyCheckpoints[0] ?? null; const activeSectionId = activeCheckpoint ? getReviewSectionIdForCheckpoint(activeCheckpoint) : null; @@ -162,28 +108,23 @@ export function useReviewSections(input: { ignoreWhitespace: false, }); - useEffect(() => { - if (!reviewCache.threadKey || !activeSectionId) { - return; - } - setReviewTurnDiffLoading(reviewCache.threadKey, activeSectionId, activeTurnDiff.isPending); - }, [activeSectionId, activeTurnDiff.isPending, reviewCache.threadKey]); - - useEffect(() => { - if (!reviewCache.threadKey || !activeSectionId || !activeTurnDiff.data) { - return; - } - setReviewTurnDiff(reviewCache.threadKey, activeSectionId, activeTurnDiff.data.diff); - setReviewAsyncError(reviewCache.threadKey, null); - }, [activeSectionId, activeTurnDiff.data, reviewCache.threadKey]); - - useEffect(() => { - if (reviewCache.threadKey && activeTurnDiff.error) { - setReviewAsyncError(reviewCache.threadKey, activeTurnDiff.error); + const reviewSections = cachedSections.map((section): ReviewSectionItem => { + if (section.kind === "turn") { + return section.id === activeSectionId + ? { + ...section, + diff: activeTurnDiff.data?.diff ?? section.diff, + isLoading: activeTurnDiff.isPending, + } + : section; } - }, [activeTurnDiff.error, reviewCache.threadKey]); + // Keep the selected section while its grant loads, without displaying cached host files. + return fileAccess.isPending ? { ...section, diff: null, isLoading: true } : section; + }); + const selectedSection = + reviewSections.find((section) => section.id === selectedSectionId) ?? null; - const refreshSelectedSection = useCallback(async () => { + const refreshSelectedSection = async () => { if (!enabled) { return; } @@ -192,32 +133,23 @@ export function useReviewSections(input: { return; } diffPreview.refresh(); - }, [activeTurnDiff, diffPreview, enabled, selectedSection?.kind]); + }; - const selectSection = useCallback( - (sectionId: string) => { - if (reviewCache.threadKey) { - setReviewSelectedSectionId(reviewCache.threadKey, sectionId); - } - }, - [reviewCache.threadKey], - ); + const selectSection = (sectionId: string) => { + if (reviewCache.threadKey) { + setReviewSelectedSectionId(reviewCache.threadKey, sectionId); + } + }; return { error: diffPreview.error ?? activeTurnDiff.error ?? - reviewCache.asyncState.error ?? (selectedSection === null && !fileAccess.isPending && !canReadFiles ? (fileAccess.error ?? "This connection cannot read local diffs.") : null), isSelectedSectionPending: selectedSection?.kind === "turn" ? activeTurnDiff.isPending : diffPreview.isPending, - loadingGitDiffs: fileAccess.isPending || diffPreview.isPending, - diffPreviewRevision: diffPreview.data - ? DateTime.formatIso(diffPreview.data.generatedAt) - : undefined, - loadingTurnIds, reviewSections, selectedSection, refreshSelectedSection, 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 ? ( + ("idle"); + const [checkState, setCheckState] = useState<{ + readonly state: AppUpdateCheckState; + // "Up to date" is a confirmation that fades back to idle. + readonly settledUntil: number | null; + }>({ state: "idle", settledUntil: null }); + const confirmationExpired = useDeadlinePassed(checkState.settledUntil); + const updateState = + checkState.state === "current" && confirmationExpired ? "idle" : checkState.state; const updateInFlight = useRef(false); const hiddenUpdateTapCount = useRef(0); @@ -50,12 +58,6 @@ function AppSettingsSection() { const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; - useEffect(() => { - if (updateState !== "current") return; - const timer = setTimeout(() => setUpdateState("idle"), 3000); - return () => clearTimeout(timer); - }, [updateState]); - async function checkForUpdate() { // disabled updates next render; the ref blocks same-frame double taps. if (updateInFlight.current) return; @@ -65,7 +67,11 @@ function AppSettingsSection() { runAppUpdateCheck({ applyMode: "immediate", onFailure: (message) => Dialog.alert("Update failed", message), - onStateChange: setUpdateState, + onStateChange: (state) => + setCheckState({ + state, + settledUntil: state === "current" ? Date.now() + 3000 : null, + }), }), ); updateInFlight.current = false; diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 2da8e38e9d116..8045fb3dd3d4e 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -1,3 +1,5 @@ +import { Platform } from "react-native"; +import { AndroidNavigationAppearanceSection } from "./appearance/sections/AndroidNavigationAppearanceSection"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -22,6 +24,7 @@ export function SettingsAppearanceRouteScreen() { }} > + {Platform.OS === "android" ? : null} diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index ff806c53ec036..276f51fcb3891 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -5,7 +5,7 @@ import { UserProfileView, } from "@clerk/expo/native-components"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useLayoutEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; @@ -20,15 +20,8 @@ const USER_PROFILE_CUSTOM_PAGES = [ }, ] satisfies UserProfileCustomPage[]; +// Without cloud config the app has no entry point here and appLinking drops the deep link. export function SettingsAuthRouteScreen() { - const navigation = useNavigation(); - - useLayoutEffect(() => { - if (!hasCloudPublicConfig()) { - navigation.goBack(); - } - }, [navigation]); - return hasCloudPublicConfig() ? : null; } @@ -41,6 +34,9 @@ function ConfiguredSettingsAuthRouteScreen() { const [hasBeenSignedIn, setHasBeenSignedIn] = useState(isSignedIn === true); if (isSignedIn && !hasBeenSignedIn) setHasBeenSignedIn(true); + // 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 6959e1e74db3a..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,51 +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(() => { - void refreshNotifications(); - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") void refreshNotifications(); - }); - return () => subscription.remove(); - }, [refreshNotifications]); - - useEffect(() => { - if (!isLoaded) { - 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 997e1683f07ac..5eecd33e3837d 100644 --- a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx @@ -12,7 +12,7 @@ import { import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -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, @@ -58,15 +58,16 @@ export function SettingsProjectOverviewRouteScreen() { setIsRemoving(false); }; const navigation = useNavigation(); - const { selectedTargets, projectGroups, selectedProjectKey, selectProject } = - useSettingsEnvironmentFilter(); - const latestProjectKey = useRef(selectedProjectKey); - useEffect(() => { - latestProjectKey.current = selectedProjectKey; - }, [selectedProjectKey]); + const { + selectedTargets, + projectGroups, + selectedProjectKey, + selectProject, + getSelectedProjectKey, + } = useSettingsEnvironmentFilter(); // The filter can move to another project while a removal is still running. const finishProjectRemoval = (projectKey: string | null) => { - if (latestProjectKey.current !== projectKey) return; + if (getSelectedProjectKey() !== projectKey) return; if (navigation.isFocused()) leaveSettingsPage(navigation, workspaceHasColumns(), DEFAULT_SETTINGS_PAGE); // Otherwise the filter keeps pointing at the removed project as an unavailable one. @@ -82,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 99d3fa658cf94..583ab61cf7d2f 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -32,7 +32,7 @@ import { WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; -import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { AppState, Platform, Pressable, TextInput as RNTextInput, View } from "react-native"; @@ -78,7 +78,11 @@ import { } from "./scheduledTaskDraft"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; import { useScheduledTaskEditor } from "./scheduled-task-editor"; -import { scheduledTaskEditorSessionAtom } from "./scheduled-task-editor-state"; +import { + discardScheduledTaskEditor, + markScheduledTaskEditorSaved, + scheduledTaskEditorSessionAtom, +} from "./scheduled-task-editor-state"; import { appAtomRegistry } from "../../state/atom-registry"; import { formatNextScheduledTaskRun, @@ -182,7 +186,11 @@ function SelectRow(props: { const value = ( {props.valueIcon} - + {props.value} @@ -196,7 +204,9 @@ function SelectRow(props: { : "min-h-14 flex-row items-center gap-3 px-4 py-3" } > - {props.label} + + {props.label} + {value} ); @@ -215,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} @@ -326,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} @@ -449,15 +469,9 @@ export function SettingsScheduledTasksRouteScreen() { ); } +// The "New task" action and takeScheduledTaskNewLink clear the editor before +// this screen opens, so it starts from the default target. export function SettingsScheduledTaskNewRouteScreen() { - const { resetEditor } = useScheduledTaskEditor(); - const { availableTargets } = useSettingsEnvironmentFilter(); - const initialized = useRef(false); - useLayoutEffect(() => { - if (initialized.current || availableTargets.length === 0) return; - initialized.current = true; - resetEditor(); - }, [availableTargets.length, resetEditor]); return ; } @@ -466,20 +480,14 @@ export function SettingsScheduledTaskEditRouteScreen() { } function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }) { - const { - editor, - voiceOwnerKey, - readEditor, - setEditor, - startEditor, - hasChanges, - draftForEnvironment, - } = useScheduledTaskEditor(); + const { editor, voiceOwnerKey, readEditor, setEditor, hasChanges, draftForEnvironment } = + useScheduledTaskEditor(); const { availableTargets } = useSettingsEnvironmentFilter(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); + // Set once the save lands, so the removal guard lets the editor close. + const saved = useRef(false); const ownerKey = editor ? `${voiceOwnerKey}:${editor.environmentId}` : null; const prompt = editor?.draft.prompt ?? ""; const [selectionState, setSelectionState] = useState<{ @@ -518,8 +526,12 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } }, disabled: saving, }); - const preventRemove = !saved && (hasChanges || saving); + const preventRemove = hasChanges || saving; usePreventRemove(preventRemove, ({ data }) => { + if (saved.current) { + navigation.dispatch(data.action); + return; + } if (saving) { Dialog.alert("Saving task", "Wait for the task to finish saving before leaving."); return; @@ -531,19 +543,12 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } style: "destructive", onPress: () => { if (voiceInput.isBusy) voiceInput.cancel(); - startEditor(null); + discardScheduledTaskEditor(); navigation.dispatch(data.action); }, }, ]); }); - useEffect(() => { - if (!saved) return; - const frame = requestAnimationFrame(() => { - if (navigation.isFocused()) navigation.goBack(); - }); - return () => cancelAnimationFrame(frame); - }, [navigation, saved]); return ( {Platform.OS === "ios" ? ( @@ -599,7 +604,11 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } setDraft={(draft) => { if (!saving) setEditor({ ...editor, draft }); }} - onSaved={() => setSaved(true)} + onSaved={() => { + saved.current = true; + markScheduledTaskEditorSaved(); + if (navigation.isFocused()) navigation.goBack(); + }} onChangeEnvironment={(target) => { if (target.environmentId === editor.environmentId) return; setEditor({ @@ -942,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"} @@ -1317,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 59% rename from apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx rename to apps/mobile/src/features/settings/ThreadSettingsSections.tsx index c7883989ddb15..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,68 +97,23 @@ 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 ? ( writeToAll({ autoResumeLimitedThreads: value })} /> writeToAll({ snoozeLimitedThreads: value })} @@ -222,7 +149,7 @@ function AutoSettleSettingsRows() { ) : null} - {pendingWrites === 0 && mismatches.length > 0 ? ( + {!props.pending && mismatches.length > 0 ? ( Auto-settle defaults differ @@ -242,15 +169,15 @@ function AutoSettleSettingsRows() { ); } -function BetaSettingsSection() { +export function WorkingSectionSettings() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const preferences = useAtomValue(mobilePreferencesAtom); const workingShelfEnabled = - AsyncResult.isSuccess(preferences) && preferences.value.workingShelfEnabled === true; + AsyncResult.isSuccess(preferences) && preferences.value.workingShelfEnabled !== false; 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 ( ; +} + interface SettingsLegalDocumentRouteScreenProps { readonly documentName: string; readonly documentUrl: string; @@ -48,28 +53,26 @@ export function SettingsLegalDocumentRouteScreen({ documentName, documentUrl, }: SettingsLegalDocumentRouteScreenProps) { - const navigation = useNavigation>(); const [reloadKey, setReloadKey] = useState(0); const [loadProgress, setLoadProgress] = useState(0); const [loadError, setLoadError] = useState(null); const [externalUrl, setExternalUrl] = useState(documentUrl); - const renderExternalHeaderButton = useCallback( - () => , - [externalUrl], + const headerOptions = ( + ); - useLayoutEffect(() => { - navigation.setOptions({ - headerRight: renderExternalHeaderButton, - }); - }, [navigation, renderExternalHeaderButton]); - - const openExternalUrl = useCallback((url: string) => { + const openExternalUrl = (url: string) => { void Linking.openURL(url).catch(() => undefined); - }, []); + }; if (loadError) { return ( + {headerOptions} + {headerOptions} {loadProgress > 0 && loadProgress < 1 ? : null} void; @@ -18,7 +17,7 @@ export function SettingsProjectOverridesSection(props: { {props.projectLabel} - {!props.pending && props.supportsOverrides && props.hasOverrides ? ( + {!props.pending && props.hasOverrides ? (