diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 06fe9867b15ae..6fde6fff30984 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -21,6 +21,19 @@ Test with meaningful project and thread data. Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) only when inspecting or seeding SQLite. Stop the test server before direct fixture writes. +## Drive agent turns for free + +Do not spend a paid model to see a turn render. `vp run dev` registers the +`Replay (dev)` provider, which replays recorded turns with simulated streaming +through the real adapter and orchestration. Its models are scenarios: +`showcase` covers thinking, a plan, tool calls, an approval, and markdown. +Press `Ctrl+Alt+Shift+R` for a picker (GNOME takes that key for screen +recording), call `window.t3dev.replay("showcase")` +with `preview_evaluate`, or run +`node apps/server/src/bin.ts thread start --base-dir .t3 --provider devReplay --model showcase "go"`. +When the test needs real provider behavior, use that provider's cheapest +model, such as Haiku for Claude or Luna for Codex. + ## Use the Browser panel Call `preview_status`, then `preview_open` if the Browser panel is diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index f22a4010df94f..0b7d0182e6df7 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -27,7 +27,7 @@ export default defineConfig((ctx) => ({ instructions: "Hold changed code to the rules in docs/internals/web-ui.md.", }, { - path: "**/*.test.{ts,tsx}", + path: "**/*.test.*", instructions: "Hold changed tests to the rules in docs/internals/testing.md. Request tests only as those rules allow.", }, diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e0ef427bd1d36..afaef17e1c310 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 }} @@ -40,36 +49,66 @@ runs: - name: Setup Vite+ if: steps.vp_cache.outputs.cache-hit != 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 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" + 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 }}- - 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 + + # A filtered install that started from the current full store adds nothing + # worth a second copy of it. + - name: Save pnpm store + if: >- + inputs.install == 'true' && + steps.pnpm_cache.outputs.cache-hit != 'true' && + (steps.pnpm_store.outputs.namespace == 'full' || + steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.path }} + key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} - name: Enable shared task cache if: >- diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 13ca38fed4ad6..78a0156d1eaa1 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -11,8 +11,8 @@ provided alongside this playbook. Everything machine-specific lives there, not h ## 1. Ask what went wrong -Your first message to the user: ask them to describe what went wrong, in their own -words. Ask them to paste screenshots directly into this session if they have any. +Unless the user already described it, your first message asks what went wrong, in +their own words. Ask them to paste screenshots directly into this session if they have any. Ask follow-up questions when the description is vague. Good repro steps are the most valuable thing you can extract from this conversation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc3b79002cb7e..e4c45728c2342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,16 +225,6 @@ jobs: !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present), - # but ACP process-tree live tests compile a small pthread fixture with `cc` - # and soft-skip when it is missing. Install build-essential so that path - # always runs in CI instead of silently no-oping. - - name: Install C toolchain for process-tree fixtures - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends build-essential - command -v cc - - uses: ./.github/actions/setup-apt-mirrors # The desktop tests compile the browser secret helper. The install runs in @@ -364,7 +354,9 @@ jobs: id-token: write name: Test Server ${{ matrix.shard }} runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 10 + # Shards take 2-5 minutes. When the shared task cache throttles reads, each + # failed fetch stalls vp for 15-90s, which pushed shards past 10 minutes. + timeout-minutes: 20 strategy: fail-fast: false matrix: diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index ff68c19caab5a..5db7a86eb98c9 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -80,6 +80,7 @@ jobs: CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }} + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} - name: Publish relay deploy commit status uses: actions/github-script@v8 diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml index 53a71110cef1f..fdce22c68a59e 100644 --- a/.github/workflows/relay-preview.yml +++ b/.github/workflows/relay-preview.yml @@ -79,3 +79,5 @@ jobs: PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + # Unset by default, so previews keep the T3 Chat link off. + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index bff1b125be44b..3383bb0e80bb8 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -147,13 +147,6 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -163,10 +156,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install desktop dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - name: Cache resource monitor id: resource_monitor_cache @@ -259,6 +254,24 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + # tsdown downloads the target Node with one fetch and no retry, and + # nodejs.org drops that download often enough to fail releases. Seed + # tsdown's cache with a retried, checksum-verified copy instead. + - name: Download target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + run: | + set -euo pipefail + version="$(node -p 'require("./package.json").engines.node')" + name="node-v$version-win-${{ inputs.arch }}" + dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$version/win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + mkdir -p "$dir" "$dest" + curl -fsSL --retry 5 --retry-all-errors -o "$dir/$name.zip" "https://nodejs.org/dist/v$version/$name.zip" + curl -fsSL --retry 5 --retry-all-errors -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$version/SHASUMS256.txt" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe" + # Setup Vite+ selects the exact engines.node version from package.json. # Native builds embed that executable; cross-target builds download the # same version for their target. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33076d208f3a4..e8f300242f054 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -420,14 +420,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3code-relay... + install-args: --filter=t3code-relay... # The deployed stack's outputs, read from Alchemy's state store without # planning or applying. Select the backend explicitly to avoid loading @@ -522,13 +518,6 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -538,10 +527,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install bundle dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -781,14 +772,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Download all desktop artifacts uses: actions/download-artifact@v8 @@ -977,15 +964,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/scripts... + install-args: --filter=t3... --filter=@t3tools/scripts... - name: Build the npm launcher run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages @@ -1041,15 +1023,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/web... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/web... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -1145,11 +1122,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Alias channel shell: bash @@ -1216,14 +1191,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/marketing... + install-args: --filter=@t3tools/marketing... - id: deploy name: Build staged deployment @@ -1287,11 +1258,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Promote marketing site shell: bash @@ -1346,15 +1315,10 @@ jobs: echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/oxlint-plugin-t3code... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/oxlint-plugin-t3code... - id: update_versions name: Update version strings @@ -1411,14 +1375,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Read published release notes if: needs.preflight.outputs.is_prerelease == 'true' diff --git a/.gitignore b/.gitignore index b33f3469c5345..b6b793e020ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ apps/mobile/.android-cache/ /.generated/ artifacts/app-store/screenshots/ artifacts/mobile-e2e/ +artifacts/mobile-behavior/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index f7cef3929ab10..f005f899595f5 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -1,7 +1,7 @@ --- title: Effect Service Conventions -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code diff --git a/.macroscope/check-run-agents/test-signal.md b/.macroscope/check-run-agents/test-signal.md new file mode 100644 index 0000000000000..5c5319aafb739 --- /dev/null +++ b/.macroscope/check-run-agents/test-signal.md @@ -0,0 +1,27 @@ +--- +title: Test Signal +model: gpt-6-1-terra +reasoning: low +input: incremental +tools: + - browse_code + - modify_pr +# An explicit include overrides ignore.md, which skips test files for every other check. +include: + - "**/*.test.*" +exclude: + - ".repos/**" +requires: + - Check +maxBudgetPerRun: 1 +maxBudgetPerPR: 5 +conclusion: neutral +--- + +# Test signal review + +Review only the tests this pull request adds or grows. Tests it moves, renames, or deletes are not findings. The rules are in `docs/internals/testing.md` under `## What to test`; read that section first and hold new tests to it. + +Post each finding as a brief inline comment on the new test with one fix. Ask only for fewer or smaller tests, never for more coverage. + +When there are no findings, make the entire final response exactly `All clear` on one line with nothing else. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index ab37120ff344f..047dafbdd4be1 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,7 +1,7 @@ --- title: UI Consistency -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code diff --git a/AGENTS.md b/AGENTS.md index f990c3d36b706..e8eb6fd49b883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,11 +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. @@ -135,6 +137,8 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. +- Real agent turns cost money. To watch a turn stream through the pipeline and UI, use the free `Replay (dev)` provider that `vp run dev` registers; [test-t3-app](.agents/skills/test-t3-app/SKILL.md) shows how to start one. Use a real provider only when the change depends on that provider's behavior, and then pick its cheapest model, such as Haiku for Claude or Luna for Codex. + For authorized mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. ## Evals 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/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index ca69f5d025899..492903139547b 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -74,7 +74,6 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_NO_BROWSER", "T3CODE_HOST", "T3CODE_DESKTOP_WS_URL", - "T3CODE_DESKTOP_LAN_ACCESS", "T3CODE_DESKTOP_LAN_HOST", "T3CODE_DESKTOP_HTTPS_ENDPOINTS", "T3CODE_TAILSCALE_SERVE", @@ -86,6 +85,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", "OTEL_SDK_DISABLED", diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 459605e7cf3fb..8c99031a4458d 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -5,11 +5,7 @@ import * as DesktopIpc from "./DesktopIpc.ts"; import { applyFnKeySetup, readFnKeySetup, setDictationKeyEnabled } from "./methods/dictationKey.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, @@ -27,7 +23,6 @@ import { ensureSshEnvironment, fetchSshEnvironmentDescriptor, fetchSshSessionState, - issueSshWebSocketTicket, resolveSshHost, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; @@ -128,7 +123,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); @@ -137,7 +131,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 947c36a44cef2..410b330782f11 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -63,7 +63,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"; @@ -71,7 +70,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"; @@ -118,7 +116,6 @@ export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-fra export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; -export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts index 4e51496a6379a..0f913d0f300a3 100644 --- a/apps/desktop/src/ipc/methods/connectionCatalog.ts +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -25,13 +25,3 @@ export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ return yield* store.set(catalog); }), }); - -export const clearConnectionCatalog = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, - payload: Schema.Void, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () { - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - yield* store.clear; - }), -}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index c6c2cd8fbdf9a..20b7bb4877d59 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -47,9 +47,6 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeStateChanges((tabId, state) => electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), ); - yield* manager.subscribeRecordingFrames((frame) => - electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), - ); yield* manager.subscribeRecordingInputs((event) => electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, event), ); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 099b97ce7bf31..15e04f71282b1 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -1,7 +1,6 @@ import { bootstrapRemoteBearerSession, fetchRemoteSessionState, - issueRemoteWebSocketTicket, RemoteEnvironmentAuthUndeclaredStatusError, type RemoteEnvironmentAuthError, } from "@t3tools/client-runtime/authorization"; @@ -24,7 +23,6 @@ import { EnvironmentScopeRequiredError, AuthAccessTokenResult, AuthSessionState, - AuthWebSocketTicketResult, } from "@t3tools/contracts"; import { SshHttpBridgeError } from "@t3tools/ssh/errors"; import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; @@ -40,8 +38,7 @@ import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts. type DesktopSshEnvironmentRequestOperation = | "fetch-environment-descriptor" | "bootstrap-bearer-session" - | "fetch-session-state" - | "issue-websocket-ticket"; + | "fetch-session-state"; type DesktopSshEnvironmentRequestCause = RemoteEnvironmentAuthError | SshHttpBridgeError; @@ -203,23 +200,6 @@ export const fetchSshSessionState = DesktopIpc.makeIpcMethod({ }), }); -export const issueSshWebSocketTicket = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - payload: DesktopSshBearerRequestInputSchema, - result: AuthWebSocketTicketResult, - handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({ - httpBaseUrl, - bearerToken, - }) { - return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) => - issueRemoteWebSocketTicket({ - httpBaseUrl: resolvedHttpBaseUrl, - bearerToken, - }), - )(httpBaseUrl); - }), -}); - export const resolveSshPasswordPrompt = DesktopIpc.makeIpcMethod({ channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, payload: DesktopSshPasswordPromptResolutionInputSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 64d8c2b3a09e2..c2ddf5446706e 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"; @@ -164,7 +163,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) => @@ -185,8 +183,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; @@ -430,15 +426,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { mimeType, data, }), - onFrame: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { - if (typeof frame !== "object" || frame === null) return; - listener(frame as DesktopPreviewRecordingFrame); - }; - ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - return () => - ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - }, }, onStateChange: (listener) => { const wrappedListener = ( diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index d9b797dd0451f..22af4a829e049 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -168,12 +168,7 @@ describe("BrowserSession", () => { return granted; }; - for (const permission of [ - "clipboard-read", - "clipboard-sanitized-write", - "geolocation", - "fullscreen", - ]) { + for (const permission of ["clipboard-sanitized-write", "geolocation", "fullscreen"]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( checkHandler(null, permission) as boolean, @@ -226,6 +221,55 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("asks once per origin before a page reads the clipboard", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0]; + const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + assert.isFunction(checkHandler); + + const readClipboard = (requestingUrl: string) => + Effect.callback((resume) => { + requestHandler( + null, + "clipboard-read", + (granted: boolean) => resume(Effect.succeed(granted)), + { requestingUrl, isMainFrame: true }, + ); + }); + const checkClipboard = (origin: string): boolean => + checkHandler(null, "clipboard-read", origin, {}) as boolean; + + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1); + + assert.isFalse(checkClipboard("https://allowed.test")); + assert.isTrue(yield* readClipboard("https://allowed.test/page")); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + assert.strictEqual( + showMessageBox.mock.calls[0]?.[0].message, + "https://allowed.test wants to see text and images copied to the clipboard", + ); + assert.deepStrictEqual(showMessageBox.mock.calls[0]?.[0].buttons, ["Allow", "Block"]); + // Enter must not accept a prompt the page raised while the user was typing. + assert.strictEqual(showMessageBox.mock.calls[0]?.[0].defaultId, 1); + + assert.isTrue(yield* readClipboard("https://allowed.test/other")); + assert.isTrue(checkClipboard("https://allowed.test")); + + assert.isFalse(yield* readClipboard("https://blocked.test/")); + assert.isFalse(yield* readClipboard("https://blocked.test/again")); + assert.isFalse(checkClipboard("https://blocked.test")); + assert.isTrue(checkClipboard("https://allowed.test")); + assert.strictEqual(showMessageBox.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + it.effect("clears a partition whose session has not been opened yet", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 0f900d521b739..2ea7efdb88ee0 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -20,7 +20,6 @@ export type BrowserSessionPartitionNamespace = "profile"; // Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ - "clipboard-read", "clipboard-sanitized-write", "geolocation", // DesktopWindow confines HTML fullscreen to the guest's webview. @@ -55,6 +54,19 @@ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => const MAX_PROMPT_URL_LENGTH = 300; +const truncateForPrompt = (text: string): string => + text.length > MAX_PROMPT_URL_LENGTH ? `${text.slice(0, MAX_PROMPT_URL_LENGTH)}…` : text; + +const permissionOrigin = (rawUrl: string | undefined): string | null => { + if (!rawUrl) return null; + try { + const { origin } = new URL(rawUrl); + return origin === "null" ? null : origin; + } catch { + return null; + } +}; + export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { @@ -175,47 +187,70 @@ export const make = Effect.gen(function* BrowserSessionMake() { const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. - let externalPromptOpen = false; - /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ - const confirmOpenExternal = ( + let permissionPromptOpen = false; + /** Settles with `undefined` when the user gave no answer: another prompt was open or the dialog failed. */ + const promptPermission = ( requester: WebContents | null, - rawUrl: string | undefined, - callback: (granted: boolean) => void, + prompt: { + readonly buttons: readonly [allow: string, deny: string]; + readonly message: string; + readonly detail?: string; + }, + callback: (answer: boolean | undefined) => void, ): void => { - const url = externalProtocolPromptUrl(rawUrl); - if (url === null || externalPromptOpen) { - callback(false); + if (permissionPromptOpen) { + callback(undefined); return; } - externalPromptOpen = true; - const settle = (granted: boolean) => + permissionPromptOpen = true; + const settle = (answer: boolean | undefined) => Effect.sync(() => { - externalPromptOpen = false; - callback(granted); + permissionPromptOpen = false; + callback(answer); }); - const shownUrl = - url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url; runFork( electronDialog .showMessageBox( { type: "question", - buttons: ["Open", "Cancel"], - defaultId: 0, + buttons: [...prompt.buttons], + // Default to deny so an Enter typed into the page cannot accept a prompt it raised. + defaultId: 1, cancelId: 1, - message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, - detail: `A page in the browser wants to open another application:\n\n${shownUrl}`, + message: prompt.message, + ...(prompt.detail === undefined ? {} : { detail: prompt.detail }), }, previewHostWindow(requester), ) .pipe( - Effect.map(({ response }) => response === 0), - Effect.orElseSucceed(() => false), + Effect.map(({ response }): boolean | undefined => response === 0), + Effect.orElseSucceed(() => undefined), Effect.flatMap(settle), - Effect.onInterrupt(() => settle(false)), + Effect.onInterrupt(() => settle(undefined)), ), ); }; + /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ + const confirmOpenExternal = ( + requester: WebContents | null, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const url = externalProtocolPromptUrl(rawUrl); + if (url === null) { + callback(false); + return; + } + promptPermission( + requester, + { + buttons: ["Open", "Cancel"], + message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, + detail: `A page in the browser wants to open another application:\n\n${truncateForPrompt(url)}`, + }, + (answer) => callback(answer === true), + ); + }; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( @@ -248,6 +283,35 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); + // Like Chrome, clipboard reads ask once per origin; both answers last for the session. + const clipboardReadDecisions = new Map(); + const confirmClipboardRead = ( + requester: WebContents, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const origin = permissionOrigin(rawUrl); + if (origin === null) { + callback(false); + return; + } + const remembered = clipboardReadDecisions.get(origin); + if (remembered !== undefined) { + callback(remembered); + return; + } + promptPermission( + requester, + { + buttons: ["Allow", "Block"], + message: `${truncateForPrompt(origin)} wants to see text and images copied to the clipboard`, + }, + (answer) => { + if (answer !== undefined) clipboardReadDecisions.set(origin, answer); + callback(answer === true); + }, + ); + }; // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010. browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { @@ -259,11 +323,21 @@ export const make = Effect.gen(function* BrowserSessionMake() { ); return; } + if (permission === "clipboard-read") { + confirmClipboardRead( + webContents, + "requestingUrl" in details ? details.requestingUrl : undefined, + callback, + ); + return; + } callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }, ); - browserSession.setPermissionCheckHandler((_webContents, permission) => - ALLOWED_PREVIEW_PERMISSIONS.has(permission), + browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => + permission === "clipboard-read" + ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true + : ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); const next = new Map(sessions); next.set(partition, browserSession); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d0221e9be0e6e..036c0d4afd932 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -359,7 +359,6 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { }; type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } @@ -536,9 +535,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const recordingInputListenersRef = yield* Ref.make>( new Set(), ); - const recordingFrameListenersRef = yield* Ref.make>( - new Set(), - ); const pickSessionsRef = yield* Ref.make>(new Map()); const controlSessionsRef = yield* SynchronizedRef.make< ReadonlyMap @@ -801,12 +797,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: - | "state-change" - | "recording-frame" - | "recording-input" - | "pointer-event" - | "open-link", + eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link", tabId: string, delivery: () => Effect.Effect, ) => @@ -1077,35 +1068,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get( - tabId, - ); - if (captureSession?.consumers.has("recording")) { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: - typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: - typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => - deliverEvent("recording-frame", frame.tabId, () => listener(frame)), - { discard: true }, - ); - } - } } if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { yield* Queue.offer(consoleReleases, undefined); @@ -1505,7 +1467,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); if (forwarded && host) { event.preventDefault(); - if (!input.isAutoRepeat) host.send(MENU_ACTION_CHANNEL, forwarded.command); + if (!input.isAutoRepeat) { + if ( + forwarded.command === "workspace.focusNext" || + forwarded.command === "workspace.focusPrevious" + ) { + // DOM focus in the renderer cannot by itself release the guest's native keyboard focus. + host.focus(); + } + host.send(MENU_ACTION_CHANNEL, forwarded.command); + } return; } if (isPreviewRefreshShortcut(input)) { @@ -2517,78 +2488,66 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function height: size.height, receivedAt, }; - const deliveries: Array> = []; - if (pictureInPicture) { - const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( - tabId, - )?.window; - if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) { - deliveries.push( - Effect.gen(function* () { - const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get( - tabId, - ); - const aspectRatio = frame.width / frame.height; - if ( - previousAspectRatio === undefined || - Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON - ) { - yield* attempt( - { - operation: "pictureInPicture.setAspectRatio", - tabId, - webContentsId: wc.id, - }, - () => { - const contentSize = fitPictureInPictureContentSize( - pictureInPictureWindow.getContentSize(), - aspectRatio, - ); - pictureInPictureWindow.setAspectRatio(0); - pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); - pictureInPictureWindow.setAspectRatio(aspectRatio); - }, - ); - yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => - replaceMap(aspectRatios, (copy) => { - copy.set(tabId, aspectRatio); - }), - ); - } - yield* attempt( - { - operation: "pictureInPicture.deliverFrame", - tabId, - webContentsId: wc.id, - }, - () => { - pictureInPictureWindow.webContents.send( - PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, - frame, - ); - }, + const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( + tabId, + )?.window; + if (!pictureInPictureWindow || pictureInPictureWindow.isDestroyed()) return; + yield* Effect.gen(function* () { + const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get(tabId); + const aspectRatio = frame.width / frame.height; + if ( + previousAspectRatio === undefined || + Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON + ) { + yield* attempt( + { + operation: "pictureInPicture.setAspectRatio", + tabId, + webContentsId: wc.id, + }, + () => { + const contentSize = fitPictureInPictureContentSize( + pictureInPictureWindow.getContentSize(), + aspectRatio, ); - yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { - if (sessions.get(tabId) !== frameSession) return sessions; - return replaceMap(sessions, (copy) => { - copy.set(tabId, { - ...frameSession, - lastPictureInPictureFingerprint: fingerprint, - }); - }); - }); - }).pipe( - Effect.catch((error) => - Effect.logWarning("Picture-in-picture frame delivery failed.", { - tabId, - error, - }), - ), - ), + pictureInPictureWindow.setAspectRatio(0); + pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); + pictureInPictureWindow.setAspectRatio(aspectRatio); + }, + ); + yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => + replaceMap(aspectRatios, (copy) => { + copy.set(tabId, aspectRatio); + }), ); } - } - yield* Effect.all(deliveries, { concurrency: 2, discard: true }); + yield* attempt( + { + operation: "pictureInPicture.deliverFrame", + tabId, + webContentsId: wc.id, + }, + () => { + pictureInPictureWindow.webContents.send(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, frame); + }, + ); + yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { + if (sessions.get(tabId) !== frameSession) return sessions; + return replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...frameSession, + lastPictureInPictureFingerprint: fingerprint, + }); + }); + }); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Picture-in-picture frame delivery failed.", { + tabId, + error, + }), + ), + ); }); const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* ( @@ -3306,7 +3265,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), - Ref.set(recordingFrameListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, @@ -3354,8 +3312,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), - subscribeRecordingFrames: (listener: RecordingFrameListener) => - subscribe(recordingFrameListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")), zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")), @@ -3597,9 +3553,6 @@ export class PreviewManager extends Context.Service< readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; - readonly subscribeRecordingFrames: ( - listener: RecordingFrameListener, - ) => Effect.Effect; } >()("@t3tools/desktop/preview/Manager/PreviewManager") {} @@ -3733,7 +3686,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, - subscribeRecordingFrames: operations.subscribeRecordingFrames, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 51c3d31daae85..ac4c6e61378e3 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -35,7 +35,6 @@ const clientSettings: ClientSettings = { confirmThreadUnpin: false, contextWindowMeterEnabled: false, composerCollapseOnScroll: true, - dismissedProviderUpdateNotificationKeys: [], diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", @@ -64,7 +63,7 @@ const clientSettings: ClientSettings = { }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, + boringSidebarEnabled: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index c80fe9b87cd6a..25c37ecefc051 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -95,6 +95,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; @@ -450,6 +451,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 30edacb073c36..76daee09c2aeb 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -605,7 +605,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; diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 26ae8fbf9ebe9..7c63a8b9aadef 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -8,8 +8,6 @@ import { accessibleWindowText, compactAccessibilityTree, findAccessibleWindow, - findCaptureSource, - hideAndWaitForBlur, isWaylandSession, } from "./snapShot.ts"; @@ -450,68 +448,6 @@ describe("findAccessibleWindow", () => { }); }); -describe("hideAndWaitForBlur", () => { - it("waits for a delayed blur after hiding the window", async () => { - let blur: (() => void) | undefined; - let settled = false; - const hidden = hideAndWaitForBlur({ - hide: () => undefined, - once: (_event, listener) => { - blur = listener; - }, - removeListener: () => undefined, - }).then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(settled).toBe(false); - blur?.(); - await hidden; - expect(settled).toBe(true); - }); -}); - -describe("findCaptureSource", () => { - const sources = [ - { id: "window:42:0", name: "Terminal" }, - { id: "window:84:0", name: "Editor" }, - ]; - - it("matches the native window id before its title", () => { - expect( - findCaptureSource(sources, { - id: 84, - title: "Changed title", - }), - ).toEqual(sources[1]); - }); - - it("falls back to a unique title match", () => { - expect( - findCaptureSource(sources, { - id: 100, - title: "Terminal", - }), - ).toEqual(sources[0]); - }); - - it("does not guess when a title is ambiguous", () => { - expect( - findCaptureSource( - [ - { id: "window:42:0", name: "Editor" }, - { id: "window:84:0", name: "Editor" }, - ], - { - id: 100, - title: "Editor", - }, - ), - ).toBeUndefined(); - }); -}); - describe("isWaylandSession", () => { effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 28bd301c557e6..872f99e1d6a72 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -23,7 +23,6 @@ interface AccessibilityTreeNode { } const MAX_ACCESSIBILITY_TREE_NODES = 10_000; -const WINDOW_BLUR_TIMEOUT_MS = 1_000; export const WINDOWS_MODIFIER_PAIR_VIRTUAL_KEYS: Record< SnapShotModifier, @@ -438,25 +437,6 @@ export async function accessibleWindowElementTree( return { root: compacted.root, truncated: completedTruncated }; } -export function hideAndWaitForBlur(window: { - readonly hide: () => void; - readonly once: (event: "blur", listener: () => void) => unknown; - readonly removeListener: (event: "blur", listener: () => void) => unknown; -}): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - window.removeListener("blur", onBlur); - reject(new Error("Timed out waiting for T3 Code to lose focus.")); - }, WINDOW_BLUR_TIMEOUT_MS); - const onBlur = () => { - clearTimeout(timeout); - resolve(); - }; - window.once("blur", onBlur); - window.hide(); - }); -} - type WindowBounds = { readonly x: number; readonly y: number; @@ -591,30 +571,6 @@ export function toElectronAccelerator(shortcut: SnapShotKeyChord): string { return parts.join("+"); } -interface CaptureSourceLike { - readonly id: string; - readonly name: string; -} - -interface ActiveWindowLike { - readonly id: number; - readonly title: string; -} - -export function findCaptureSource( - sources: readonly T[], - activeWindow: ActiveWindowLike, -): T | undefined { - const idPrefix = `window:${activeWindow.id}:`; - const idMatch = sources.find((source) => source.id.startsWith(idPrefix)); - if (idMatch) return idMatch; - - const title = activeWindow.title.trim(); - if (!title) return undefined; - const titleMatches = sources.filter((source) => source.name.trim() === title); - return titleMatches.length === 1 ? titleMatches[0] : undefined; -} - export function isWaylandSession( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index b7571b4c02a64..78ce70852c599 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -156,8 +156,6 @@ export class DesktopUpdateUnexpectedActionError extends Schema.TaggedError; readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; - readonly configure: Effect.Effect; + readonly configure: Effect.Effect; readonly setChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 36939729682ba..ab52ca07a0d59 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -437,10 +437,8 @@ cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at // launch (which, in wsl-only mode, would just fail to launch with no fallback). // -// The sentinel must be a package the CLI bundle leaves external. It used to be -// "effect", back when the bundle externalized its runtime deps and the whole -// node_modules tree was unpacked. The bundle now inlines its JS dependencies, -// so "effect" no longer exists on disk and only the native packages do. +// The sentinel must be a package the CLI bundle leaves external. The bundle +// inlines its JS dependencies, so only the native packages exist on disk. // Resolving node-pty is what actually validates the unpacked tree. try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp deleted file mode 100644 index a05a5738a6cd7..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp deleted file mode 100644 index 1a16b037cf0b0..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp deleted file mode 100644 index dd72a9110265c..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp deleted file mode 100644 index 3aa1c274507b9..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp deleted file mode 100644 index e2510c829bb15..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp deleted file mode 100644 index f3ba3edb51909..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp deleted file mode 100644 index 8971323e7af55..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp deleted file mode 100644 index aa535e600a573..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp and /dev/null differ diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9f0d4c8c49180..c99064412e6d5 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -425,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", diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index 46b0539e5c97b..939ac2d9147ed 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,3 +1,3 @@ -The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), and development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)). +The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)), and `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [md4c#5](https://github.com/software-mansion-labs/md4c/pull/5)). To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz new file mode 100644 index 0000000000000..06ac560ff2eba Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz differ diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz deleted file mode 100644 index 3cf77c4adf971..0000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz and /dev/null differ diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 169d5cc4048ba..9367ade408e95 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,6 @@ -// 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"; 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 32969b1b71537..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 @@ -320,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 -> 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-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index b8c92f2d9332f..703bcdf361b49 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -66,8 +66,16 @@ class T3TerminalModule : Module() { Prop("captureRequest") { view: T3TerminalView, request: Double -> view.captureRequest = request } + + Prop("sharedGrid") { view: T3TerminalView, sharedGrid: List -> + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") + OnViewDidUpdateProps { view: T3TerminalView -> + view.applyPendingBuffer() + } + OnViewDestroys { view: T3TerminalView -> view.cleanup() } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index f4de25460d5c6..1deb3a6170350 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -58,6 +58,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private var streamedOutput: TerminalOutputBuffer? = null private var cols = 0 private var rows = 0 + private var measuredCols = 0 + private var measuredRows = 0 private var clearingInput = false private var isCleanedUp = false private var backgroundColorValue = Color.parseColor("#24292E") @@ -74,12 +76,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex recreateTerminal() } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" set(value) { if (field == value) return field = value - feedPendingBuffer() + hasPendingBuffer = true } + private var hasPendingBuffer = false var fontSize: Float = 10f set(value) { @@ -91,6 +97,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex var appearanceScheme: String = "dark" + // The PTY grid shared by every attached client, or empty before the server reports one. + // The terminal draws this grid, clamped to the view's own fit (the server's grid lags a + // local resize, and read-only observers never constrain it), so its wrapping and cursor + // match the PTY; the view's own fit still goes out through onResize. + var sharedGrid: List = emptyList() + set(value) { + if (field == value) return + field = value + applyGrid() + } + var themeConfig: String = "" set(value) { field = value @@ -327,6 +344,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex val nextRows = (terminalCanvas.usableHeight() / terminalCanvas.cellHeightPx) .toInt() .coerceIn(2, 200) + if (nextCols != measuredCols || nextRows != measuredRows) { + measuredCols = nextCols + measuredRows = nextRows + onResize(mapOf("cols" to measuredCols, "rows" to measuredRows)) + } + applyGrid() + } + + private fun applyGrid() { + if (measuredCols <= 0 || measuredRows <= 0 || isCleanedUp) return + val shared = sharedGrid.takeIf { it.size == 2 && it[0] > 0 && it[1] > 0 } + val nextCols = shared?.get(0)?.coerceAtMost(measuredCols) ?: measuredCols + val nextRows = shared?.get(1)?.coerceAtMost(measuredRows) ?: measuredRows if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return cols = nextCols rows = nextRows @@ -343,8 +373,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex ) } emitResponse(response) - onResize(mapOf("cols" to cols, "rows" to rows)) - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -369,7 +398,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (terminalHandle == 0L) return destroyTerminal() createTerminal() - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -381,6 +410,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalCanvas.resetSelectionState() } + fun applyPendingBuffer() { + if (!hasPendingBuffer) return + hasPendingBuffer = false + feedPendingBuffer() + } + private fun feedPendingBuffer() { if (streamedOutput != null || terminalHandle == 0L || initialBuffer == fedBuffer) return if (!initialBuffer.startsWith(fedBuffer)) { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 2f7445265886c..d68e46a5416b1 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -63,7 +63,15 @@ public class T3TerminalModule: Module { Prop("captureRequest") { (view: T3TerminalView, request: Double) in view.captureRequest = request } + + Prop("sharedGrid") { (view: T3TerminalView, sharedGrid: [Int]) in + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") + + OnViewDidUpdateProps { (view: T3TerminalView) in + view.applyPendingBuffer() + } } } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 364880aaeb39b..0ae146bb1241a 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -243,9 +243,23 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" { didSet { - applyRemoteBuffer(initialBuffer) + hasPendingBuffer = true + } + } + private var hasPendingBuffer = false + + // The PTY grid shared by every attached client, or empty before the server reports one. + // The surface draws this grid so its wrapping and cursor match the PTY; the view's own + // fit still goes out through onResize. + var sharedGrid: [Int] = [] { + didSet { + guard oldValue != sharedGrid else { return } + resizeSurface() } } @@ -529,7 +543,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { resizeSurface() if let streamedOutput { feedData(streamedOutput.data) - } else { + } else if !hasPendingBuffer { feedBuffer(initialBuffer) } } @@ -559,6 +573,19 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } surface = nil app = nil + // Ghostty adds its IOSurface layer to the viewport on iOS but never removes it on + // free, leaving the last frame on screen under the next surface's layer. The + // viewport has no subviews, so every sublayer belongs to a freed surface. + CATransaction.begin() + CATransaction.setDisableActions(true) + terminalViewport.layer.sublayers?.forEach { $0.removeFromSuperlayer() } + CATransaction.commit() + } + + func applyPendingBuffer() { + guard hasPendingBuffer else { return } + hasPendingBuffer = false + applyRemoteBuffer(initialBuffer) } private func applyRemoteBuffer(_ buffer: String) { @@ -647,10 +674,25 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { terminalViewport.contentScaleFactor = scale ghostty_surface_set_content_scale(surface, Double(scale), Double(scale)) ghostty_surface_set_size(surface, width, height) + let fitted = ghostty_surface_size(surface) + let fittedCols = max(1, Int(fitted.columns)) + let fittedRows = max(1, Int(fitted.rows)) + // Trimming whole cells keeps ghostty's padding, and the surface can only shrink + // within the view, so a stale larger grid falls back to the fitted one. + var surfaceWidth = width + var surfaceHeight = height + if sharedGrid.count == 2, sharedGrid[0] > 0, sharedGrid[1] > 0 { + surfaceWidth -= UInt32(max(fittedCols - sharedGrid[0], 0)) * fitted.cell_width_px + surfaceHeight -= UInt32(max(fittedRows - sharedGrid[1], 0)) * fitted.cell_height_px + if surfaceWidth != width || surfaceHeight != height { + ghostty_surface_set_size(surface, surfaceWidth, surfaceHeight) + } + } ghostty_surface_set_occlusion(surface, window != nil) - configureIOSurfaceLayers() + configureIOSurfaceLayers( + size: CGSize(width: CGFloat(surfaceWidth) / scale, height: CGFloat(surfaceHeight) / scale)) redrawSurface() - emitGhosttyResize() + emitResize(cols: fittedCols, rows: fittedRows) } private func redrawSurface() { @@ -658,19 +700,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_surface_refresh(surface) ghostty_surface_draw(surface) markIOSurfaceLayersForDisplay() - emitGhosttyResize() - } - - private func emitGhosttyResize() { - guard let surface else { - emitEstimatedResize() - return - } - - let size = ghostty_surface_size(surface) - let cols = max(1, Int(size.columns)) - let rows = max(1, Int(size.rows)) - emitResize(cols: cols, rows: rows) } private func emitEstimatedResize() { @@ -721,8 +750,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_app_keyboard_changed(app) } - private func configureIOSurfaceLayers() { - let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size) + private func configureIOSurfaceLayers(size: CGSize) { + let targetBounds = CGRect(origin: .zero, size: size) CATransaction.begin() CATransaction.setDisableActions(true) terminalViewport.layer.sublayers?.forEach { sublayer in diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6f1a5c1e576e2..24d02ad1d60d7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -124,7 +124,7 @@ "react-dom": "catalog:", "react-native": "0.88.0", "react-native-blob-util": "0.25.1", - "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz", + "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz", "react-native-gesture-handler": "~3.3.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.22.8", diff --git a/apps/mobile/plugins/withAndroidCleartextTraffic.cjs b/apps/mobile/plugins/withAndroidCleartextTraffic.cjs deleted file mode 100644 index f612f751e9151..0000000000000 --- a/apps/mobile/plugins/withAndroidCleartextTraffic.cjs +++ /dev/null @@ -1,18 +0,0 @@ -const { withAndroidManifest } = require("expo/config-plugins"); - -module.exports = function withAndroidCleartextTraffic(config) { - return withAndroidManifest(config, (nextConfig) => { - const application = nextConfig.modResults.manifest.application?.[0]; - - if (application == null) { - throw new Error( - "AndroidManifest.xml is missing the application element required for cleartext traffic configuration.", - ); - } - - application.$ ??= {}; - application.$["android:usesCleartextTraffic"] = "true"; - - return nextConfig; - }); -}; diff --git a/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs b/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs new file mode 100644 index 0000000000000..90fea189e8d01 --- /dev/null +++ b/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs @@ -0,0 +1,56 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { withAndroidManifest, withDangerousMod } = require("expo/config-plugins"); + +// Environments behind a private CA installed in the user store must work like they do in Chrome. +// Android ignores usesCleartextTraffic once a network security config exists, so permit it here. +const NETWORK_SECURITY_CONFIG = ` + + + + + + + + +`; + +function withNetworkSecurityConfigManifest(config) { + return withAndroidManifest(config, (nextConfig) => { + const application = nextConfig.modResults.manifest.application?.[0]; + + if (application == null) { + throw new Error( + "AndroidManifest.xml is missing the application element required for network security configuration.", + ); + } + + application.$ ??= {}; + application.$["android:networkSecurityConfig"] = "@xml/network_security_config"; + + return nextConfig; + }); +} + +function withNetworkSecurityConfigResource(config) { + return withDangerousMod(config, [ + "android", + async (config) => { + const xmlDir = path.join( + config.modRequest.platformProjectRoot, + "app", + "src", + "main", + "res", + "xml", + ); + fs.mkdirSync(xmlDir, { recursive: true }); + fs.writeFileSync(path.join(xmlDir, "network_security_config.xml"), NETWORK_SECURITY_CONFIG); + return config; + }, + ]); +} + +module.exports = function withAndroidNetworkSecurityConfig(config) { + return withNetworkSecurityConfigResource(withNetworkSecurityConfigManifest(config)); +}; diff --git a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift b/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift deleted file mode 100644 index db279d0c62e75..0000000000000 --- a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation - -enum NWInterface { - enum InterfaceType { - case other, wifi, cellular, loopback, wiredEthernet - } -} - -struct NWPath { - enum Status { - case satisfied, unsatisfied - } - - // A reference inside the value models the native path's retained storage. - final class Storage { - let type: NWInterface.InterfaceType - init(_ type: NWInterface.InterfaceType) { self.type = type } - } - - let status: Status - let storage: Storage - - init(_ type: NWInterface.InterfaceType, status: Status = .satisfied) { - self.status = status - storage = Storage(type) - } - - func usesInterfaceType(_ type: NWInterface.InterfaceType) -> Bool { - storage.type == type - } -} - -final class NWPathMonitor { - static var scenario = "first" - static let deliveries = DispatchGroup() - var pathUpdateHandler: ((NWPath) -> Void)? - - func start(queue: DispatchQueue) { - guard let handler = pathUpdateHandler else { fatalError("Missing path handler") } - if Self.scenario == "offline" { - handler(NWPath(.wifi, status: .unsatisfied)) - } else if Self.scenario == "first" { - handler(NWPath(.wifi)) - handler(NWPath(.cellular)) - } else { - queue.async(group: Self.deliveries) { - handler(NWPath(.wifi)) - DispatchQueue.concurrentPerform(iterations: 128) { index in - handler(NWPath(index.isMultiple(of: 2) ? .cellular : .wiredEthernet)) - } - } - } - } - - // Already queued callbacks can run after cancellation. - func cancel() {} -} - -@main -enum NetworkModuleRegression { - static func main() { - NWPathMonitor.scenario = CommandLine.arguments[1] - let module = NetworkModule() - if NWPathMonitor.scenario == "concurrent" { - DispatchQueue.concurrentPerform(iterations: 64) { _ in - let state = module.getNetworkStateAsync() - precondition(state["type"] as? String == "WIFI", "The first path must survive late callbacks") - precondition(state["isConnected"] as? Bool == true) - } - NWPathMonitor.deliveries.wait() - } else { - let state = module.getNetworkStateAsync() - let connected = NWPathMonitor.scenario != "offline" - precondition(state["type"] as? String == (connected ? "WIFI" : "NONE")) - precondition(state["isConnected"] as? Bool == connected) - precondition(state["isInternetReachable"] as? Bool == connected) - } - print("passed") - } -} diff --git a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift deleted file mode 100644 index 6d13972d5742e..0000000000000 --- a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift +++ /dev/null @@ -1,189 +0,0 @@ -import Foundation - -// The registry runs on macOS without starting a simulator. Only the OS-facing -// types are replaced; the tests compile the dependency's actual Swift source. -public enum UIBackgroundFetchResult { - case noData -} - -public struct UNNotificationPresentationOptions: OptionSet { - public let rawValue: Int - public init(rawValue: Int) { self.rawValue = rawValue } -} - -public final class UNNotification: NSObject {} - -public final class UNNotificationResponse: NSObject { - let identifier: String - init(_ identifier: String) { self.identifier = identifier } -} - -public protocol UNUserNotificationCenterDelegate: AnyObject {} - -public final class UNUserNotificationCenter: NSObject { - private static let instance = UNUserNotificationCenter() - public weak var delegate: UNUserNotificationCenterDelegate? - public static func current() -> UNUserNotificationCenter { instance } -} - -private final class TestDelegate: NotificationDelegate { - private let lock = NSLock() - private var recordedEvents: [String] = [] - var onEvent: ((String) -> Void)? - var onResponse: ((UNNotificationResponse) -> Bool)? - - var events: [String] { lock.withLock { recordedEvents } } - - private func record(_ event: String) { - lock.withLock { recordedEvents.append(event) } - onEvent?(event) - } - - func didRegister(_ deviceToken: String) { record("registered") } - func didFailRegistration(_ error: Error) { record("failed") } - func openSettings(_ notification: UNNotification?) { record("settings") } - - func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { - record("present") - return false - } - - func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { - record("background") - return false - } - - func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { - record(response.identifier) - return onResponse?(response) ?? false - } -} - -private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { - if !condition() { - throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) - } -} - -private func deliver(_ identifier: String) { - NotificationCenterManager.shared.userNotificationCenter( - UNUserNotificationCenter.current(), - didReceive: UNNotificationResponse(identifier), - withCompletionHandler: {} - ) -} - -private func reentrantCallbacks() throws { - let manager = NotificationCenterManager.shared - let center = UNUserNotificationCenter.current() - let callbacks: [(String, () -> Void)] = [ - ("registered", { manager.didRegister("token") }), - ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }), - ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }), - ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }), - ("background", { manager.didReceive([:], completionHandler: { _ in }) }) - ] - - for (event, callback) in callbacks { - let original = TestDelegate() - let replacement = TestDelegate() - original.onEvent = { [weak original] _ in - guard let original else { return } - manager.removeDelegate(original) - manager.addDelegate(replacement) - } - manager.addDelegate(original) - callback() - try require(original.events == [event], "The original delegate must receive the callback once") - try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot") - callback() - try require(replacement.events == [event], "Reentrant registration must take effect for the next callback") - manager.removeDelegate(replacement) - } -} - -private func registrationDuringDelivery() throws { - let manager = NotificationCenterManager.shared - let original = TestDelegate() - let receiver = TestDelegate() - receiver.onResponse = { _ in true } - original.onResponse = { _ in - manager.addDelegate(receiver) - return false - } - manager.addDelegate(original) - deliver("handoff") - try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response") - manager.removeDelegate(original) - manager.removeDelegate(receiver) -} - -private func responseDuringReplay() throws { - let manager = NotificationCenterManager.shared - deliver("first") - let original = TestDelegate() - original.onResponse = { response in - if response.identifier == "first" { - deliver("second") - return true - } - return false - } - manager.addDelegate(original) - let receiver = TestDelegate() - receiver.onResponse = { _ in true } - manager.addDelegate(receiver) - try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks") - manager.removeDelegate(original) - manager.removeDelegate(receiver) -} - -private func concurrentRegistrations() throws { - let manager = NotificationCenterManager.shared - let delegates = (0..<512).map { _ in - let delegate = TestDelegate() - delegate.onResponse = { _ in true } - return delegate - } - DispatchQueue.concurrentPerform(iterations: delegates.count) { index in - manager.addDelegate(delegates[index]) - if index.isMultiple(of: 16) { - manager.didRegister("during-add") - deliver("during-add") - } - } - let before = delegates.map { $0.events.count } - manager.didRegister("after-add") - for (index, delegate) in delegates.enumerated() { - try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once") - } - DispatchQueue.concurrentPerform(iterations: delegates.count) { index in - manager.removeDelegate(delegates[index]) - if index.isMultiple(of: 16) { - manager.didRegister("during-remove") - deliver("during-remove") - } - } - let removed = delegates.map { $0.events.count } - manager.didRegister("after-remove") - try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks") -} - -@main -private enum RegressionTests { - static func main() { - do { - switch CommandLine.arguments.last { - case "reentrant": try reentrantCallbacks() - case "handoff": try registrationDuringDelivery() - case "pending": try responseDuringReplay() - case "concurrent": try concurrentRegistrations() - default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2) - } - print("passed") - } catch { - FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) - exit(1) - } - } -} diff --git a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m b/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m deleted file mode 100644 index 7124178383d49..0000000000000 --- a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m +++ /dev/null @@ -1,55 +0,0 @@ -@interface TestPermissionRequester : NSObject -@end - -@implementation TestPermissionRequester -+ (NSString *)permissionType { return NSStringFromClass(self); } -- (NSDictionary *)getPermissions { return @{ @"status": @(EXPermissionStatusGranted) }; } -- (void)requestPermissionsWithResolver:(EXPromiseResolveBlock)resolve rejecter:(EXPromiseRejectBlock)reject { - resolve([self getPermissions]); -} -@end - -typedef struct { - __unsafe_unretained EXPermissionsService *service; - __unsafe_unretained NSArray *requesters; - size_t offset; -} Worker; - -static void *exerciseRegistry(void *context) { - Worker *worker = context; - for (size_t index = 0; index < 250; index++) { - @autoreleasepool { - id requester = worker->requesters[(index + worker->offset) % worker->requesters.count]; - [worker->service registerRequesters:@[requester]]; - id resolved = [worker->service getPermissionRequesterForType:[[requester class] permissionType]]; - assert(resolved == requester); - NSDictionary *permissions = [worker->service getPermissionUsingRequesterClass:[requester class]]; - assert([permissions[@"status"] isEqualToString:@"granted"]); - } - } - return NULL; -} - -int main(void) { - @autoreleasepool { - EXPermissionsService *service = [EXPermissionsService new]; - NSMutableArray *requesters = [NSMutableArray new]; - for (int i = 0; i < 32; i++) { - NSString *name = [NSString stringWithFormat:@"TestPermission%d", i]; - Class cls = objc_allocateClassPair([TestPermissionRequester class], name.UTF8String, 0); - objc_registerClassPair(cls); - [requesters addObject:[cls new]]; - } - Worker workers[8]; - pthread_t threads[8]; - for (size_t index = 0; index < 8; index++) { - workers[index] = (Worker){ service, requesters, index }; - assert(pthread_create(&threads[index], NULL, exerciseRegistry, &workers[index]) == 0); - } - for (size_t index = 0; index < 8; index++) { - assert(pthread_join(threads[index], NULL) == 0); - } - puts("passed"); - } - return 0; -} diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 3a4860b43a087..5a1a701a0556a 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -17,6 +17,7 @@ import { type NavigationAction, type NavigationState, } from "@react-navigation/native"; +import { pipe } from "effect/Function"; import { RegistryContext } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; @@ -54,7 +55,11 @@ 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 } from "./features/connection/devAutoConnect"; +import { + pairingFormLinkAction, + startDevAutoConnect, + withoutPairingTarget, +} from "./features/connection/devAutoConnect"; import { resolvePendingNewTaskPromptLink, takeNewTaskPromptLink, @@ -106,17 +111,15 @@ const linkingConfig = { } as const; function getAppStateFromPath(path: string, options: Parameters[1]) { - return takeScheduledTaskNewLink( - startDevAutoConnect( - takeNewTaskPromptLink( - takeThreadPromptLink( - restoreSettingsLinkState( - restoreDraftLinkState(getStateFromPath(path, options), path), - settingsLinkPage(), - ), - ), - ), - ), + return pipe( + getStateFromPath(path, options), + (state) => restoreDraftLinkState(state, path), + (state) => restoreSettingsLinkState(state, settingsLinkPage()), + takeThreadPromptLink, + takeNewTaskPromptLink, + withoutPairingTarget, + startDevAutoConnect, + takeScheduledTaskNewLink, ); } 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/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index c96e872fd6938..59c0a74e4525f 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -27,6 +27,7 @@ import { startComposerDraftOperation } from "../state/composerDraftOperations"; import { appAtomRegistry } from "../state/atom-registry"; import { mobilePreferencesAtom } from "../state/preferences"; import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread"; +import { useLinkedEnvironments } from "../state/peerLinks"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./ui/AppText"; import { @@ -77,6 +78,7 @@ export function ComposerEditor({ const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( null, ); + const linkedEnvironments = useLinkedEnvironments(environmentId ?? null); const contextImports = useAtomValue(composerContextImportsAtom); const importing = draftKey ? contextImports[draftKey] === true : false; const pasteContext = async ( @@ -101,6 +103,7 @@ export function ComposerEditor({ signal, retained.context?.records.length ?? 0, environmentId, + linkedEnvironments, ); if (signal.aborted) return; if (!result) { diff --git a/apps/mobile/src/components/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/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index 12d31986710cd..c6dc31ca00ad2 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -1,6 +1,6 @@ import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; @@ -28,7 +28,7 @@ function LoadedMediaVideo(props: { player.staysActiveInBackground = false; player.bufferOptions = { preferredForwardBufferDuration: 5 }; }); - const playback = useMemo(() => createAudioPlaybackSession(player, true), [player]); + const playback = createAudioPlaybackSession(player, true); const { active, fullscreen, createPlayRequest, onFullscreenEnter, onFullscreenExit } = useMediaVideoPlayback(player, props, playback); const previousPlaybackRequested = useRef(false); diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 85f5bbd55c6a9..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,10 +145,7 @@ function ProjectFaviconImage(props: { readonly projectTitle: string; readonly size: number; }) { - const faviconRequest = useMemo( - () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), - [props.cacheKey, props.faviconUrl], - ); + 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 diff --git a/apps/mobile/src/components/ScrollComposeButton.android.tsx b/apps/mobile/src/components/ScrollComposeButton.android.tsx index 12906a2bb4ac6..e9e57373e19c0 100644 --- a/apps/mobile/src/components/ScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/ScrollComposeButton.android.tsx @@ -8,7 +8,7 @@ import { onSizeChanged, size, } from "@expo/ui/jetpack-compose/modifiers"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -28,9 +28,9 @@ export function ScrollComposeButton(props: { const scale = fabSize / nativeSize; const nativeIconSize = Math.round(iconSize / scale); const [buttonWidth, setButtonWidth] = useState(nativeSize); - const rememberWidth = useCallback(({ width }: { width: number }) => { + const rememberWidth = ({ width }: { width: number }) => { setButtonWidth((previous) => Math.max(previous, width)); - }, []); + }; return ( 0); const onSearchChange = search?.onChangeText; - const closeSearch = useCallback(() => { + const closeSearch = () => { onSearchChange?.(""); setSearchOpen(false); Keyboard.dismiss(); - }, [onSearchChange]); + }; useHardwareBack(searching && search?.mode !== "inline", () => { closeSearch(); return true; diff --git a/apps/mobile/src/components/ui/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/Toast.tsx b/apps/mobile/src/components/ui/Toast.tsx index 7610a815195c9..2483ac9fa1285 100644 --- a/apps/mobile/src/components/ui/Toast.tsx +++ b/apps/mobile/src/components/ui/Toast.tsx @@ -1,5 +1,5 @@ import { MOBILE_MOTION, MOBILE_SHAPE } from "../../lib/designTokens"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { AccessibilityInfo, Platform, StyleSheet, View, useWindowDimensions } from "react-native"; import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; import Animated, { @@ -134,19 +134,16 @@ function SwipeableToast(props: ToastProps & { readonly onDismiss: () => void }) const [glassReady, setGlassReady] = useState(Platform.OS !== "ios"); const finishEntering = useCallback(() => setGlassReady(true), []); // UIKit can skip glass on a moving host; remount the surface after entry so its blur installs. - const toastEntering = useMemo( - () => (values: EntryAnimationsValues) => { - "worklet"; - return { - initialValues: { originY: values.targetOriginY + values.targetHeight + 12 }, - animations: { originY: withTiming(values.targetOriginY, ENTER_TIMING) }, - callback: (finished: boolean) => { - if (finished) runOnJS(finishEntering)(); - }, - }; - }, - [finishEntering], - ); + const toastEntering = (values: EntryAnimationsValues) => { + "worklet"; + return { + initialValues: { originY: values.targetOriginY + values.targetHeight + 12 }, + animations: { originY: withTiming(values.targetOriginY, ENTER_TIMING) }, + callback: (finished: boolean) => { + if (finished) runOnJS(finishEntering)(); + }, + }; + }; const { onDismiss } = props; const translation = useSharedValue(0); const width = useSharedValue(0); diff --git a/apps/mobile/src/connection/app-state-wakeups.ts b/apps/mobile/src/connection/app-state-wakeups.ts index d3f5b225a1671..d50646edd1ca5 100644 --- a/apps/mobile/src/connection/app-state-wakeups.ts +++ b/apps/mobile/src/connection/app-state-wakeups.ts @@ -1,6 +1,6 @@ import type { Wakeups } from "@t3tools/client-runtime/connection"; -export const MOBILE_LONG_BACKGROUND_AFTER_MS = 10_000; +const MOBILE_LONG_BACKGROUND_AFTER_MS = 10_000; export type MobileApplicationActiveWakeup = Extract< Wakeups.ConnectionWakeup, diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index a546aa76beaac..3f1fe66908e93 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -59,9 +59,6 @@ const layerTest = Layer.mergeAll( Layer.succeed( MobileStorage.MobileStorage, MobileStorage.MobileStorage.of({ - loadSavedConnections: Effect.succeed([]), - saveConnection: () => Effect.void, - clearSavedConnection: () => Effect.void, loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), loadAgentAwarenessDeviceId: Effect.succeed("device-1"), loadAgentAwarenessRegistrationRecord: Effect.succeed(null), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d2ce9ca5580f6..d7e340d886ff2 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -13,9 +13,7 @@ import * as Layer from "effect/Layer"; import { Cookies, FetchHttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import type { EnvironmentId } from "@t3tools/contracts"; import { verifyDpopProof } from "@t3tools/shared/dpop"; -import type { SavedRemoteConnection } from "../../lib/connection"; import * as Dpop from "../cloud/dpop"; import * as ManagedRelayLayer from "../cloud/managedRelayLayer"; import { @@ -29,11 +27,9 @@ import { __resetAgentAwarenessRemoteRegistrationForTest, getAgentAwarenessRegistrationStatus, refreshAgentAwarenessRegistration, - registerAgentAwarenessConnection, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, - unregisterAgentAwarenessConnection, updateAgentAwarenessRegistrationPreferences, } from "./remoteRegistration"; import * as Notifications from "expo-notifications"; @@ -200,18 +196,6 @@ function proofIat(proof: string): number { return decoded.iat; } -function savedConnection(): SavedRemoteConnection { - return { - environmentId: "env-1" as EnvironmentId, - environmentLabel: "Desktop", - pairingUrl: "https://desktop.example/pair", - displayUrl: "https://desktop.example", - httpBaseUrl: "https://desktop.example", - wsBaseUrl: "wss://desktop.example/ws", - bearerToken: "bearer-token", - }; -} - const layerRelayTest = ManagedRelayLayer.layer("https://relay.example.test").pipe( Layer.provide(Layer.mergeAll(FetchHttpClient.layer, Dpop.layer)), ); @@ -288,7 +272,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); it.effect("registers one push-token listener per Live Activity across wrappers", () => { - registerAgentAwarenessConnection(savedConnection()); const addPushTokenListener = vi.fn(); // getInstances() returns a new wrapper for the same native activity each call. const wrapper = () => ({ @@ -622,44 +605,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }); - it.effect( - "keeps the user-scoped relay APNs device when an environment connection is removed", - () => { - const fetchMock = vi.fn((request: RequestInfo | URL) => { - const url = request instanceof Request ? request.url : String(request); - return Promise.resolve( - Response.json( - url.endsWith("/v1/client/dpop-token") - ? { - access_token: "relay-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "mobile:registration", - } - : { ok: true }, - ), - ); - }); - vi.stubGlobal("fetch", fetchMock); - Constants.expoConfig!.extra = { - relay: { - url: "https://relay.example.test/", - }, - }; - - registerAgentAwarenessConnection(savedConnection()); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - return Effect.gen(function* () { - yield* runBackgroundOperations(); - fetchMock.mockClear(); - - unregisterAgentAwarenessConnection(savedConnection().environmentId); - - expect(fetchMock).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerRelayTest)); - }, - ); it.effect.each(["ios", "android"] as const)( "does not enable %s notifications when a token rotates after permission is revoked", (os) => { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 93bfbfca2d646..b4f9a30d0be16 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -23,7 +23,6 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { SavedRemoteConnection } from "../../lib/connection"; import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -77,7 +76,6 @@ export class AgentAwarenessOperationError extends Schema.TaggedError(); // ActivityKit returns new wrappers per id; serialize by id to prevent stale-token registration. const activityPushTokenLocks = new Map(); // Each accepted registration replays the relay aggregate, repairing drifted or orphaned activities. @@ -135,7 +133,7 @@ interface RegisterDeviceInput extends DeviceRegistrationInput { readonly preferencesOverride?: Partial; } -export function mergeAgentAwarenessRegistrationPreferences( +function mergeAgentAwarenessRegistrationPreferences( stored: Preferences, override: Partial | undefined, ): Preferences { @@ -145,7 +143,6 @@ export function mergeAgentAwarenessRegistrationPreferences( function readRelayConfig(): { readonly url: string } | null { const relayUrl = resolveCloudPublicConfig().relay.url; if (!relayUrl) { - logRegistrationDebug("relay registration skipped; relay config missing"); return null; } @@ -160,7 +157,7 @@ function canRegisterPushNotifications(): boolean { return Platform.OS === "ios" || Platform.OS === "android"; } -export function shouldRegisterAgentAwarenessDeviceForProvider( +function shouldRegisterAgentAwarenessDeviceForProvider( previousIdentity: string | null, identity: string | undefined, ): boolean { @@ -327,10 +324,6 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith expectedGeneration: number, ): Effect.fn.Return { if (expectedGeneration !== deviceRegistrationGeneration) { - logRegistrationDebug("device registration cancelled before relay request", { - expectedGeneration, - currentGeneration: deviceRegistrationGeneration, - }); return; } const relayConfig = readRelayConfig(); @@ -340,14 +333,9 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith } const token = yield* relayToken("read-device-registration-relay-token"); if (expectedGeneration !== deviceRegistrationGeneration) { - logRegistrationDebug("device registration cancelled after auth lookup", { - expectedGeneration, - currentGeneration: deviceRegistrationGeneration, - }); return; } if (!token) { - logRegistrationDebug("relay device registration skipped; user is not signed in"); setRegistrationStatus("unknown"); return; } @@ -360,10 +348,6 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith if (expectedGeneration !== deviceRegistrationGeneration) { // Signed out while the record loaded — do not resurrect the cleared // record or report the previous account's registration as current. - logRegistrationDebug("device registration cancelled after record lookup", { - expectedGeneration, - currentGeneration: deviceRegistrationGeneration, - }); return; } const payload = body; @@ -382,16 +366,10 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith !needsAndroidReplay ) { setRegistrationStatus("registered"); - logRegistrationDebug("relay device registration skipped; already registered for account", { - expectedGeneration, - }); return; } const client = yield* ManagedRelay.ManagedRelayClient; - logRegistrationDebug("relay device registration request started", { - expectedGeneration, - }); yield* client.registerDevice({ clerkToken: token, payload, @@ -400,10 +378,6 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith // Signed out while the request was in flight: the sign-out path already // reset the status and cleared the record for the next account, so a // stale success must not overwrite either. - logRegistrationDebug("device registration completed after sign-out; result discarded", { - expectedGeneration, - currentGeneration: deviceRegistrationGeneration, - }); return; } if (body.platform === "android") androidDeviceReplayedAt = Date.now(); @@ -416,9 +390,6 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith logRegistrationError("persist registration record failed", error); }), ); - logRegistrationDebug("relay device registration request completed", { - expectedGeneration, - }); }); const unregisterDeviceWithRelay = Effect.fn("remoteRegistration.unregisterDeviceWithRelay")( @@ -436,7 +407,6 @@ const unregisterDeviceWithRelay = Effect.fn("remoteRegistration.unregisterDevice }), }); if (!token) { - logRegistrationDebug("relay device unregistration skipped; user is not signed in"); return; } @@ -464,9 +434,6 @@ export function armAgentAwarenessLiveActivityForLocalWork(input: { return; } if (!environmentPublishesAgentActivity(input.environmentId)) { - logRegistrationDebug("live activity arming skipped; environment does not publish", { - environmentId: input.environmentId, - }); return; } void loadPreferences() @@ -513,9 +480,6 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { if (!activity) { return; } - logRegistrationDebug("live activity card armed for local work", { - threadTitle: input.threadTitle, - }); runRegistrationInBackground( registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid), "live activity arming after local task start failed", @@ -554,7 +518,6 @@ const registerLiveActivityWithRelay = Effect.fn("remoteRegistration.registerLive if (!readRelayConfig()) return false; const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { - logRegistrationDebug("relay live activity registration skipped; user is not signed in"); return false; } @@ -578,13 +541,6 @@ function logRegistrationError(context: string, error: unknown): void { }); } -function logRegistrationDebug(context: string, details?: unknown): void { - if (!__DEV__) { - return; - } - console.log(`[agent-awareness] ${context}`, details ?? ""); -} - function runRegistrationInBackground( operation: Effect.Effect, context: string, @@ -622,10 +578,6 @@ function startPendingDeviceRegistration(): void { const next = pendingDeviceRegistration; pendingDeviceRegistration = null; const generation = deviceRegistrationGeneration; - logRegistrationDebug("device registration started", { - generation, - hasObservedPushToken: next.input.observedPushToken !== undefined, - }); if (registrationStatus !== "registered") { setRegistrationStatus("pending"); } @@ -645,7 +597,6 @@ function startPendingDeviceRegistration(): void { } logRegistrationError(next.context, squashAtomCommandFailure(result)); } - logRegistrationDebug("device registration finished", { generation }); if (activeDeviceRegistration === registration) { activeDeviceRegistration = null; } @@ -658,17 +609,9 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri activeDeviceRegistration && !registrationAddsInformation(activeDeviceRegistration.input, input) ) { - logRegistrationDebug("device registration coalesced with active request", { - generation: deviceRegistrationGeneration, - }); return; } - logRegistrationDebug("device registration enqueued", { - generation: deviceRegistrationGeneration, - hasActiveRegistration: activeDeviceRegistration !== null, - hasPendingRegistration: pendingDeviceRegistration !== null, - }); pendingDeviceRegistration = pendingDeviceRegistration ? { input: mergeDeviceRegistrationInput(pendingDeviceRegistration.input, input), @@ -683,11 +626,9 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* expectedGeneration = deviceRegistrationGeneration, ): Effect.fn.Return { if (!canRegisterPushNotifications()) { - logRegistrationDebug("device registration skipped; platform does not support it"); return; } - logRegistrationDebug("device registration loading local state", { expectedGeneration }); const [deviceId, storedPreferences] = yield* Effect.all([ Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), @@ -719,10 +660,6 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* ); } const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); - logRegistrationDebug("device registration local state ready", { - expectedGeneration, - notificationsEnabled: pushTokenRegistration.notificationsEnabled, - }); const bundleId = Platform.OS === "android" ? Constants.expoConfig?.android?.package?.trim() @@ -818,29 +755,6 @@ function endLocalLiveActivities(context: string): void { } } -export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void { - if (!canRegisterPushNotifications()) { - return; - } - - environmentConnections.set(connection.environmentId, connection); - ensurePushTokenListener(); - ensureAppStateListener(); - enqueueDeviceRegistration({}, "device registration failed"); - runRegistrationInBackground( - refreshActiveLiveActivityRemoteRegistration(), - "active live activity registration after environment connection failed", - ); -} - -function removeAgentAwarenessConnection(environmentId: EnvironmentId): void { - environmentConnections.delete(environmentId); -} - -export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId): void { - removeAgentAwarenessConnection(environmentId); -} - export function refreshAgentAwarenessRegistration(): Effect.Effect< void, never, @@ -874,7 +788,6 @@ export function updateAgentAwarenessRegistrationPreferences( } export function __resetAgentAwarenessRemoteRegistrationForTest(): void { - environmentConnections.clear(); pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); @@ -942,10 +855,6 @@ export const registerLiveActivityPushToken = Effect.fn( }), }); if (!activityPushToken) { - logRegistrationDebug( - "live activity push token not available yet; waiting for token event", - { connectionCount: environmentConnections.size }, - ); return false; } @@ -965,9 +874,6 @@ function observeLiveActivityPushTokens( activityPushTokenLocks.set(activityId, lock); activity.addPushTokenListener((event) => { if (!event.pushToken) return; - logRegistrationDebug("live activity push token event received", { - tokenSuffix: event.pushToken.slice(-8), - }); runRegistrationInBackground( lock.withPermit(registerLiveActivityPushTokenValue({ activityPushToken: event.pushToken })), "live activity token listener registration failed", @@ -999,9 +905,6 @@ const registerLiveActivityPushTokenValue = Effect.fn( }); if (registered) { registeredActivityPushTokens.set(input.activityPushToken, Date.now()); - logRegistrationDebug("live activity push token registered", { - tokenSuffix: input.activityPushToken.slice(-8), - }); } return registered; }); @@ -1020,7 +923,7 @@ function scheduleActiveLiveActivityRegistrationRetry(): void { }, REMOTE_ACTIVITY_REGISTRATION_RETRY_MS); } -export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( +const refreshActiveLiveActivityRemoteRegistration = Effect.fn( "remoteRegistration.refreshActiveLiveActivityRemoteRegistration", )(function* (): Effect.fn.Return { if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { @@ -1106,9 +1009,6 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( ), ); if (primed) { - logRegistrationDebug("live activity card primed", { - activeCount: aggregate.activeCount, - }); activities = [primed]; } } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx index 9ad4790faab15..5916d5f5749ed 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -57,12 +57,9 @@ export function ArchivedThreadsRouteScreen() { }), [environmentLabels, searchQuery, selectedEnvironmentId, snapshots, sortOrder], ); - const refreshChangedEnvironment = useCallback( - (thread: { readonly environmentId: EnvironmentId }) => { - refreshArchivedThreadsForEnvironment(thread.environmentId); - }, - [], - ); + const refreshChangedEnvironment = (thread: { readonly environmentId: EnvironmentId }) => { + refreshArchivedThreadsForEnvironment(thread.environmentId); + }; const { unarchiveThread, confirmDeleteThread } = useArchivedThreadListActions(refreshChangedEnvironment); diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 90f722e53669f..62e43c04cda61 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -15,7 +15,7 @@ import { useNavigation } from "@react-navigation/native"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppBar } from "../../components/ui/AppBar"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { useCallback, useMemo, useRef, type ComponentProps } from "react"; +import { useMemo, useRef, type ComponentProps } from "react"; import { Platform, RefreshControl, useWindowDimensions, View } from "react-native"; import { GestureDetector, useNativeGesture } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -341,55 +341,46 @@ export function ArchivedThreadsScreen(props: { } return items; }, [environmentLabelsById, props.groups, serverConfigs]); - const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { + const handleSwipeableWillOpen = (methods: SwipeableMethods) => { if (openSwipeableRef.current && openSwipeableRef.current !== methods) { openSwipeableRef.current.close(); } openSwipeableRef.current = methods; - }, []); - const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { + }; + const handleSwipeableClose = (methods: SwipeableMethods) => { if (openSwipeableRef.current === methods) { openSwipeableRef.current = null; } - }, []); + }; const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null; const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null; - const renderListItem = useCallback( - ({ item }: { item: ArchivedThreadListItem }) => { - if (item.kind === "project") { - return ( - - - - ); - } - + const renderListItem = ({ item }: { item: ArchivedThreadListItem }) => { + if (item.kind === "project") { return ( - onDeleteThread(item.thread)} - onSwipeableClose={handleSwipeableClose} - onSwipeableWillOpen={handleSwipeableWillOpen} - onUnarchive={() => onUnarchiveThread(item.thread)} - simultaneousSwipeGesture={archiveScrollGesture} - thread={item.thread} - /> + + + ); - }, - [ - archiveScrollGesture, - handleSwipeableClose, - handleSwipeableWillOpen, - onDeleteThread, - onUnarchiveThread, - ], - ); + } + + return ( + onDeleteThread(item.thread)} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + onUnarchive={() => onUnarchiveThread(item.thread)} + simultaneousSwipeGesture={archiveScrollGesture} + thread={item.thread} + /> + ); + }; const listEmptyComponent = useMemo(() => { if (isInitialLoad) { return ( diff --git a/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts b/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts index d18cc230c639c..aeec98f116e93 100644 --- a/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts +++ b/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts @@ -5,7 +5,7 @@ import { makeArchivedThreadsEnvironmentKey, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { appAtomRegistry } from "../../state/atom-registry"; import { orchestrationEnvironment } from "../../state/orchestration"; @@ -37,11 +37,11 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray { + const refresh = () => { for (const environmentId of environmentIds) { appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); } - }, [environmentIds]); + }; return { ...result, refresh }; } diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx index a861a6015fb7c..a797ed7b13d86 100644 --- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -6,7 +6,7 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { EnvironmentId, ThreadId, type PreviewSessionSnapshot } from "@t3tools/contracts"; import { normalizePreviewUrl, previewPdfAssetUrl } from "@t3tools/shared/preview"; import * as Option from "effect/Option"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Platform, TextInput, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -37,7 +37,7 @@ type BrowserPreviewRouteScreenProps = StaticScreenProps<{ export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) { const navigation = useNavigation(); - const onClose = useCallback(() => navigation.goBack(), [navigation]); + const onClose = () => navigation.goBack(); return ( { if (focused && loaded && tabs.length === 0) onClose(); }, [focused, loaded, tabs.length, onClose]); - const onPictureInPicture = useCallback((state: PreviewPictureInPictureState, detail?: string) => { + const onPictureInPicture = (state: PreviewPictureInPictureState, detail?: string) => { setPictureInPicture({ supported: state.supported, active: state.active }); if (detail) Dialog.alert("Picture in picture is unavailable", detail); - }, []); + }; const selectTab = (next: string) => { setPictureInPicture(NO_PICTURE_IN_PICTURE); setSelectedTabId(next); diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index bf072979166de..3fae93f483bcc 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -15,7 +15,6 @@ import { useEffectEvent, useImperativeHandle, useLayoutEffect, - useMemo, useRef, useState, type Ref, @@ -308,13 +307,10 @@ function PreviewStreamDocumentView({ streamEnded(); }; }, []); - const source = useMemo( - () => ({ - html: previewStreamDocument(configuration, previewStreamScript), - baseUrl: Platform.OS === "android" ? "https://localhost/" : "file:///", - }), - [configuration], - ); + const source = { + html: previewStreamDocument(configuration, previewStreamScript), + baseUrl: Platform.OS === "android" ? "https://localhost/" : "file:///", + }; useImperativeHandle(ref, () => ({ command, paste, diff --git a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx index ea5f0d9c8c0ca..43d7c0813cef5 100644 --- a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx +++ b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx @@ -2,7 +2,7 @@ import { useIsFocused } from "@react-navigation/native"; import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; import { previewPdfAssetUrl } from "@t3tools/shared/preview"; import * as Option from "effect/Option"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; @@ -54,12 +54,12 @@ export function ThreadBrowserFloat(props: { const foreground = useAppForeground(); const tab = props.tabs.find((entry) => entry.tabId === tabId) ?? null; const onOpen = props.onOpen; - const open = useCallback(() => { + const open = () => { if (tabId === null) return; setTabId(null); onOpen(tabId); - }, [onOpen, tabId]); - const close = useCallback(() => setTabId(null), []); + }; + const close = () => setTabId(null); if (!tab || !focused) return null; return ( ): void { - logger.log(event, data); -} diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 13860d4bea178..2f9618e0953cd 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -78,9 +78,6 @@ function layerCloudClient() { Layer.succeed( MobileStorage.MobileStorage, MobileStorage.MobileStorage.of({ - loadSavedConnections: Effect.succeed([]), - saveConnection: () => Effect.void, - clearSavedConnection: () => Effect.void, loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), loadAgentAwarenessDeviceId: Effect.succeed("device-1"), loadAgentAwarenessRegistrationRecord: Effect.succeed(null), diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 507b84629b07d..3b34f6fb2d664 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -12,18 +12,13 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useCallback } from "react"; import * as Runtime from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; -import { cloudDebugLog } from "./cloudDebugLog"; const managedRelayAtomRuntime = Atom.runtime(Runtime.layer); -export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime, { - onQueryEvent: (event) => - cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), -}); +const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); const managedRelayMutationScheduler = createAtomCommandScheduler(); @@ -53,11 +48,11 @@ export function useManagedRelayEnvironments() { : EMPTY_ENVIRONMENTS_ATOM; const result = useAtomValue(atom); const snapshot = readManagedRelaySnapshotState(result); - const refresh = useCallback(() => { + const refresh = () => { if (accountId) { managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); } - }, [accountId]); + }; return { ...snapshot, diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 15ae30ee9407d..6d1af92d5fb91 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -14,7 +14,7 @@ import { resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Pressable, type TextLayoutEvent, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; @@ -77,10 +77,8 @@ function CloudEnvironmentRowsContent( const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; - const handleConnectCloudEnvironment = useCallback( - (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), - [controller], - ); + const handleConnectCloudEnvironment = (entry: RelayEnvironmentView) => + controller.connectRelayEnvironment(entry.environment); const discoveredDescriptors = new Map( controller.relayEnvironments.flatMap((entry) => @@ -90,9 +88,9 @@ function CloudEnvironmentRowsContent( ), ); - const handleToggleCloudError = useCallback((environmentId: string) => { + const handleToggleCloudError = (environmentId: string) => { setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); - }, []); + }; const showHeader = props.showHeader ?? true; @@ -307,21 +305,18 @@ function CloudEnvironmentRowShell(props: { const StatusContainer = errorCanExpand ? Pressable : View; const ContentContainer = props.onOpen || props.onRemove ? Pressable : View; const ChevronContainer = props.onOpen || props.onRemove ? Pressable : View; - const onMeasuredErrorTextLayout = useCallback( - (event: TextLayoutEvent) => { - if (!props.connectionError) { - return; - } - const nextLineCount = event.nativeEvent.lines.length; - setErrorMeasurement((currentMeasurement) => - currentMeasurement?.text === measuredErrorText && - currentMeasurement.lineCount === nextLineCount - ? currentMeasurement - : { text: measuredErrorText, lineCount: nextLineCount }, - ); - }, - [measuredErrorText, props.connectionError], - ); + const onMeasuredErrorTextLayout = (event: TextLayoutEvent) => { + if (!props.connectionError) { + return; + } + const nextLineCount = event.nativeEvent.lines.length; + setErrorMeasurement((currentMeasurement) => + currentMeasurement?.text === measuredErrorText && + currentMeasurement.lineCount === nextLineCount + ? currentMeasurement + : { text: measuredErrorText, lineCount: nextLineCount }, + ); + }; return ( { + const handleSave = async () => { const result = await props.onUpdate(props.environment.environmentId, { label: label.trim(), displayUrl: url.trim(), @@ -88,7 +88,7 @@ export function ConnectionEnvironmentRow(props: { "Could not update environment", error instanceof Error ? error.message : "The environment could not be updated.", ); - }, [label, url, props]); + }; return ( diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index 1c89305b8fdbd..bac5e09721d00 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -3,7 +3,7 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScroll import { useNavigation } from "@react-navigation/native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -24,9 +24,9 @@ export function ConnectionsRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [expandedId, setExpandedId] = useState(null); - const handleToggle = useCallback((environmentId: EnvironmentId) => { + const handleToggle = (environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); - }, []); + }; return ( diff --git a/apps/mobile/src/features/connection/devAutoConnect.test.ts b/apps/mobile/src/features/connection/devAutoConnect.test.ts index 760e68ac598c1..f4261a4eacd09 100644 --- a/apps/mobile/src/features/connection/devAutoConnect.test.ts +++ b/apps/mobile/src/features/connection/devAutoConnect.test.ts @@ -89,7 +89,7 @@ it("pairs from a nested settings link once, reports failure, and closes the form const state = settingsLink({ pairingUrl: ` ${PAIRING_URL} `, autoConnect: "1" }); expect(startDevAutoConnect(state)).toBe(state); - expect(harness.pair).toHaveBeenCalledExactlyOnceWith(PAIRING_URL, undefined); + expect(harness.pair).toHaveBeenCalledExactlyOnceWith(PAIRING_URL); expect(appAtomRegistry.get(devAutoConnectAttemptAtom)).toEqual({ pairingUrl: PAIRING_URL, status: "connecting", diff --git a/apps/mobile/src/features/connection/devAutoConnect.ts b/apps/mobile/src/features/connection/devAutoConnect.ts index 213504d8dfd55..67f3ab1eee942 100644 --- a/apps/mobile/src/features/connection/devAutoConnect.ts +++ b/apps/mobile/src/features/connection/devAutoConnect.ts @@ -1,9 +1,8 @@ import type { NavigationState, PartialState } from "@react-navigation/native"; import { CommonActions, StackActions } from "@react-navigation/native"; -import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; -import { findLinkedRoute } from "../../lib/appLinking"; +import { findLinkedRoute, updateLinkedRoute } from "../../lib/appLinking"; import { rootNavigationRef, whenRootNavigationReady } from "../../lib/rootNavigation"; import { appAtomRegistry } from "../../state/atom-registry"; import { leaveSettingsPage } from "../settings/settings-navigation"; @@ -44,10 +43,10 @@ function isAutoConnectFlag(value: unknown): boolean { return value === "1" || value === "true"; } -async function connect(pairingUrl: string, expectedEnvironmentId: EnvironmentId | undefined) { +async function connect(pairingUrl: string) { const attempt: DevAutoConnectAttempt = { pairingUrl, status: "connecting" }; appAtomRegistry.set(devAutoConnectAttemptAtom, attempt); - const outcome = await pairEnvironment(pairingUrl, expectedEnvironmentId); + const outcome = await pairEnvironment(pairingUrl); // A later link replaced or cleared this attempt; its result belongs to no open form. if (appAtomRegistry.get(devAutoConnectAttemptAtom) !== attempt) return; if (!outcome.ok) { @@ -71,6 +70,20 @@ async function connect(pairingUrl: string, expectedEnvironmentId: EnvironmentId }); } +/** + * `routeFor` makes a pairing an "Add route" to a saved environment, which skips the guard against + * a pairing taking over that environment's id. Only in-app navigation may set it, never a link. + */ +export function withoutPairingTarget( + state: PartialState | undefined, +): PartialState | undefined { + return updateLinkedRoute(state, PAIRING_FORM_ROUTES, (route) => { + if (route.params === undefined || !("routeFor" in route.params)) return route; + const { routeFor: _routeFor, ...params } = route.params as { readonly routeFor?: unknown }; + return { ...route, params }; + }); +} + /** * `connections/new?pairingUrl=…&autoConnect=1` (or `settings/environment-new?…`) pairs from the * link itself, so a remount of the form never pairs twice. Production builds ignore the flag so @@ -85,11 +98,10 @@ export function startDevAutoConnect( const params = (route.params ?? {}) as { readonly autoConnect?: unknown; readonly pairingUrl?: unknown; - readonly routeFor?: EnvironmentId; }; const pairingUrl = typeof params.pairingUrl === "string" ? params.pairingUrl.trim() : ""; if (isAutoConnectFlag(params.autoConnect) && pairingUrl.length > 0) { - void connect(pairingUrl, params.routeFor); + void connect(pairingUrl); } else { // Only links put a pairing URL on the form, so a form opened by any other link starts // clean instead of showing an earlier auto-connect's failure for the same URL. diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index f88b2563a7d65..bb9d9f94fe23b 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -9,7 +9,7 @@ import type { RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { environmentCatalog } from "../../connection/catalog"; import { @@ -69,54 +69,35 @@ export function useConnectionController() { [registeredIds, relayEnvironments], ); - const connectPairingUrl = useCallback( - (pairingUrl: string, expectedEnvironmentId?: EnvironmentId) => - connectPairingUrlMutation({ - pairingUrl, - ...(expectedEnvironmentId === undefined ? {} : { expectedEnvironmentId }), - }), - [connectPairingUrlMutation], - ); - const connectRelayEnvironment = useCallback( - (environment: RelayClientEnvironmentRecord) => - registerEnvironment( - new RelayConnectionRegistration({ - target: new RelayConnectionTarget({ - environmentId: environment.environmentId, - label: environment.label, - }), + const connectPairingUrl = (pairingUrl: string, expectedEnvironmentId?: EnvironmentId) => + connectPairingUrlMutation({ + pairingUrl, + ...(expectedEnvironmentId === undefined ? {} : { expectedEnvironmentId }), + }); + const connectRelayEnvironment = (environment: RelayClientEnvironmentRecord) => + registerEnvironment( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: environment.environmentId, + label: environment.label, }), - ), - [registerEnvironment], - ); - const removeEnvironment = useCallback( - (environmentId: EnvironmentId) => - unregisterServerPushDevice(environmentId).then(() => - removeEnvironmentMutation(environmentId), - ), - [removeEnvironmentMutation], - ); - const retryEnvironment = useCallback( - (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId), - [retryEnvironmentMutation], - ); - const setEnvironmentEnabled = useCallback( - (environmentId: EnvironmentId, enabled: boolean) => - setEnvironmentEnabledMutation({ environmentId, enabled }), - [setEnvironmentEnabledMutation], - ); - const updateEnvironment = useCallback( - ( - environmentId: EnvironmentId, - updates: { readonly label: string; readonly displayUrl: string }, - ) => - updateBearer({ - environmentId, - label: updates.label, - httpBaseUrl: updates.displayUrl, }), - [updateBearer], - ); + ); + const removeEnvironment = (environmentId: EnvironmentId) => + unregisterServerPushDevice(environmentId).then(() => removeEnvironmentMutation(environmentId)); + const retryEnvironment = (environmentId: EnvironmentId) => + retryEnvironmentMutation(environmentId); + const setEnvironmentEnabled = (environmentId: EnvironmentId, enabled: boolean) => + setEnvironmentEnabledMutation({ environmentId, enabled }); + const updateEnvironment = ( + environmentId: EnvironmentId, + updates: { readonly label: string; readonly displayUrl: string }, + ) => + updateBearer({ + environmentId, + label: updates.label, + httpBaseUrl: updates.displayUrl, + }); return { connectedEnvironments, diff --git a/apps/mobile/src/features/development/behavior-probe-entry.ts b/apps/mobile/src/features/development/behavior-probe-entry.ts new file mode 100644 index 0000000000000..0771a35dd1c62 --- /dev/null +++ b/apps/mobile/src/features/development/behavior-probe-entry.ts @@ -0,0 +1,4 @@ +// Metro folds this condition before collecting dependencies, excluding the probe from release bundles. +if (__DEV__ && process.env.EXPO_PUBLIC_T3_BEHAVIOR_PROBE === "1") { + require("./behavior-probe"); +} diff --git a/apps/mobile/src/features/development/behavior-probe.ts b/apps/mobile/src/features/development/behavior-probe.ts new file mode 100644 index 0000000000000..03f9727602e51 --- /dev/null +++ b/apps/mobile/src/features/development/behavior-probe.ts @@ -0,0 +1,333 @@ +// Read by evals/mobile-behavior through the Hermes inspector; product modules load lazily so import order is unchanged. + +interface Fiber { + readonly tag: number; + readonly type: unknown; + readonly child: Fiber | null; + readonly sibling: Fiber | null; + readonly memoizedProps: Record | null; +} +interface DevToolsHook { + onCommitFiberRoot?: (id: unknown, root: { current: Fiber }, ...rest: unknown[]) => unknown; + __t3BehaviorProbe?: true; +} +interface ListState { + readonly scroll: number; + readonly scrollLength: number; + readonly contentLength: number; + readonly isAtEnd: boolean; + readonly start: number; + readonly end: number; + readonly data: ReadonlyArray<{ readonly id?: string; readonly key?: string }>; + readonly positionAtIndex: (index: number) => number; +} +interface Feed { + readonly threadId: string; + readonly environmentId: string; + readonly list: { readonly current: { getState?: () => ListState } | null } | undefined; + following: boolean | null; +} +interface Atom { + readonly _atom: true; +} + +// Every root React has committed; LogBox and other overlays commit their own. +const roots = new Set<{ current: Fiber }>(); + +function installCommitHook() { + const hook = (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: DevToolsHook }) + .__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || typeof hook.onCommitFiberRoot !== "function") { + setTimeout(installCommitHook, 50); + return; + } + if (hook.__t3BehaviorProbe) return; + hook.__t3BehaviorProbe = true; + const original = hook.onCommitFiberRoot.bind(hook); + hook.onCommitFiberRoot = (id, next, ...rest) => { + roots.add(next); + return original(id, next, ...rest); + }; +} + +/** Visits every mounted fiber; `enter` returns the context passed to the fiber's children. */ +function walk(fiber: Fiber | null, context: C, enter: (fiber: Fiber, context: C) => C) { + let node = fiber; + while (node) { + const next = enter(node, context); + if (node.child) walk(node.child, next, enter); + node = node.sibling; + } +} + +const readAtom = (atom: Atom): T => { + const { appAtomRegistry } = require("../../state/atom-registry") as { + appAtomRegistry: { get: (atom: Atom) => unknown }; + }; + return appAtomRegistry.get(atom) as T; +}; + +/** Mounted thread feeds, the composer text the native editor was given, and the status pill. */ +function scanTree() { + const feeds = new Map(); + const editors: Array<{ value: string; expanded: boolean }> = []; + let floatingStatus: string | null = null; + let homeList: Array<{ + type: string; + threadId?: string; + status?: string; + pinned?: boolean; + count?: number; + expanded?: boolean; + }> | null = null; + const enter = (fiber: Fiber, feed: Feed | null): Feed | null => { + const props = fiber.memoizedProps; + if (!props || typeof props !== "object") return feed; + if ("listRef" in props && typeof props.threadId === "string" && "detailRef" in props) { + const existing = feeds.get(props.threadId); + if (existing) return existing; + const created: Feed = { + threadId: props.threadId, + environmentId: String(props.environmentId), + list: props.listRef as Feed["list"], + following: null, + }; + feeds.set(props.threadId, created); + return created; + } + if (feed && "maintainScrollAtEnd" in props && "maintainVisibleContentPosition" in props) + feed.following = props.maintainScrollAtEnd !== false; + if (typeof props.value === "string" && "chipsInert" in props) + editors.push({ value: props.value, expanded: props.chipsInert === false }); + if ("status" in props && "showScrollToEnd" in props) { + const status = props.status as { kind?: string } | null; + floatingStatus = status?.kind ?? null; + } + if (homeList === null && Array.isArray(props.data) && "renderItem" in props) { + const data = props.data as ReadonlyArray<{ + type?: string; + item?: { thread?: { id?: string }; pinned?: boolean }; + count?: number; + expanded?: boolean; + }>; + if (data.some((entry) => entry?.type?.startsWith("v2-"))) { + const { resolveThreadListV2Status } = require("../threads/threadListV2") as { + resolveThreadListV2Status: (thread: unknown) => string; + }; + homeList = data.map((entry) => ({ + type: String(entry.type), + ...(entry.item?.thread?.id + ? { + threadId: entry.item.thread.id, + status: resolveThreadListV2Status(entry.item.thread), + pinned: entry.item.pinned === true, + } + : {}), + ...(typeof entry.count === "number" ? { count: entry.count } : {}), + ...(typeof entry.expanded === "boolean" ? { expanded: entry.expanded } : {}), + })); + } + } + return feed; + }; + for (const root of roots) walk(root.current, null, enter); + return { feeds: [...feeds.values()], editors, floatingStatus, homeList }; +} + +function feedState(feed: Feed) { + const state = feed.list?.current?.getState?.(); + if (!state) return { threadId: feed.threadId, ready: false as const }; + const start = Math.max(0, state.start); + const end = Math.min(state.data.length - 1, state.end); + const rows: Record = {}; + for (let index = start; index <= end; index++) { + const row = state.data[index]; + const key = row?.id ?? row?.key; + if (key) rows[key] = Math.round(state.positionAtIndex(index) - state.scroll); + } + return { + threadId: feed.threadId, + ready: true as const, + scroll: Math.round(state.scroll), + viewport: Math.round(state.scrollLength), + content: Math.round(state.contentLength), + fromEnd: Math.round(state.contentLength - state.scroll - state.scrollLength), + atEnd: state.isAtEnd, + following: feed.following, + count: state.data.length, + /** Screen y of each visible row, keyed by row id. */ + rows, + }; +} + +function route() { + const { rootNavigationRef } = + require("../../lib/rootNavigation") as typeof import("../../lib/rootNavigation"); + if (!rootNavigationRef.isReady()) return null; + const current = rootNavigationRef.getCurrentRoute(); + const { workspaceHasColumns } = require("../workspace/workspace-selection-store") as { + workspaceHasColumns: () => boolean; + }; + return { + name: current?.name ?? null, + params: (current?.params ?? {}) as Record, + columns: workspaceHasColumns(), + }; +} + +function snapshot() { + const scan = scanTree(); + const at = route(); + const threadId = typeof at?.params.threadId === "string" ? at.params.threadId : null; + const environmentId = + typeof at?.params.environmentId === "string" ? at.params.environmentId : null; + const threadKey = threadId && environmentId ? `${environmentId}:${threadId}` : null; + const { composerDraftsAtom } = require("../../state/use-composer-drafts") as { + composerDraftsAtom: Atom; + }; + const { threadComposerErrorsAtom } = require("../../state/thread-composer-error") as { + threadComposerErrorsAtom: Atom; + }; + const drafts = readAtom>(composerDraftsAtom); + const errors = readAtom>(threadComposerErrorsAtom); + const { KeyboardController } = require("react-native-keyboard-controller") as { + KeyboardController: { isVisible: () => boolean }; + }; + const feed = visibleFeed(scan.feeds); + const { environmentThreadShells } = require("../../state/threads") as { + environmentThreadShells: { threadShellsAtom: Atom }; + }; + const { resolveThreadListV2Status } = require("../threads/threadListV2") as { + resolveThreadListV2Status: (thread: unknown) => string; + }; + const shells = readAtom>( + environmentThreadShells.threadShellsAtom, + ); + return { + /** Saved environments the app has threads from. */ + environments: [...new Set(shells.map((shell) => shell.environmentId))], + /** The app's own status for every thread it knows, including rows folded into a shelf. */ + threads: Object.fromEntries( + shells.map((shell) => [shell.id, resolveThreadListV2Status(shell)]), + ), + route: at, + feed: feed ? feedState(feed) : null, + composer: { + key: threadKey, + draft: threadKey ? (drafts[threadKey]?.text ?? "") : null, + drafts: Object.fromEntries( + Object.entries(drafts).map(([key, draft]) => [key, draft.text ?? ""]), + ), + editors: scan.editors, + error: threadKey ? (errors[threadKey]?.message ?? null) : null, + keyboard: KeyboardController.isVisible(), + }, + floatingStatus: scan.floatingStatus, + homeList: scan.homeList, + }; +} + +const frames = { js: [] as number[], on: false }; + +function jsFrame(time: number) { + if (!frames.on) return; + frames.js.push(time); + requestAnimationFrame(jsFrame); +} + +function uiFramesStart() { + "worklet"; + const ui = globalThis as { __t3UiFrames?: number[]; __t3UiFramesOn?: boolean }; + ui.__t3UiFrames = []; + ui.__t3UiFramesOn = true; + const tick = (time: number) => { + if (!ui.__t3UiFramesOn) return; + ui.__t3UiFrames!.push(time); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} + +function uiFramesStop() { + "worklet"; + const ui = globalThis as { __t3UiFrames?: number[]; __t3UiFramesOn?: boolean }; + ui.__t3UiFramesOn = false; + return ui.__t3UiFrames ?? []; +} + +let samples: Array> = []; +let sampled: Feed | null = null; + +// The feed is found once per recording; a tree walk on every frame would itself cause jank. +function sampleFeed(time: number) { + if (!sampled) return; + const { following: _following, ...state } = feedState(sampled); + samples.push({ at: time, ...state }); + requestAnimationFrame(sampleFeed); +} + +function visibleFeed(feeds: ReadonlyArray) { + const threadId = route()?.params.threadId; + return feeds.find((feed) => feed.threadId === threadId) ?? feeds.at(-1) ?? null; +} + +installCommitHook(); + +Object.assign(globalThis, { + __t3Behavior: { + open(url: string) { + const { Linking } = require("react-native") as typeof import("react-native"); + void Linking.openURL(url); + return true; + }, + /** Restarts the JS app; deferred so the inspector call can reply first. */ + reload() { + const { DevSettings } = require("react-native") as typeof import("react-native"); + setTimeout(() => DevSettings.reload(), 100); + return true; + }, + state: () => JSON.stringify(snapshot()), + /** Removes these saved environments (and their cached threads) through the app's own command. */ + async forgetEnvironments(ids: ReadonlyArray) { + const { runAtomCommand } = require("@t3tools/client-runtime/state/runtime") as { + runAtomCommand: ( + registry: unknown, + command: unknown, + value: unknown, + options: object, + ) => Promise; + }; + const { appAtomRegistry } = require("../../state/atom-registry") as { + appAtomRegistry: unknown; + }; + const { environmentCatalog } = require("../../connection/catalog") as { + environmentCatalog: { remove: unknown }; + }; + for (const id of ids) + await runAtomCommand(appAtomRegistry, environmentCatalog.remove, id, { + label: "behavior probe", + }); + return true; + }, + /** Records JS and UI frame times, and the visible feed on every JS frame. */ + recordStart(withFeed: boolean) { + const { scheduleOnUI } = + require("react-native-worklets") as typeof import("react-native-worklets"); + frames.js = []; + frames.on = true; + requestAnimationFrame(jsFrame); + scheduleOnUI(uiFramesStart); + samples = []; + sampled = withFeed ? visibleFeed(scanTree().feeds) : null; + if (sampled) requestAnimationFrame(sampleFeed); + return true; + }, + recordStop() { + const { runOnUISync } = + require("react-native-worklets") as typeof import("react-native-worklets"); + frames.on = false; + sampled = null; + return JSON.stringify({ js: frames.js, ui: runOnUISync(uiFramesStop), feed: samples }); + }, + }, +}); diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx index 8c0cbff3352ab..01532c826f847 100644 --- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -16,7 +16,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { crashReportingAvailable, setCrashReportsEnabled } from "../observability/observe"; -import { SettingsActionRow } from "../settings/components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { SettingsSection } from "../settings/components/SettingsSection"; import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; diff --git a/apps/mobile/src/features/files/AudioFilePreview.tsx b/apps/mobile/src/features/files/AudioFilePreview.tsx index ea042ab7da1c7..e1b3214d38290 100644 --- a/apps/mobile/src/features/files/AudioFilePreview.tsx +++ b/apps/mobile/src/features/files/AudioFilePreview.tsx @@ -1,6 +1,6 @@ import { Button } from "../../components/ui/Button"; import { useAudioPlayerStatus } from "expo-audio"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { AppState, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { useFullscreenVideoOpen, useMediaAudioSessionHold } from "../../lib/videoAudioSession"; @@ -16,10 +16,7 @@ export function AudioFilePreview(props: { uri: string; onRetry: () => void }) { const player = usePlaybackAudioPlayer(props.uri); const status = useAudioPlayerStatus(player); const [playbackRequested, setPlaybackRequested] = useState(false); - const playback = useMemo( - () => createAudioPlaybackSession(player, false, setPlaybackRequested), - [player], - ); + const playback = createAudioPlaybackSession(player, false, setPlaybackRequested); const [seekError, setSeekError] = useState(false); // Video teardown deactivates the shared iOS session, which would stop this player. useMediaAudioSessionHold(status.playing); diff --git a/apps/mobile/src/features/files/FilesHeader.tsx b/apps/mobile/src/features/files/FilesHeader.tsx index 1c373f78f3507..b1e8c825572bd 100644 --- a/apps/mobile/src/features/files/FilesHeader.tsx +++ b/apps/mobile/src/features/files/FilesHeader.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { Keyboard, type TextInputInstance, View } from "react-native"; import { @@ -23,11 +23,11 @@ export function FilesHeader(props: { const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; const { onSearchQueryChange } = props; - const closeSearch = useCallback(() => { + const closeSearch = () => { onSearchQueryChange(""); setSearchOpen(false); Keyboard.dismiss(); - }, [onSearchQueryChange]); + }; useHardwareBack(searching, () => { closeSearch(); diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 7729347c9b05c..60b2c902a76e5 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo, useState } from "react"; +import { useId, useState } from "react"; import { Image, Pressable, View } from "react-native"; import { FilePreviewLoading } from "./FilePreviewFeedback"; @@ -18,10 +18,7 @@ function ResolvedWorkspaceFileImagePreview(props: { const [preview, setPreview] = useState(null); const sourceIdentifier = useId(); const mediaActions = useMediaActions(props.actionsSource); - const imageSource = useMemo( - () => ({ uri: props.uri, cache: "force-cache" as const }), - [props.uri], - ); + const imageSource = { uri: props.uri, cache: "force-cache" as const }; return ( diff --git a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx index 79df6d05c700f..63c3e7be0c65e 100644 --- a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx +++ b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx @@ -1,5 +1,4 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback } from "react"; import { View } from "react-native"; import { EmptyState } from "../../components/ui/EmptyState"; @@ -17,10 +16,10 @@ export function WorkspaceFilePreviewError(props: { const { environmentId, onRetry } = props; const environment = useEnvironmentPresentation(environmentId); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); - const retryConnection = useCallback(() => { + const retryConnection = () => { if (environmentId !== null) void retryEnvironment(environmentId); onRetry(); - }, [environmentId, onRetry, retryEnvironment]); + }; if (props.reason === "disconnected") { return ( diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index aa6a01cdb17a4..d889e88825830 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -1,15 +1,9 @@ -import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; +import { joinPath, resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; import { isWorkspaceAudioPreviewPath, isWorkspaceVideoPreviewPath, } from "@t3tools/shared/filePreview"; -export interface FileBreadcrumb { - readonly label: string; - readonly path: string; - readonly kind: "project" | "directory" | "file"; -} - function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } @@ -27,14 +21,6 @@ function isWindowsPathStyle(value: string): boolean { return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value); } -function joinPath(base: string, next: string, separator: "/" | "\\"): string { - const cleanBase = base.replace(/[\\/]+$/, ""); - if (separator === "\\") { - return `${cleanBase}\\${next.replaceAll("/", "\\")}`; - } - return `${cleanBase}/${next.replace(/^\/+/, "")}`; -} - export function basename(path: string): string { const parts = path.split(/[\\/]/).filter(Boolean); return parts.at(-1) ?? path; @@ -123,18 +109,6 @@ export function isMarkdownPreviewFile(path: string): boolean { return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); } -export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] { - const parts = relativePath.split("/").filter(Boolean); - return [ - { label: projectName, path: "", kind: "project" }, - ...parts.map((part, index) => ({ - label: part, - path: parts.slice(0, index + 1).join("/"), - kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), - })), - ]; -} - export function fileHeaderSubtitle(projectName: string, relativePath: string): string { const parentDir = relativePath.slice( 0, diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index 17b49d44ce41a..4e4b4b77171dd 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -90,16 +90,6 @@ export function buildFileTree(entries: ReadonlyArray): ReadonlyArr return [...root.children.values()].sort(compareNodes).map(freezeNode); } -export function defaultExpandedTreePaths(nodes: ReadonlyArray): ReadonlySet { - const expanded = new Set(); - for (const node of nodes) { - if (node.kind === "directory") { - expanded.add(node.path); - } - } - return expanded; -} - function flattenNode( output: VisibleFileTreeNode[], node: FileTreeNode, diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index d10746616d70f..0c3d3f772a8c0 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -3,7 +3,7 @@ import { environmentSession } from "../../state/session"; import type { EnvironmentId } from "@t3tools/contracts"; import { SymbolView } from "../../components/ui/AppSymbol"; import { ScreenContent } from "../../components/ui/ScreenContent"; -import { useCallback, useMemo, useState, type ComponentProps } from "react"; +import { useState, type ComponentProps } from "react"; import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; import { Screen, @@ -57,32 +57,25 @@ export function ThreadFileNavigatorPane(props: { cwd: canReadFiles ? props.cwd : null, searchQuery, }); - const handlePreviewFile = useCallback( - (relativePath: string) => { - preloadWorkspaceFileContents({ - cwd: props.cwd, - environmentId: props.environmentId, - relativePath, - theme: highlightTheme, - }); + const handlePreviewFile = (relativePath: string) => { + preloadWorkspaceFileContents({ + cwd: props.cwd, + environmentId: props.environmentId, + relativePath, + theme: highlightTheme, + }); + }; + const nativeHeaderRightBarButtonItems = [ + { + accessibilityLabel: "Close files", + icon: { name: "xmark", type: "sfSymbol" as const }, + identifier: "thread-file-navigator-close", + onPress: toggleAuxiliaryPane, + sharesBackground: false, + tintColor: foregroundColor, + type: "button" as const, }, - [highlightTheme, props.cwd, props.environmentId], - ); - const nativeHeaderRightBarButtonItems = useMemo( - () => - [ - { - accessibilityLabel: "Close files", - icon: { name: "xmark", type: "sfSymbol" as const }, - identifier: "thread-file-navigator-close", - onPress: toggleAuxiliaryPane, - sharesBackground: false, - tintColor: foregroundColor, - type: "button" as const, - }, - ] as ComponentProps["headerRightBarButtonItems"], - [foregroundColor, toggleAuxiliaryPane], - ); + ] as ComponentProps["headerRightBarButtonItems"]; const fileTree = ( 8) workspaces.delete(workspaces.keys().next().value!); setCache({ key: workspaceKey, workspaces }); } - const setDirectories = useCallback( - ( - 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) }; - }), - [workspaceKey], - ); + 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. @@ -538,16 +533,13 @@ export function useFileTreeEntries(input: { }); }; - const setExpandedPaths = useCallback( - (update: (current: ReadonlySet) => ReadonlySet) => - setDirectories((current) => { - const expandedPaths = update(current.expandedPaths); - return expandedPaths === current.expandedPaths - ? current - : { ...current, expandedPaths: new Set(expandedPaths) }; - }), - [setDirectories], - ); + 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, 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/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index ca55f384257f6..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, useMemo, useRef, useState, type ComponentProps } from "react"; +import { useRef, useState, type ComponentProps } from "react"; import { useWindowDimensions, View, @@ -45,12 +45,9 @@ export function AndroidHomeFabLayout(props: ComponentProps (floatingTabsEnabled && workspace ? { ...workspace, bottomClearance } : workspace), - [floatingTabsEnabled, workspace, bottomClearance], - ); + 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, @@ -59,7 +56,7 @@ export function AndroidHomeFabLayout(props: ComponentProps( - () => [ - { - 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 1864d885b7206..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, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -67,32 +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], - ); + 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, @@ -111,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], diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 5c7b2e9858f50..1edb92f0d556b 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -432,6 +432,7 @@ export function HomeScreen(props: HomeScreenProps) { machineByEnvironmentId, settlementEnvironmentIds, snoozeEnvironmentIds, + snoozePresetsByEnvironmentId, pinningEnvironmentIds, autoSettleOptOutEnvironmentIds, pinReorderEnvironmentIds, @@ -602,6 +603,7 @@ export function HomeScreen(props: HomeScreenProps) { settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} + snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} reorderSupported={ diff --git a/apps/mobile/src/features/home/ThreadListToolbar.tsx b/apps/mobile/src/features/home/ThreadListToolbar.tsx index 1a1c4297d7641..9e8c55fd2b5e5 100644 --- a/apps/mobile/src/features/home/ThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/ThreadListToolbar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; +import { useEffect, useRef, useState, type ComponentProps } from "react"; import { useNavigation, useNavigationState } from "@react-navigation/native"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { @@ -42,19 +42,19 @@ export function ThreadListToolbar(props: { const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; - const openSearch = useCallback(() => { + const openSearch = () => { onRequestVisibility?.(); setSearchOpen(true); searchRef.current?.focus(); return true; - }, [onRequestVisibility]); + }; useHardwareKeyboardCommand("focusSearch", openSearch); - const closeSearch = useCallback(() => { + const closeSearch = () => { onSearchQueryChange(""); setSearchOpen(false); Keyboard.dismiss(); - }, [onSearchQueryChange]); + }; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to BackHandler while searching to close search on Android back useEffect(() => { diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index f3e40419d05bc..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, @@ -716,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.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 3ba684bdff174..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.", @@ -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 b900108065caf..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( @@ -519,7 +556,7 @@ export function CommandPalette(props: { const dismissed = useRef(false); const dismissFallback = useRef | null>(null); - const handleDismissed = useCallback(() => { + const handleDismissed = () => { if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); dismissFallback.current = null; if (dismissed.current) return; @@ -527,7 +564,7 @@ export function CommandPalette(props: { // Present navigation sheets only after UIKit has dismissed this modal. props.onClose(); pendingAction.current?.(); - }, [props]); + }; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the dismiss fallback timer on unmount useEffect( () => () => { diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 8cd9e215e0f4a..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, @@ -134,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/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 c58cb479d2be5..eff3c6dfec456 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -626,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, @@ -936,6 +938,7 @@ function AdaptiveWorkspaceLayoutContent( flex: 1, width: contentTargetWidth, alignSelf: contentAnchor.end ? "flex-end" : "flex-start", + marginRight: contentAnchor.end ? contentEndGap : 0, } : { flex: 1 } } diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 2b7677929dcb1..e2697f4e4d238 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -19,16 +19,7 @@ import { type NativeStackNavigatorProps, type NativeStackTypeBag, } from "@react-navigation/native-stack"; -import { - Fragment, - use, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ComponentProps, -} from "react"; +import { Fragment, use, useEffect, useRef, useState, type ComponentProps } from "react"; import { StyleSheet, useWindowDimensions, View } from "react-native"; import { Split, type SplitHostCommands } from "react-native-screens"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -106,17 +97,13 @@ function ColumnScreen(props: { descriptor.options, ); const [presented, setPresented] = useState(false); - const primaryColumn = useMemo( - () => - props.primary - ? { - presented, - selectedThreadKey: props.selectedThreadKey ?? null, - selectedPullRequestKey: props.selectedPullRequestKey ?? null, - } - : null, - [props.primary, props.selectedThreadKey, props.selectedPullRequestKey, presented], - ); + const primaryColumn = props.primary + ? { + presented, + selectedThreadKey: props.selectedThreadKey ?? null, + selectedPullRequestKey: props.selectedPullRequestKey ?? null, + } + : null; const key = descriptor.route.key; return ( { - setScreens((current) => ({ - ...current, - routes: current.routes.filter((route) => route.key !== key), - completed: new Set([...current.completed, key]), - })); - if (native) { - const state = props.navigation.getState(); - const action = nativeWorkspacePopAction(state, key); - if (action) props.navigation.dispatch(action); - } - }, - [props.navigation], - ); + const handleNativeDismiss = (key: string, native: boolean) => { + setScreens((current) => ({ + ...current, + routes: current.routes.filter((route) => route.key !== key), + completed: new Set([...current.completed, key]), + })); + if (native) { + const state = props.navigation.getState(); + const action = nativeWorkspacePopAction(state, key); + if (action) props.navigation.dispatch(action); + } + }; const followCollapsedSplit = ( type: "willAppear" | "didAppear" | "willDisappear", key: string, @@ -506,6 +490,14 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean const presentation = props.descriptors[route.key]?.options.presentation; return presentation !== undefined && presentation !== "card"; }); + // The root's willDisappear reaches JS frames into the push, so hide the dock in the commit that + // pushes; the native events still own every later change, including back gestures. + const topDetailKey = projection.detail.at(-1)?.key; + const [pushedDetailKey, setPushedDetailKey] = useState(topDetailKey); + if (pushedDetailKey !== topDetailKey) { + setPushedDetailKey(topDetailKey); + if (topDetailKey !== undefined && primaryVisible) setPrimaryVisible(false); + } // Detail-only histories must stay single-stack: placeholder descriptors cannot own sidebar actions or setOptions. const primaryRoute = projection.primary; if (!primaryRoute) return ; diff --git a/apps/mobile/src/features/layout/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/phone-background/PhoneBackgroundFolders.tsx b/apps/mobile/src/features/phone-background/PhoneBackgroundFolders.tsx index af7dcb59a9d78..55d763cf419bb 100644 --- a/apps/mobile/src/features/phone-background/PhoneBackgroundFolders.tsx +++ b/apps/mobile/src/features/phone-background/PhoneBackgroundFolders.tsx @@ -7,7 +7,7 @@ import { Alert, Linking, View } from "react-native"; import type { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; -import { SettingsActionRow } from "../settings/components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { SettingsRow } from "../settings/components/SettingsRow"; import { usePhoneFolderRead } from "./phoneBackground"; import { diff --git a/apps/mobile/src/features/phone-background/PhoneBackgroundRouteScreen.tsx b/apps/mobile/src/features/phone-background/PhoneBackgroundRouteScreen.tsx index 1c1ab28c3491a..57ffe4a3a682a 100644 --- a/apps/mobile/src/features/phone-background/PhoneBackgroundRouteScreen.tsx +++ b/apps/mobile/src/features/phone-background/PhoneBackgroundRouteScreen.tsx @@ -21,8 +21,8 @@ import { Menu } from "../../components/ui/Menu"; import { ListRowLayout } from "../../components/ui/ListRowLayout"; import { ScreenScrollView } from "../../components/ui/ScreenScrollView"; import { updateMobilePreferencesAtom } from "../../state/preferences"; -import { SettingsActionRow } from "../settings/components/SettingsActionRow"; -import { SettingsChoiceRow } from "../settings/components/SettingsChoiceRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; +import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { SettingsSection } from "../settings/components/SettingsSection"; import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index 91acc7a9bfe6e..b251310ef12f9 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -24,12 +24,10 @@ const state = vi.hoisted(() => ({ workspaceRoot: string; }[], createResult: null as Promise | null, - cloneResult: null as Promise | null, createNewResult: null as Promise | null, startCloneResult: null as Promise | null, connectionStates: {} as Record, newProjectsRoot: null as string | null, - supportsCloneTracking: false, waitForProject: vi.fn(), dispatch: vi.fn(), alert: vi.fn(), @@ -114,10 +112,7 @@ vi.mock("../../state/entities", () => ({ [ "environment", { - environment: { - platform: { os: "linux" }, - capabilities: { projectCloneTracking: state.supportsCloneTracking }, - }, + environment: { platform: { os: "linux" }, capabilities: {} }, newProjectsRoot: state.newProjectsRoot, settings: { addProjectBaseDirectory: state.baseDirectory }, }, @@ -148,11 +143,10 @@ vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { discovery: (input: unknown) => input, - startProjectClone: () => state.startCloneResult, - cloneRepository: async ({ input }: { input: { destinationPath: string } }) => { - if (state.cloneResult) return state.cloneResult; + startProjectClone: async ({ input }: { input: { destinationPath: string } }) => { + if (state.startCloneResult) return state.startCloneResult; NodeFS.mkdirSync(input.destinationPath); - return AsyncResult.success({ cwd: input.destinationPath }); + return AsyncResult.success(undefined); }, }, })); @@ -563,30 +557,6 @@ describe("local project navigation", () => { expect(state.dispatch).not.toHaveBeenCalled(); }); - it("does not redirect a reopened flow when an earlier legacy clone finishes", async () => { - const { created, projected } = setup(); - state.scopes.add(AuthSourceControlWriteScope); - const cloned = Promise.withResolvers(); - state.cloneResult = cloned.promise; - const navigation = mountProjectFlow(false, true, true, "AddProjectDestination"); - try { - const submission = cloneAction()(); - navigation.back(); - navigation.push("AddProjectDestination"); - const remaining = navigation.getState(); - cloned.resolve(AsyncResult.success({ cwd: "/tmp/t3-mobile-audit-fixture/repo" })); - created.resolve(AsyncResult.success(undefined)); - projected.resolve({ environmentId: "environment", id: "project" }); - await submission; - - expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture/repo"]); - expect(navigation.getState()).toEqual(remaining); - expect(state.dispatch).not.toHaveBeenCalled(); - } finally { - state.cloneResult = null; - } - }); - it("does not redirect a reopened flow when an earlier new project finishes", async () => { const { projected } = setup(); state.newProjectsRoot = "/projects"; diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 37701e954fbbe..218d01041561d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -87,6 +87,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { ThemedSwitch } from "../../components/ui/ThemedSwitch"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; +import { suggestAppAccessForRemote } from "./suggestAppAccess"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { useRemoteConnectionStatus, @@ -110,7 +111,6 @@ interface EnvironmentOption { readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; - readonly supportsCloneTracking: boolean; } const environmentOptionOrder = Order.mapInput( @@ -401,7 +401,6 @@ function useEnvironmentOptions( connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, - supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true, }; }); return Arr.sort( @@ -999,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, }); @@ -1097,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, + ); } }); } @@ -1314,11 +1323,12 @@ export function AddProjectDestinationScreen(props: { readonly prompt?: string | string[]; }) { const openNewTaskDraft = useOpenNewTaskDraft(); - const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { + const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { reportFailure: false, }); - const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { + const checkAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, { reportFailure: false, + reportDefect: false, }); const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); @@ -1331,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); @@ -1367,65 +1376,47 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); - if (environment.supportsCloneTracking) { - const projectId = ProjectId.make(uuidv4()); - const title = inferProjectTitleFromPath(resolved.path); - const startResult = await startProjectClone({ - environmentId: environment.environmentId, - input: { - projectId, - title, - createdAt: new Date().toISOString(), - remoteUrl, - destinationPath: resolved.path, - }, - }); - if (AsyncResult.isFailure(startResult)) { - setError(errorMessage(Cause.squash(startResult.cause))); - } else { - // Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls. - const project = await waitForProject( - { environmentId: environment.environmentId, projectId }, - 15_000, - ); - if (project === null) { - setError( - "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", - ); - } else { - openNewTaskDraft( - { - environmentId: environment.environmentId, - projectId, - title, - cloning: "1", - ...(prompt - ? await draftWithPrompt(environment.environmentId, projectId, prompt).then( - (draftId) => ({ draftId }), - () => ({}), - ) - : {}), - }, - originRoute, - ); - } - } - setIsSubmitting(false); - return; - } - const cloneResult = await cloneRepository({ + const projectId = ProjectId.make(uuidv4()); + const title = inferProjectTitleFromPath(resolved.path); + const startResult = await startProjectClone({ environmentId: environment.environmentId, input: { + projectId, + title, + createdAt: new Date().toISOString(), remoteUrl, destinationPath: resolved.path, }, }); - if (AsyncResult.isFailure(cloneResult)) { - setError(errorMessage(Cause.squash(cloneResult.cause))); + if (AsyncResult.isFailure(startResult)) { + setError(errorMessage(Cause.squash(startResult.cause))); } else { - const createResult = await createProject(cloneResult.value.cwd, originRoute); - if (createResult && AsyncResult.isFailure(createResult)) { - setError(errorMessage(Cause.squash(createResult.cause))); + void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl); + // Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + } else { + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title, + cloning: "1", + ...(prompt + ? await draftWithPrompt(environment.environmentId, projectId, prompt).then( + (draftId) => ({ draftId }), + () => ({}), + ) + : {}), + }, + originRoute, + ); } } setIsSubmitting(false); diff --git a/apps/mobile/src/features/projects/suggestAppAccess.ts b/apps/mobile/src/features/projects/suggestAppAccess.ts new file mode 100644 index 0000000000000..9340d8dd836ad --- /dev/null +++ b/apps/mobile/src/features/projects/suggestAppAccess.ts @@ -0,0 +1,38 @@ +import type { + EnvironmentId, + SourceControlAppAccess, + SourceControlAppAccessInput, +} from "@t3tools/contracts"; +import { appSignInTargetForRemote } from "@t3tools/client-runtime/source-control-clients"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { Alert, Linking } from "react-native"; + +/** + * After a clone, suggests installing the forge app on the repository's owner when the server is + * signed in with it but the app does not reach the repository. + */ +export async function suggestAppAccessForRemote( + check: (value: { + readonly environmentId: EnvironmentId; + readonly input: SourceControlAppAccessInput; + }) => Promise>, + environmentId: EnvironmentId, + remoteUrl: string, +): Promise { + const target = appSignInTargetForRemote(remoteUrl); + if (target === null) return; + const result = await check({ environmentId, input: target.input }); + if (result._tag !== "Success" || result.value.status !== "not-covered") return; + const owner = target.input.repository.split("/")[0] ?? target.input.repository; + Alert.alert( + `Give ${target.appSignIn.appName} access to ${owner}`, + `It isn't installed there, so GitHub requests for ${target.input.repository} use your own login and its lower rate limit.`, + [ + { text: "Not now", style: "cancel" }, + { + text: "Install", + onPress: () => void Linking.openURL(target.appSignIn.installUrl).catch(() => undefined), + }, + ], + ); +} diff --git a/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts b/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts index 4028e6f56912d..e59670cc64966 100644 --- a/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts +++ b/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts @@ -333,8 +333,7 @@ function cloneRepository(repository: PromptLinkRepository, request: LinkedDraftR } const prompt = request.pullRequest === null && - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .projectCloneTracking === true + appAtomRegistry.get(environmentServerConfigsAtom).has(environmentId) ? request.prompt : null; const name = promptLinkRepositoryName(repository); 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 1e52f69870d26..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; 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/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index a9a5abaaea8ad..38ac98f3b2a84 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -2,7 +2,6 @@ import { useAtomValue } from "@effect/atom-react"; import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; -import { useMemo } from "react"; import { Platform, ScrollView } from "react-native"; import type { MenuProps } from "../../components/ui/Menu.types"; import { ListFilterMenu } from "../workspace/ListFilterMenu"; @@ -79,7 +78,7 @@ function useProjectsInSidebarOrder sortProjectsByGroupOrder(projects, scopes), [projects, scopes]); + return sortProjectsByGroupOrder(projects, scopes); } export interface PullRequestFilterMenu { diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index 93615a47a7deb..63946016d591f 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -9,7 +9,7 @@ import { usePullRequestMediaUrl, } from "./PullRequestMedia"; import { videoMimeType } from "@t3tools/shared/video"; -import { createContext, useContext, useCallback, useMemo, useState } from "react"; +import { createContext, useContext, useMemo, useState } from "react"; import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; import { linkReferenceAutolinks } from "./pullRequestAutolinks"; @@ -40,23 +40,17 @@ export function PullRequestMarkdownRepository(props: { }) { const provider = props.changeRequest?.provider; const url = props.changeRequest?.url; - const autolinks = useMemo( - () => - provider === undefined || url === undefined - ? null - : changeRequestReferenceAutolinker({ provider, url }), - [provider, url], - ); + const autolinks = + provider === undefined || url === undefined + ? null + : changeRequestReferenceAutolinker({ provider, url }); const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, projectId: target.input.projectId, }); const cwd = project?.workspaceRoot; - const media = useMemo( - () => (cwd ? { environmentId: target.environmentId, cwd } : null), - [target.environmentId, cwd], - ); + const media = cwd ? { environmentId: target.environmentId, cwd } : null; return ( {props.children} @@ -119,32 +113,23 @@ function PullRequestMarkdownContent(props: { }) { const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); - const markdown = useMemo( - () => linkReferenceAutolinks(props.markdown, autolinks), - [props.markdown, autolinks], - ); + const markdown = linkReferenceAutolinks(props.markdown, autolinks); const openPullRequest = useOpenPullRequest(); - const openLink = useCallback( - (href: string) => { - void openPullRequest(href); - }, - [openPullRequest], - ); - const gallery = useMemo( - () => - markdownImageSources(props.imageGroupMarkdown ?? props.markdown) - .filter( - (uri) => - /^https?:\/\//i.test(uri) && - videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null, - ) - .map((uri) => pullRequestImageSource(uri, mediaContext)), - [props.markdown, props.imageGroupMarkdown, mediaContext], - ); + const openLink = (href: string) => { + void openPullRequest(href); + }; + const gallery: FilePreviewItem[] = markdownImageSources( + props.imageGroupMarkdown ?? props.markdown, + ) + .filter( + (uri) => + /^https?:\/\//i.test(uri) && + videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null, + ) + .map((uri) => pullRequestImageSource(uri, mediaContext)); const [preview, setPreview] = useState(null); - const renderImage = useCallback( - (image) => , - [], + const renderImage: MarkdownImageRenderer = (image) => ( + ); const styles = useMarkdownPreviewStyles(); return ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx index 58590229fbf67..0665f045b8465 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useMemo, useState } from "react"; +import { createContext, useContext, useState } from "react"; import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; @@ -32,13 +32,10 @@ export const PullRequestMediaContext = createContext<{ export function usePullRequestMediaUrl(url: string) { const context = useContext(PullRequestMediaContext); const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl; - const resource = useMemo( - () => - context && fetchUrl - ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } - : null, - [context, fetchUrl], - ); + const resource: AssetResource | null = + context && fetchUrl + ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } + : null; const asset = useAssetUrlState(context?.environmentId ?? null, resource); const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource); return { diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 164b1dc965c7f..e0ad9c76e9bb4 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -82,6 +82,7 @@ import { pullRequestLabelColor } from "./pullRequestList"; import { PullRequestReactions } from "./PullRequestReactions"; import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; +import { SourceControlAppAccessNotice } from "./SourceControlAppAccessNotice"; import { usePullToRefresh } from "../../state/use-pull-to-refresh"; import { resolveNativeReviewDiffView, @@ -720,6 +721,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps {tab === "overview" && identity ? ( + {pr ? ( (null); const searching = tab.searchMode !== "resting"; - const openSearch = useCallback(() => { + const openSearch = () => { onRequestVisibility?.(); dispatch({ type: "scroll", @@ -34,13 +34,13 @@ export function PullRequestToolbar(props: { 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) return; diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index b8347d7b08099..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, type ReactNode } from "react"; +import { use, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useAtomValue } from "@effect/atom-react"; import { Keyboard, @@ -119,13 +119,10 @@ export function PullRequestsScreen( animated: false, }); }, [tab.searchMode, tab.scrollOffset, insets.top]); - const maintainVisiblePosition = useMemo( - () => ({ - data: tab.searchMode === "resting", - shouldRestorePosition: () => workspace.getScrollOffset("pullRequests") > 0, - }), - [tab.searchMode, workspace], - ); + 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; @@ -136,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( @@ -162,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), @@ -202,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) => ({ @@ -265,19 +258,16 @@ export function PullRequestsScreen( labelColors, }), }); - const renderPullRequest = useCallback( - ({ item }: { readonly item: EnvironmentPullRequestListEntry }) => ( - - ), - [selectPullRequest, selectedKey, actions, select, busyKey, sidebar], + const renderPullRequest = ({ item }: { readonly item: EnvironmentPullRequestListEntry }) => ( + ); const 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/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/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 753709e204955..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"; @@ -117,23 +117,23 @@ export function usePullRequestDiff( const nextCursor = pages.at(-1)?.result.nextCursor ?? null; const canLoadMore = nextCursor !== null && nextCursor !== cursor && !query.isPending && query.error === null; - const loadMore = useCallback(() => { + const loadMore = () => { if (!canLoadMore) return; setState({ key, cursor: nextCursor, pages }); - }, [canLoadMore, key, nextCursor, pages, setState]); + }; const { error, refresh: refreshPage } = query; - const refresh = useCallback(() => { + const refresh = () => { if (cursor !== null && error !== null) { refreshPage(); return; } setState({ key, cursor: null, pages: NO_PAGES }); refreshFirstPage(); - }, [cursor, error, refreshPage, key, refreshFirstPage, setState]); - const onPullRefresh = useCallback(() => { + }; + const onPullRefresh = () => { setState({ key, cursor: null, pages: NO_PAGES }); onRefresh(); - }, [key, onRefresh, setState]); + }; return { ...query, refresh, diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts index ddbc8520dcd14..598f15a7e8e2b 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiffHighlightFiles.ts @@ -37,7 +37,7 @@ export function usePullRequestDiffHighlightFiles( reportFailure: false, }); const { environmentId, projectId, repository, number, host, commit } = params; - const cache = useMemo(() => createPullRequestDiffHighlightCache(), []); + const cache = createPullRequestDiffHighlightCache(); return useMemo(() => { if (process.env.EXPO_PUBLIC_T3_DIFF_TRACE === "1") { const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts index a48e1a0965673..71b3304f7e2d2 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts @@ -1,5 +1,5 @@ import { useNavigation } from "@react-navigation/native"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import { useProjects } from "../../state/entities"; import { useConnectionsReady, useWorkspaceEnvironments } from "../../state/workspace"; @@ -13,10 +13,7 @@ export function usePullRequestEnvironmentFallback(params: PullRequestEnvironment const projects = useProjects(); const isReady = useConnectionsReady(); const environments = useWorkspaceEnvironments(); - const fallback = useMemo( - () => (isReady ? pullRequestFallbackEnvironment(params, projects, environments) : null), - [params, projects, environments, isReady], - ); + const fallback = isReady ? pullRequestFallbackEnvironment(params, projects, environments) : null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- rewrites the route params through the navigator to the fallback environment once connections are ready useEffect(() => { if (fallback) navigation.setParams(fallback); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index caea7afb85036..a87e07b4dbff8 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -5,7 +5,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; -import { use, useCallback, useMemo, useState } from "react"; +import { use, useMemo, useState } from "react"; import { environmentCatalog } from "../../connection/catalog"; import { composerPullRequests, @@ -225,23 +225,20 @@ export function usePullRequestList(targets: readonly ListTarget[]) { key: string; rows: readonly EnvironmentPullRequestListEntry[]; }>({ key: scopeKey, rows: [] }); - const onViewableItemsChanged = useCallback( - ({ - viewableItems, - }: { - viewableItems: readonly { item: EnvironmentPullRequestListEntry; isViewable: boolean }[]; - }) => { - const rows = viewableItems.filter((token) => token.isViewable).map((token) => token.item); - setVisible((previous) => - previous.key === scopeKey && - previous.rows.map(scopedPullRequestListKey).join() === - rows.map(scopedPullRequestListKey).join() - ? previous - : { key: scopeKey, rows }, - ); - }, - [scopeKey], - ); + const onViewableItemsChanged = ({ + viewableItems, + }: { + viewableItems: readonly { item: EnvironmentPullRequestListEntry; isViewable: boolean }[]; + }) => { + const rows = viewableItems.filter((token) => token.isViewable).map((token) => token.item); + setVisible((previous) => + previous.key === scopeKey && + previous.rows.map(scopedPullRequestListKey).join() === + rows.map(scopedPullRequestListKey).join() + ? previous + : { key: scopeKey, rows }, + ); + }; const visibleRows = visible.key === scopeKey && visible.rows.length ? visible.rows : data.entries.slice(0, 8); // Visible rows carry the cached-answer overlay below. Decide which still need stats on the rows @@ -286,24 +283,24 @@ export function usePullRequestList(targets: readonly ListTarget[]) { [data.entries, shown.answers], ); - const updateEntry = useCallback( - (entry: EnvironmentPullRequestListEntry, patch: Partial) => { - const target = targets.find((target) => target.environmentId === entry.environmentId); - if (!target) return; - const pagesAtom = pullRequestListPagesFamily(pullRequestListPagesKey(target)); - const read = (atom: Atom.Atom) => registry.get(atom); - const patched = patchPullRequestListEntry( - read(pagesAtom), - currentHead(read, target), - entry, - patch, - Date.now(), - ); - if (patched) registry.set(pagesAtom, patched); - registry.refresh(composerPullRequests.list(target)); - }, - [registry, targets], - ); + const updateEntry = ( + entry: EnvironmentPullRequestListEntry, + patch: Partial, + ) => { + const target = targets.find((target) => target.environmentId === entry.environmentId); + if (!target) return; + const pagesAtom = pullRequestListPagesFamily(pullRequestListPagesKey(target)); + const read = (atom: Atom.Atom) => registry.get(atom); + const patched = patchPullRequestListEntry( + read(pagesAtom), + currentHead(read, target), + entry, + patch, + Date.now(), + ); + if (patched) registry.set(pagesAtom, patched); + registry.refresh(composerPullRequests.list(target)); + }; const errors = useMemo( () => sources.flatMap(({ target, result }) => { diff --git a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts index 50a3d11f0d3ec..74a19737ac85d 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts @@ -42,10 +42,7 @@ export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDe ...query, stack, isFresh: supported && query.isSuccess && !query.isPending, - supportsStackActions: - supported && - pr?.capabilities.stackActions === true && - config?.environment.capabilities.pullRequestStackActions === true, + supportsStackActions: supported && pr?.capabilities.stackActions === true, }; } diff --git a/apps/mobile/src/features/review/ReviewCommentCard.tsx b/apps/mobile/src/features/review/ReviewCommentCard.tsx index ef1eff43b4547..a65bd83e9a66d 100644 --- a/apps/mobile/src/features/review/ReviewCommentCard.tsx +++ b/apps/mobile/src/features/review/ReviewCommentCard.tsx @@ -32,17 +32,14 @@ export interface ReviewCommentColors { export function useReviewCommentColors(): ReviewCommentColors { const theme = useUniwindTheme(); - return useMemo( - () => ({ - background: theme["--color-card"], - border: theme["--color-border"], - mutedBackground: theme["--color-subtle"], - text: theme["--color-foreground"], - mutedText: theme["--color-foreground-muted"], - codeBackground: theme["--color-md-code-bg"], - }), - [theme], - ); + return { + background: theme["--color-card"], + border: theme["--color-border"], + mutedBackground: theme["--color-subtle"], + text: theme["--color-foreground"], + mutedText: theme["--color-foreground-muted"], + codeBackground: theme["--color-md-code-bg"], + }; } export const ReviewCommentCard = memo(function ReviewCommentCard(props: { @@ -113,10 +110,7 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { () => JSON.stringify(nativeReviewDiffTheme), [nativeReviewDiffTheme], ); - const nativeStyleJson = useMemo( - () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }), - [nativeReviewDiffStyle], - ); + const nativeStyleJson = JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }); const nativeDiffHeight = useMemo( () => Math.min( diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index 5288929ed8986..a98d88a152e6d 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -1,7 +1,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { TextInputWrapper } from "expo-paste-input"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -82,10 +82,10 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp codeSurface.rowHeight, ); const previewViewportWidth = Math.max(width - 40, 280); - const dismissComposer = useCallback(() => { + const dismissComposer = () => { clearReviewCommentTarget(); navigation.goBack(); - }, [navigation]); + }; const handleNativePaste = useNativePaste((uris) => { void (async () => { try { @@ -112,7 +112,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp } } - const handleSubmit = useCallback(() => { + const handleSubmit = () => { if (!target || !environmentId || !threadId || commentText.trim().length === 0) { return; } @@ -125,7 +125,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp }); setAttachments([]); dismissComposer(); - }, [attachments, commentText, dismissComposer, environmentId, target, threadId]); + }; return ( diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 59a3cc5b1db6e..4e3b22407f5a0 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -178,7 +178,7 @@ export function getReviewUnifiedLineNumber(line: ReviewRenderableLineRow): numbe return line.newLineNumber ?? line.oldLineNumber; } -export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string { +function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string { if (change === "add") return "+"; if (change === "delete") return "-"; return " "; diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index 4b2c66a2af972..99b589e47cf74 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -12,7 +12,7 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ default: "monospace", }); -export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; +const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export function renderVisibleWhitespace(value: string): string { const expandedTabs = value.replace(/\t/g, " "); @@ -25,7 +25,7 @@ export function changeTone(change: ReviewRenderableLineRow["change"]): string { return "bg-card"; } -export function changeBarTone(change: ReviewRenderableLineRow["change"]): string { +function changeBarTone(change: ReviewRenderableLineRow["change"]): string { if (change === "add") return "bg-emerald-400"; if (change === "delete") return "bg-rose-400"; return "bg-border/50"; diff --git a/apps/mobile/src/features/review/reviewFileVisibility.ts b/apps/mobile/src/features/review/reviewFileVisibility.ts index fbdfcf2302255..5d01de5700eda 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.ts @@ -9,7 +9,7 @@ function getDefaultReviewExpandedFileIds( return files.map((file) => file.id); } -export function getValidReviewFileIds( +function getValidReviewFileIds( files: ReadonlyArray, fileIds: ReadonlyArray | undefined, ): ReadonlyArray { @@ -21,7 +21,7 @@ export function getValidReviewFileIds( return fileIds.filter((id) => fileIdSet.has(id)); } -export function getValidExplicitReviewFileIds( +function getValidExplicitReviewFileIds( files: ReadonlyArray, fileIds: ReadonlyArray | undefined, ): ReadonlyArray { @@ -32,17 +32,11 @@ export function getValidExplicitReviewFileIds( return getValidReviewFileIds(files, fileIds); } -export function toggleReviewFileId( - fileIds: ReadonlyArray, - fileId: string, -): ReadonlyArray { +function toggleReviewFileId(fileIds: ReadonlyArray, fileId: string): ReadonlyArray { return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : [...fileIds, fileId]; } -export function removeReviewFileId( - fileIds: ReadonlyArray, - fileId: string, -): ReadonlyArray { +function removeReviewFileId(fileIds: ReadonlyArray, fileId: string): ReadonlyArray { return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : fileIds; } diff --git a/apps/mobile/src/features/review/reviewPerf.ts b/apps/mobile/src/features/review/reviewPerf.ts deleted file mode 100644 index 2613ff0c3dbbc..0000000000000 --- a/apps/mobile/src/features/review/reviewPerf.ts +++ /dev/null @@ -1,56 +0,0 @@ -interface ReviewPerformanceLike { - readonly now?: () => number; - readonly mark?: (name: string) => void; - readonly measure?: (name: string, startMark: string, endMark: string) => void; - readonly clearMarks?: (name?: string) => void; - readonly clearMeasures?: (name?: string) => void; -} - -const REVIEW_PERF_PREFIX = "t3.review"; -let reviewPerfSequence = 0; - -function getPerformance(): ReviewPerformanceLike | null { - const candidate = (globalThis as { readonly performance?: ReviewPerformanceLike }).performance; - return candidate ?? null; -} - -export function isReviewPerfEnabled(): boolean { - return typeof __DEV__ !== "undefined" ? __DEV__ : false; -} - -export function measureReviewWork(name: string, callback: () => T): T { - if (!isReviewPerfEnabled()) { - return callback(); - } - - const perf = getPerformance(); - const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`; - const startMark = `${marker}.start`; - const endMark = `${marker}.end`; - const startedAt = perf?.now?.() ?? Date.now(); - - perf?.mark?.(startMark); - try { - return callback(); - } finally { - const durationMs = (perf?.now?.() ?? Date.now()) - startedAt; - perf?.mark?.(endMark); - perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark); - perf?.clearMarks?.(startMark); - perf?.clearMarks?.(endMark); - console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) }); - } -} - -export function markReviewEvent(name: string, details?: Record): void { - if (!isReviewPerfEnabled()) { - return; - } - - getPerformance()?.mark?.(`${REVIEW_PERF_PREFIX}.${name}`); - if (details) { - console.log(`[review-perf] ${name}`, details); - return; - } - console.log(`[review-perf] ${name}`); -} diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index 2ae7c77e14d96..8e5adca1ce598 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { NativeSyntheticEvent } from "react-native"; import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter"; @@ -8,8 +8,6 @@ import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighti import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -export { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; - export function useNativeReviewDiffBridge(input: { readonly threadKey: string | null; readonly sectionId: string | null; @@ -53,7 +51,7 @@ export function useNativeReviewDiffBridge(input: { [collapsedCommentIds], ); const themeJson = useMemo(() => JSON.stringify(theme), [theme]); - const styleJson = useMemo(() => JSON.stringify(nativeReviewDiffStyle), [nativeReviewDiffStyle]); + const styleJson = JSON.stringify(nativeReviewDiffStyle); const tokensResetKey = useMemo( () => `${buildNativeReviewTokensResetKey({ @@ -76,50 +74,44 @@ export function useNativeReviewDiffBridge(input: { enabled: canHighlight, }); - const onDebug = useCallback( - (event: NativeSyntheticEvent>) => { - const payload = event.nativeEvent; - const message = payload.message; - if (message === "surface-content-mounted") { - // The initial content already has a fresh hook; replacements lose accepted tokens. - if (hasMountedContentRef.current) setSurfaceGeneration((generation) => generation + 1); - hasMountedContentRef.current = true; - } - if (message === "tokens-patch-delivered") acknowledgeTokenPatch(payload); - if (message === "tokens-patch-failed") rejectTokenPatch(payload); - if ( - (message === "draw-metrics" || message === "visible-range") && - typeof payload.firstRowIndex === "number" && - typeof payload.lastRowIndex === "number" - ) { - updateVisibleRange({ - firstRowIndex: payload.firstRowIndex, - lastRowIndex: payload.lastRowIndex, - }); - } - }, - [acknowledgeTokenPatch, rejectTokenPatch, updateVisibleRange], - ); + const onDebug = (event: NativeSyntheticEvent>) => { + const payload = event.nativeEvent; + const message = payload.message; + if (message === "surface-content-mounted") { + // The initial content already has a fresh hook; replacements lose accepted tokens. + if (hasMountedContentRef.current) setSurfaceGeneration((generation) => generation + 1); + hasMountedContentRef.current = true; + } + if (message === "tokens-patch-delivered") acknowledgeTokenPatch(payload); + if (message === "tokens-patch-failed") rejectTokenPatch(payload); + if ( + (message === "draw-metrics" || message === "visible-range") && + typeof payload.firstRowIndex === "number" && + typeof payload.lastRowIndex === "number" + ) { + updateVisibleRange({ + firstRowIndex: payload.firstRowIndex, + lastRowIndex: payload.lastRowIndex, + }); + } + }; - const onToggleComment = useCallback( - (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => { - const { commentId } = event.nativeEvent; - if (!commentId) { - return; - } + const onToggleComment = (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => { + const { commentId } = event.nativeEvent; + if (!commentId) { + return; + } - setCollapsedCommentIds((current) => { - const next = new Set(current); - if (next.has(commentId)) { - next.delete(commentId); - } else { - next.add(commentId); - } - return next; - }); - }, - [], - ); + setCollapsedCommentIds((current) => { + const next = new Set(current); + if (next.has(commentId)) { + next.delete(commentId); + } else { + next.add(commentId); + } + return next; + }); + }; return { themeId, diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 1e263e5039782..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, @@ -182,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", { @@ -196,25 +196,22 @@ export function useNativeReviewDiffHighlighting(input: { return; pending.shift(); setTokensPatchJson(pending[0]?.json ?? createEmptyTokenPatch(head.resetKey)); - }, []); + }; - const rejectTokenPatch = useCallback( - (payload: Readonly>) => { - const head = pendingPatchesRef.current[0]; - traceDiffHighlight("patch-rejected", { - reset: diffHighlightTraceId(payload.resetKey), - chunk: typeof payload.chunkIndex === "number" ? payload.chunkIndex : -1, - matches: - !!head && payload.resetKey === head.resetKey && payload.chunkIndex === head.chunkIndex, - }); - if (!head || payload.resetKey !== head.resetKey || payload.chunkIndex !== head.chunkIndex) - return; - for (const rowId of head.rowIds) highlightedRowIdsRef.current.delete(rowId); - // Retry these rows only on a later viewport request, not in a failure loop. - acknowledgeTokenPatch(payload); - }, - [acknowledgeTokenPatch], - ); + const rejectTokenPatch = (payload: Readonly>) => { + const head = pendingPatchesRef.current[0]; + traceDiffHighlight("patch-rejected", { + reset: diffHighlightTraceId(payload.resetKey), + chunk: typeof payload.chunkIndex === "number" ? payload.chunkIndex : -1, + matches: + !!head && payload.resetKey === head.resetKey && payload.chunkIndex === head.chunkIndex, + }); + if (!head || payload.resetKey !== head.resetKey || payload.chunkIndex !== head.chunkIndex) + return; + for (const rowId of head.rowIds) highlightedRowIdsRef.current.delete(rowId); + // Retry these rows only on a later viewport request, not in a failure loop. + acknowledgeTokenPatch(payload); + }; return { rejectTokenPatch, diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index fcad725b13f40..6ccc5ae3f495c 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -3,7 +3,6 @@ import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; -import { markReviewEvent, measureReviewWork } from "./reviewPerf"; import { getCachedReviewParsedDiff } from "./reviewState"; import { applyReviewDiffMetadata, @@ -101,7 +100,7 @@ function logReviewDiffDiagnostic(message: string, details?: Record( - () => - lazySource - ? { kind: "empty" } - : measureReviewWork("parse-diff", () => - getCachedReviewParsedDiff({ - threadKey, - sectionId: selectedSection?.id ?? null, - diff: selectedSection?.diff, - }), - ), - [lazySource, selectedSection?.diff, selectedSection?.id, threadKey], - ); + const previewDiff: ReviewParsedDiff = lazySource + ? { kind: "empty" } + : getCachedReviewParsedDiff({ + threadKey, + sectionId: selectedSection?.id ?? null, + diff: selectedSection?.diff, + }); const registry = useContext(RegistryContext); const { environmentId, cwd } = input; const scope = JSON.stringify([ @@ -237,30 +230,18 @@ 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(() => { @@ -268,19 +249,12 @@ export function useReviewDiffData(input: { 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 9d1b2a6819d1a..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; diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index ce6a565f0ab9e..632fb08c2e7b3 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"; diff --git a/apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx b/apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx index ae8512fdb68eb..71e12d09e0b17 100644 --- a/apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx +++ b/apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx @@ -13,7 +13,7 @@ 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 { environmentReleaseCheckAtom, 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 752038c65f476..cd9d8e03c9c1f 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -27,7 +27,7 @@ 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 { @@ -136,13 +136,7 @@ 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); setNotice(`Updated to ${result.value.targetVersion}.`); diff --git a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx index f7e1680159c02..29b47a9ddd462 100644 --- a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; 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"; 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 b7ccdc0e00640..0af6eacd30c2c 100644 --- a/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx @@ -21,7 +21,7 @@ import { import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { ListRowLayout } from "../../components/ui/ListRowLayout"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { SettingsActionRow } from "./components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { SettingsDragHandle } from "./components/SettingsDragHandle"; import { SettingsSection } from "./components/SettingsSection"; 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 9a9c1a9aeff49..9d49650e1a67a 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -10,7 +10,7 @@ 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"; diff --git a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx index c848304e75167..5eecd33e3837d 100644 --- a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx @@ -33,7 +33,7 @@ import { import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; -import { SettingsActionRow } from "./components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { AndroidSettingsEnvironmentFilter, SettingsEnvironmentFilterHeader, diff --git a/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx index dfb07954a3acd..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, @@ -60,22 +65,17 @@ export function SettingsProviderAccountsRouteScreen() { ) : ( <> {(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 64de1a8b20ecb..34de55fd44082 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -16,7 +16,7 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScroll 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 { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; @@ -61,7 +61,7 @@ export function SettingsListScreen(props: { const workspace = useWorkspaceTabs(); const inSidebar = props.sidebar; const selected = props.selected; - const sidebar = useMemo(() => (inSidebar ? { selected } : null), [inSidebar, selected]); + const sidebar = inSidebar ? { selected } : null; const content = hasCloudPublicConfig() ? ( ) : ( diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index ef62306accbdc..4fa4874da13b9 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -45,6 +45,9 @@ 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", () => ({ @@ -86,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, diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 94a54db718db5..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"; @@ -258,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 && @@ -306,7 +295,6 @@ 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} @@ -395,6 +383,12 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { {props.page === "source-control" ? ( <> + {projectSelected ? null : ( + writableEnvironments.has(environmentId)} + /> + )} write({ continueThreadsAfterServerUpdate: value }) } diff --git a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx index 5149ffa54c6fc..2e77d4d3937e7 100644 --- a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx +++ b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx @@ -97,12 +97,7 @@ export function AutoSettleSettingsRows(props: { })), ); - const supportsProjectOverrides = displayTargets.every( - (target) => - target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, - ); - const disabled = - !canWriteSettings || props.pending || (projectSelected && !supportsProjectOverrides); + const disabled = !canWriteSettings || props.pending; const afterDays = referenceSettings.sidebarAutoSettleAfterDays; return ( diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index faadfadcbab5e..fd517e957014c 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, startTransition, use, useCallback, useMemo, 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"; @@ -243,106 +243,75 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize], ); const isReady = AsyncResult.isSuccess(preferencesResult); - const updatePreferences = useCallback( - (patch: Partial) => { - startTransition(() => savePreferences(patch)); - }, - [savePreferences], - ); + const updatePreferences = (patch: Partial) => { + startTransition(() => savePreferences(patch)); + }; - const updateThemePreferences = useCallback( - (patch: Partial) => { - savePreferences(patch); - }, - [savePreferences], - ); + const updateThemePreferences = (patch: Partial) => { + savePreferences(patch); + }; - const setThemeIdForAppearance = useCallback( - (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), - ); - }, - [themeAppearance, updateThemePreferences], - ); + 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 setThemeIdForBothAppearances = useCallback( - (value: MobileThemeId) => { - updateThemePreferences(createMobileThemePairPatch(value)); - }, - [updateThemePreferences], - ); + const setThemeIdForBothAppearances = (value: MobileThemeId) => { + updateThemePreferences(createMobileThemePairPatch(value)); + }; - const setThemeMode = useCallback( - (value: MobileThemeMode) => { - const current = currentRuntimeState(); - - // 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, - }; - - if (value === "system") { - appliedRuntimeState = next; - } else { - applyThemeRuntime(next); - } - updateThemePreferences({ themeMode: value }); - }, - [updateThemePreferences], - ); + const setThemeMode = (value: MobileThemeMode) => { + const current = currentRuntimeState(); - const setAndroidNavigationStyle = useCallback( - (value: AndroidNavigationStyle) => updatePreferences({ androidNavigationStyle: value }), - [updatePreferences], - ); + // 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, + }; + + if (value === "system") { + appliedRuntimeState = next; + } else { + applyThemeRuntime(next); + } + updateThemePreferences({ themeMode: value }); + }; - const setBaseFontSize = useCallback( - (value: number) => { - applyThemeRuntime({ ...currentRuntimeState(), baseFontSize: value }); - updatePreferences({ baseFontSize: value }); - }, - [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 => ({ diff --git a/apps/mobile/src/features/settings/appearance/sections/AndroidNavigationAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/AndroidNavigationAppearanceSection.tsx index e6945c65342fb..6d751f2e7f886 100644 --- a/apps/mobile/src/features/settings/appearance/sections/AndroidNavigationAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/AndroidNavigationAppearanceSection.tsx @@ -1,6 +1,6 @@ import { View } from "react-native"; -import { SettingsChoiceRow } from "../../components/SettingsChoiceRow"; +import { ListChoiceRow as SettingsChoiceRow } from "../../../../components/ui/ListChoiceRow"; import { SettingsSection } from "../../components/SettingsSection"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; 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 35f613ad8cae7..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 = { diff --git a/apps/mobile/src/features/settings/components/DatabaseStorageNotice.tsx b/apps/mobile/src/features/settings/components/DatabaseStorageNotice.tsx index 1e7e6693103d3..f492574db4339 100644 --- a/apps/mobile/src/features/settings/components/DatabaseStorageNotice.tsx +++ b/apps/mobile/src/features/settings/components/DatabaseStorageNotice.tsx @@ -5,7 +5,7 @@ import { copyTextWithHaptic } from "../../../lib/copyTextWithHaptic"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import { serverEnvironment } from "../../../state/server"; import type { SettingsTarget } from "../settings-environment-filter"; -import { SettingsActionRow } from "./SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../../components/ui/ListActionRow"; import { SettingsSection } from "./SettingsSection"; /** Suggests `t3 doctor` on every machine whose database still holds at least 1 GiB of freed pages. */ diff --git a/apps/mobile/src/features/settings/components/SettingsActionRow.tsx b/apps/mobile/src/features/settings/components/SettingsActionRow.tsx deleted file mode 100644 index 9497bc19247f8..0000000000000 --- a/apps/mobile/src/features/settings/components/SettingsActionRow.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ListActionRow as SettingsActionRow } from "../../../components/ui/ListActionRow"; diff --git a/apps/mobile/src/features/settings/components/SettingsChoiceRow.tsx b/apps/mobile/src/features/settings/components/SettingsChoiceRow.tsx deleted file mode 100644 index da48fd7069b4f..0000000000000 --- a/apps/mobile/src/features/settings/components/SettingsChoiceRow.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ListChoiceRow as SettingsChoiceRow } from "../../../components/ui/ListChoiceRow"; diff --git a/apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx b/apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx index 1bd899dc4d67e..493a515fad8dc 100644 --- a/apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx +++ b/apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx @@ -7,7 +7,6 @@ import { SettingsSection } from "./SettingsSection"; export function SettingsProjectOverridesSection(props: { readonly projectLabel: string; readonly hasOverrides: boolean; - readonly supportsOverrides: boolean; readonly pending: boolean; readonly disabled?: boolean; readonly onClear: () => void; @@ -18,7 +17,7 @@ export function SettingsProjectOverridesSection(props: { {props.projectLabel} - {!props.pending && props.supportsOverrides && props.hasOverrides ? ( + {!props.pending && props.hasOverrides ? (