diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 06fe9867b15ae..6fde6fff30984 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -21,6 +21,19 @@ Test with meaningful project and thread data. Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) only when inspecting or seeding SQLite. Stop the test server before direct fixture writes. +## Drive agent turns for free + +Do not spend a paid model to see a turn render. `vp run dev` registers the +`Replay (dev)` provider, which replays recorded turns with simulated streaming +through the real adapter and orchestration. Its models are scenarios: +`showcase` covers thinking, a plan, tool calls, an approval, and markdown. +Press `Ctrl+Alt+Shift+R` for a picker (GNOME takes that key for screen +recording), call `window.t3dev.replay("showcase")` +with `preview_evaluate`, or run +`node apps/server/src/bin.ts thread start --base-dir .t3 --provider devReplay --model showcase "go"`. +When the test needs real provider behavior, use that provider's cheapest +model, such as Haiku for Claude or Luna for Codex. + ## Use the Browser panel Call `preview_status`, then `preview_open` if the Browser panel is diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 66be80cd12312..f48bb0377ad94 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -38,8 +38,8 @@ This installs the dev client for the checkout's native fingerprint: from the device, the local cache, or the shared cache in seconds, building once only when no machine has that fingerprint yet (5-15 minutes, logged to the file it names). The device id may be omitted when exactly one device is booted. It -prints one JSON line; on failure it names the next command. Authorized mobile -verification includes that step unless the user prohibits it. +prints one JSON line; on failure it names the next command. Mobile verification +includes that step unless the user prohibits it. For a device you opened in an agent-device session elsewhere (a device pool, cloud, or remote host), run `vp run mobile:install --session ` diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 06983eaa71fd1..0b7d0182e6df7 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -26,6 +26,11 @@ export default defineConfig((ctx) => ({ path: "apps/web/src/**/*.{tsx,css}", instructions: "Hold changed code to the rules in docs/internals/web-ui.md.", }, + { + path: "**/*.test.*", + instructions: + "Hold changed tests to the rules in docs/internals/testing.md. Request tests only as those rules allow.", + }, ], }, knowledge_base: { @@ -33,6 +38,7 @@ export default defineConfig((ctx) => ({ filePatterns: [ { files: "docs/internals/effect-services.md", applyTo: "{apps,packages,infra}/**/*.ts" }, { files: "docs/internals/web-ui.md", applyTo: "apps/web/src/**/*.{tsx,css}" }, + { files: "docs/internals/testing.md", applyTo: "{apps,packages,infra}/**/*.{ts,tsx}" }, ], }, }, diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 353a1f41a2616..1c4ae817060a9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -59,6 +59,7 @@ github:myacoub91 github:naMqe-h github:nateEc github:naveed949 +github:nexxeln github:nmggithub github:Noojuno github:notkainoa diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e0ef427bd1d36..e9b7ae70a36ad 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -5,6 +5,14 @@ inputs: description: Read-only credential for the private maintainer task cache required: false default: "" + install: + description: Set to "false" to set up Vite+ only, with no pnpm store or install + required: false + default: "true" + install-args: + description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. + required: false + default: "" runs: using: composite steps: @@ -19,10 +27,11 @@ runs: echo "node=$(jq -r '.engines.node' package.json)" >> "$GITHUB_OUTPUT" echo "pnpm=$(jq -r '.packageManager' package.json)" >> "$GITHUB_OUTPUT" + # Pinned because release-desktop.yml signs builds with this action in the job. - name: Cache Vite+ id: vp_cache if: steps.vp.outputs.version != '' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.vite-plus key: vite-plus-home-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }}-node${{ steps.vp.outputs.node }}-${{ steps.vp.outputs.pnpm }} @@ -38,38 +47,93 @@ runs: echo "$HOME/.vite-plus/bin" >> "$GITHUB_PATH" "$HOME/.vite-plus/bin/vp" env use "${{ steps.vp.outputs.node }}" + # vp gives each Node.js download attempt 10 minutes by default, so one + # stalled stream holds the job that long. It retries 3 times and resumes + # partial bytes, so a shorter limit costs a slow download only a retry. - name: Setup Vite+ if: steps.vp_cache.outputs.cache-hit != 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + env: + VP_DOWNLOAD_TIMEOUT: "120" with: node-version-file: package.json cache: false run-install: false - - name: Resolve pnpm store path + # A shared key lets whichever install saves first, often a small filtered + # one, become every job's store. Each set of install args gets its own. + - name: Resolve pnpm store id: pnpm_store + if: inputs.install == 'true' shell: bash + env: + INSTALL_ARGS: ${{ inputs.install-args }} run: | - store_path="$(vp pm cache dir)" - echo "path=$store_path" >> "$GITHUB_OUTPUT" + echo "path=$(vp pm cache dir)" >> "$GITHUB_OUTPUT" + metadata_dir="${pnpm_config_cache_dir:-$RUNNER_TEMP/pnpm-metadata}" + echo "pnpm_config_cache_dir=$metadata_dir" >> "$GITHUB_ENV" + echo "verified=$metadata_dir/lockfile-verified.jsonl" >> "$GITHUB_OUTPUT" + if [[ -z "$INSTALL_ARGS" ]]; then + echo "namespace=full" >> "$GITHUB_OUTPUT" + else + echo "namespace=filtered-$(printf '%s' "$INSTALL_ARGS" | cksum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + fi - # setup-vp's shared cache key ignores install filters. Keep full installs in a - # separate namespace, including fallback restores. + # Filtered installs prefer the current full store, which holds every package, + # over an older store of their own. # The pnpm store stays a cache tarball on the runner's own disk. A store on a # Blacksmith sticky disk mounts in under a second, but pnpm then copies every # package across filesystems instead of hard-linking it: install took 28-49s # against 9s plus an 11s restore. - - name: Cache full pnpm store - uses: actions/cache@v6 + - name: Restore pnpm store + id: pnpm_cache + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.pnpm_store.outputs.path }} - key: pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + key: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} restore-keys: | + pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}- pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}- + # pnpm accepts an earlier pass for the same lockfile and policy at any age, so + # a cached record skips fetching registry metadata for every package. + - name: Restore lockfile verification + id: pnpm_verified + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: pnpm-verified-v1-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + - name: Install dependencies + if: inputs.install == 'true' shell: bash - run: vp install + env: + INSTALL_ARGS: ${{ inputs.install-args }} + # Unquoted so each filter is its own argument. + run: vp install $INSTALL_ARGS + + - name: Save lockfile verification + if: inputs.install == 'true' && steps.pnpm_verified.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} + + # A filtered install that started from the current full store adds nothing + # worth a second copy of it. + - name: Save pnpm store + if: >- + inputs.install == 'true' && + steps.pnpm_cache.outputs.cache-hit != 'true' && + (steps.pnpm_store.outputs.namespace == 'full' || + steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.path }} + key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} - name: Enable shared task cache if: >- diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 2ff10b84fd3a9..77a526076de4d 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -32,6 +32,8 @@ const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; // These tests read other packages without declaring dependencies. const TEST_ONLY_DEPS = { + // Evals import the server's source to test its real model-facing text. + "@t3tools/evals": ["t3"], "@t3tools/provider-pi": ["@t3tools/client-runtime"], "@t3tools/scripts": [ "@t3tools/desktop", diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 13ca38fed4ad6..78a0156d1eaa1 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -11,8 +11,8 @@ provided alongside this playbook. Everything machine-specific lives there, not h ## 1. Ask what went wrong -Your first message to the user: ask them to describe what went wrong, in their own -words. Ask them to paste screenshots directly into this session if they have any. +Unless the user already described it, your first message asks what went wrong, in +their own words. Ask them to paste screenshots directly into this session if they have any. Ask follow-up questions when the description is vague. Good repro steps are the most valuable thing you can extract from this conversation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9053e8887094..53d1ea359d8a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,12 @@ jobs: - name: Test run: vp run --filter @t3tools/web test --maxWorkers=1 --testTimeout=60000 + # Browser tests run source modules; this boots the production chunk graph. + - name: Boot the built web client + run: | + vp run --filter @t3tools/web build + node apps/web/scripts/verify-built-boot.ts + # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of # separate workers, so no two server test files ever share a machine and the 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 e9d6503a48ff8..1122c10766911 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,14 +98,17 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## Verifying -- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, and tests beside changes. CI still runs the full test suites. +- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, tests beside changes, and the cross-file checks those miss. CI still runs the full test suites. - 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. @@ -133,9 +136,15 @@ 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. +- 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. -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. +- 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 mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. + +## Evals + +`evals/` checks how real models behave with T3 Code's model-facing text: agent instructions, MCP tool descriptions and schemas, and generated titles. Before you change that text, read [evals/README.md](evals/README.md) and run the evals it names. When you fix a model behavior bug, add a case that fails before the fix. ## Pull requests @@ -197,13 +206,12 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. - Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. -- React effects are for synchronizing with something outside React. Derive in render and act in handlers instead of syncing state, but don't hide real lifecycle work in refs or stores to dodge one. The `t3code/react-effect-budget` lint rule holds each file to its count in `scripts/effect-budget.json`; a new effect states its reason with `// oxlint-disable-next-line t3code/react-effect-budget -- `. +- React effects are for synchronizing with something outside React. Derive in render and act in handlers instead of syncing state, but don't hide real lifecycle work in refs or stores to dodge one. The `t3code/no-unreasoned-state-sync` lint rule reports every effect; one that stays states its reason with `// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- `. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. ## Additional tips -- Don't verify with browsers or computer use unless the user explicitly agrees or requests it. - Security is important, but should not be over-indexed on, especially for dev mode/maintainer-only features. ## Agent skills diff --git a/README.md b/README.md index 8aa78f81b9ad7..6f6dab985e154 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,16 @@ yay -S t3code-nightly-bin The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). +#### Nix (nixpkgs) + +```bash +nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code +``` + +The package is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code) +and includes the desktop app (`t3code-desktop`) and the `t3` command. +See [Nix installation](./docs/user/install.md#nix-nixpkgs) for NixOS configuration and updates. + ## Some notes We are very very early in this project. Expect bugs. @@ -94,6 +104,7 @@ We are (mostly) not accepting contributions yet. Small fixes may be considered. Full docs live in [docs/](./docs). There's no docs site yet. +- [FAQ](./docs/user/faq.md) - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f87fa20453f36..7f768c1b44d75 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,14 +1,13 @@ { "name": "@t3tools/desktop", - "version": "0.1.2", + "version": "0.1.4", "private": true, "type": "module", "main": "dist-electron/boot.cjs", "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", - "test": "vp test run --passWithNoTests", - "smoke-test": "node scripts/smoke-test.mjs" + "test": "vp test run --passWithNoTests" }, "dependencies": { "@clerk/electron": "catalog:", @@ -23,7 +22,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.9.3", + "electron": "44.10.0", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs deleted file mode 100644 index 05195bb8d8cda..0000000000000 --- a/apps/desktop/scripts/smoke-test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import * as NodeChildProcess from "node:child_process"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; -import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; - -const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); -const desktopDir = NodePath.resolve(__dirname, ".."); -const mainJs = NodePath.resolve(desktopDir, "dist-electron/boot.cjs"); - -console.log("\nLaunching Electron smoke test..."); - -const electronCommand = resolveElectronLaunchCommand([mainJs]); -const child = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - VITE_DEV_SERVER_URL: "", - ELECTRON_ENABLE_LOGGING: "1", - }, -}); - -let output = ""; -child.stdout.on("data", (chunk) => { - output += chunk.toString(); -}); -child.stderr.on("data", (chunk) => { - output += chunk.toString(); -}); - -const timeout = setTimeout(() => { - child.kill(); -}, 8_000); - -child.on("exit", () => { - clearTimeout(timeout); - - const fatalPatterns = [ - "Cannot find module", - "MODULE_NOT_FOUND", - "Refused to execute", - "Uncaught Error", - "Uncaught TypeError", - "Uncaught ReferenceError", - ]; - const failures = fatalPatterns.filter((pattern) => output.includes(pattern)); - - if (failures.length > 0) { - console.error("\nDesktop smoke test failed:"); - for (const failure of failures) { - console.error(` - ${failure}`); - } - console.error("\nFull output:\n" + output); - process.exit(1); - } - - console.log("Desktop smoke test passed."); - process.exit(0); -}); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index f57e709bca7af..d49d2cdebb701 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -29,6 +29,7 @@ import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; +import * as DesktopStartupRecovery from "./DesktopStartupRecovery.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; @@ -325,6 +326,7 @@ const startup = Effect.gen(function* () { yield* applicationMenu.configure; yield* updates.configure; yield* DesktopRemoteUpdates.listen; + yield* DesktopStartupRecovery.listen; yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 340cb23136441..094033192e088 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat, an atomic rename, and a directory watch at the Node adapter boundary. +import { pipe } from "effect/Function"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; @@ -151,8 +152,10 @@ export async function startDesktopAppControlServer(input: { } if (!isDesktopAppActivationRequest(parsed)) { - finish( - invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + pipe( + requestIdFromUnknown(parsed), + (requestId) => invalidResponse(requestId, "The desktop app request is invalid."), + finish, ); return; } diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index a244ff4b3fa7a..34b7286c335ed 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -156,6 +156,7 @@ describe("DesktopClerk", () => { `setPath:userData:${userData}`, "requestSingleInstanceLock", "on:second-instance", + "on:open-url", "createClerkBridge", ]); }, @@ -200,7 +201,7 @@ describe("DesktopClerk", () => { }, ); - it.effect("delivers queued startup links and later links once in arrival order", () => { + it.effect("delivers each queued startup link once, before later links", () => { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const listeners = new Map void>(); @@ -208,6 +209,7 @@ describe("DesktopClerk", () => { const first = "t3code://new?prompt=first"; const second = "t3code://threads/env-1/thread-1"; const later = "t3code://new?prompt=later"; + const launchPair = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; const delivered = Promise.withResolvers(); const electronApp = { on: (name: string, listener: (...args: unknown[]) => void) => @@ -220,13 +222,14 @@ describe("DesktopClerk", () => { const clerk = yield* DesktopClerk.DesktopClerk; listeners.get("second-instance")?.({}, ["t3", first]); listeners.get("second-instance")?.({}, ["t3", second]); + listeners.get("open-url")?.({ preventDefault: () => undefined }, launchPair); assert.deepEqual(received, []); yield* clerk.configure; listeners.get("second-instance")!({}, ["t3", later]); yield* Effect.promise(() => delivered.promise); - assert.deepEqual(received, [first, second, later]); + assert.deepEqual(received, [launchPair, first, second, later]); }).pipe( Effect.scoped, Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), @@ -425,6 +428,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" }), } as unknown as ElectronApp.ElectronApp["Service"]; const received: Array = []; + const pairLink = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; yield* Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; yield* clerk.configure; @@ -437,6 +441,8 @@ it.effect("hands web and app links to the renderer and leaves other links alone" assert.strictEqual(open("http://localhost:3000/"), 1); assert.strictEqual(open("t3code://new?prompt=hi"), 1); assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1); + assert.strictEqual(open(pairLink), 1); + assert.strictEqual(open("t3code://pair"), 0); assert.strictEqual(open("mailto:hello@example.com"), 0); assert.strictEqual(open("t3code://somewhere-else"), 0); yield* Effect.yieldNow; @@ -445,6 +451,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" "http://localhost:3000/", "t3code://new?prompt=hi", "t3code://threads/env-1/thread-1", + pairLink, ]); }).pipe( Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 3e0d753d1bfba..992e1fe056937 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -13,6 +13,7 @@ import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCal import * as ElectronShell from "../electron/ElectronShell.ts"; import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; +import { readPairingAppLink } from "@t3tools/shared/remote"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -118,6 +119,15 @@ export const make = Effect.gen(function* () { yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { handleSecondInstance(argv); }); + // macOS hands a launch link to open-url, which can fire before configure runs. + const pendingOpenUrls: Array = []; + let handleOpenUrl = (url: string) => { + pendingOpenUrls.push(url); + return true; + }; + yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { + if (handleOpenUrl(url)) event.preventDefault(); + }); yield* Effect.acquireRelease( Effect.try({ @@ -194,7 +204,8 @@ export const make = Effect.gen(function* () { return true; }; const openAppLink = (value: string | undefined) => { - if (!value || promptLinkAppPath(value) === null) return false; + if (!value || (promptLinkAppPath(value) === null && readPairingAppLink(value) === null)) + return false; void runPromise(webLinks.receive(value)); return true; }; @@ -205,15 +216,12 @@ export const make = Effect.gen(function* () { void runPromise(webLinks.receive(url)); return true; }; - yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { - if ( - startProviderAuthHandoff(url) || - resumeProviderAuth(url) || - openWebLink(url) || - openAppLink(url) - ) - event.preventDefault(); - }); + handleOpenUrl = (url) => + startProviderAuthHandoff(url) || + resumeProviderAuth(url) || + openWebLink(url) || + openAppLink(url); + for (const url of pendingOpenUrls.splice(0)) handleOpenUrl(url); yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { if (!DesktopWebLinks.isWebPageFile(path)) return; event.preventDefault(); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 21e25fae21db4..1d7014647d008 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -373,7 +373,7 @@ describe("DesktopConnectionCatalogStore", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - 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; @@ -382,9 +382,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 ad746fae82c36..6a8d69acb0357 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -176,7 +176,6 @@ export class DesktopConnectionCatalogStore extends Context.Service< boolean, DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError >; - readonly clear: Effect.Effect; } >()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} @@ -583,16 +582,6 @@ export const make = Effect.gen(function* () { yield* writeCatalog(catalog).pipe(mutex.withPermits(1)); return true; }), - clear: fileSystem.remove(catalogPath, { force: true }).pipe( - mutex.withPermits(1), - Effect.catch((error) => - Effect.logWarning("Could not clear the desktop connection catalog.", { - catalogPath, - error, - }), - ), - Effect.withSpan("desktop.connectionCatalogStore.clear"), - ), }); }); diff --git a/apps/desktop/src/app/DesktopStartupRecovery.test.ts b/apps/desktop/src/app/DesktopStartupRecovery.test.ts new file mode 100644 index 0000000000000..d8a0ef1a999cc --- /dev/null +++ b/apps/desktop/src/app/DesktopStartupRecovery.test.ts @@ -0,0 +1,70 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { createInitialDesktopUpdateState } from "../updates/updateMachine.ts"; +import { resolveStartupRecoveryOffer } from "./DesktopStartupRecovery.ts"; + +const updateState = (patch: Partial): DesktopUpdateState => ({ + ...createInitialDesktopUpdateState( + "0.1.3", + { hostArch: "arm64", appArch: "arm64", runningUnderArm64Translation: false }, + "latest", + ), + enabled: true, + ...patch, +}); + +describe("resolveStartupRecoveryOffer", () => { + it("offers a newer version before going back", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ status: "available", availableVersion: "0.1.4" }), + "0.1.2", + ), + ).toEqual({ _tag: "Update", version: "0.1.4" }); + }); + + it("offers a retained download after a later check fails", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ status: "error", errorContext: "check", downloadedVersion: "0.1.4" }), + null, + ), + ).toEqual({ _tag: "Update", version: "0.1.4" }); + }); + + it("waits for the update check before offering to go back", () => { + expect(resolveStartupRecoveryOffer(updateState({ status: "checking" }), "0.1.2")).toEqual({ + _tag: "Checking", + }); + }); + + it("offers the last version that started when no update exists", () => { + expect(resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), "0.1.2")).toEqual({ + _tag: "Reinstall", + version: "0.1.2", + }); + }); + + it("does not offer an update that already failed to apply here", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ + status: "downloaded", + downloadedVersion: "0.1.4", + notAppliedVersion: "0.1.4", + }), + "0.1.2", + ), + ).toEqual({ _tag: "Reinstall", version: "0.1.2" }); + }); + + it("falls back to the releases page when this version never started", () => { + expect(resolveStartupRecoveryOffer(updateState({ status: "disabled" }), "0.1.3")).toEqual({ + _tag: "Releases", + }); + expect(resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), null)).toEqual({ + _tag: "Releases", + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts new file mode 100644 index 0000000000000..4d2dbcfb14409 --- /dev/null +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -0,0 +1,338 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import type { BackendStartupFailure } from "../backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopState from "./DesktopState.ts"; + +const RELEASES_URL = "https://github.com/pingdotgg/t3code/releases"; +const LAST_STARTED_VERSION_FILE = "desktop-last-started-version"; +// Buttons are plain links to this host; will-navigate cancels them and runs the action. +const ACTION_ORIGIN = "https://startup-recovery.invalid"; + +const { logInfo, logWarning } = DesktopObservability.makeComponentLogger( + "desktop-startup-recovery", +); + +export type StartupRecoveryOffer = + | { readonly _tag: "Checking" } + | { readonly _tag: "Update"; readonly version: string } + | { readonly _tag: "Downloading"; readonly version: string; readonly percent: number | null } + | { readonly _tag: "Reinstall"; readonly version: string } + | { readonly _tag: "Releases" }; + +const STARTUP_RECOVERY_ACTIONS = ["update", "reinstall", "releases", "copy", "quit"] as const; +type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number]; + +/** Prefer a newer build, then the last build that started on this machine, then the releases page. */ +export function resolveStartupRecoveryOffer( + update: DesktopUpdateState, + lastStartedVersion: string | null, +): StartupRecoveryOffer { + if (update.status === "downloading" && update.availableVersion !== null) { + return { + _tag: "Downloading", + version: update.availableVersion, + percent: update.downloadPercent, + }; + } + const updateVersion = + update.downloadedVersion ?? (update.status === "available" ? update.availableVersion : null); + // A version that already failed to apply here would loop through install and relaunch. + if (updateVersion !== null && updateVersion !== update.notAppliedVersion) { + return { _tag: "Update", version: updateVersion }; + } + if (update.enabled && (update.status === "idle" || update.status === "checking")) { + return { _tag: "Checking" }; + } + if (lastStartedVersion !== null && lastStartedVersion !== update.currentVersion) { + return { _tag: "Reinstall", version: lastStartedVersion }; + } + return { _tag: "Releases" }; +} + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (char) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char, + ); + +const actionLink = (action: StartupRecoveryAction, label: string, primary = false) => + `${escapeHtml(label)}`; + +function describeOffer(offer: StartupRecoveryOffer): { text: string; action: string } { + switch (offer._tag) { + case "Checking": + return { text: "Checking for a newer version…", action: "" }; + case "Update": + return { + text: `Version ${offer.version} is available. It can contain a fix.`, + action: actionLink("update", `Update to ${offer.version}`, true), + }; + case "Downloading": + return { + text: `Downloading ${offer.version}${offer.percent === null ? "" : ` (${Math.floor(offer.percent / 10) * 10}%)`}. T3 Code restarts when the download is done.`, + action: "", + }; + case "Reinstall": + return { + text: `Version ${offer.version} started correctly on this computer. Download it and install it to go back.`, + action: actionLink("reinstall", `Download ${offer.version}`, true), + }; + case "Releases": + return { + text: "Get a different version from the releases page.", + action: actionLink("releases", "Open releases", true), + }; + } +} + +function buildStartupRecoveryHtml(input: { + readonly appVersion: string; + readonly failure: BackendStartupFailure; + readonly offer: StartupRecoveryOffer; + readonly logDir: string; +}): string { + const offer = describeOffer(input.offer); + const output = input.failure.output.trim(); + return `T3 Code could not start +

T3 Code could not start

+

${escapeHtml(input.failure.reason)}

+

${escapeHtml(offer.text)}

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

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

+`; +} + +/** Shows a self-contained error window while the primary backend fails to start, so a broken release still opens. */ +export const listen: Effect.Effect< + void, + never, + | DesktopState.DesktopState + | DesktopUpdates.DesktopUpdates + | DesktopEnvironment.DesktopEnvironment + | ElectronApp.ElectronApp + | ElectronShell.ElectronShell + | ElectronTheme.ElectronTheme + | ElectronWindow.ElectronWindow + | FileSystem.FileSystem + | Scope.Scope +> = Effect.gen(function* () { + const { primaryBackendStartup, quitting } = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + const electronShell = yield* ElectronShell.ElectronShell; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const fileSystem = yield* FileSystem.FileSystem; + const runFork = Effect.runForkWith(yield* Effect.context()); + + const lastStartedVersionPath = environment.path.join( + environment.stateDir, + LAST_STARTED_VERSION_FILE, + ); + const lastStartedVersion = yield* fileSystem.readFileString(lastStartedVersionPath).pipe( + Effect.map((text) => text.trim() || null), + Effect.orElseSucceed(() => null), + ); + + const windowRef = yield* Ref.make(Option.none()); + // The failure that opened the window; later restarts do not reload its details. + const failureRef = yield* Ref.make(Option.none()); + const renderedHtmlRef = yield* Ref.make(""); + const checkedRef = yield* Ref.make(false); + const { latest, changes: updateChanges } = yield* updates.subscribe; + const updateStateRef = yield* Ref.make(latest); + + const offer = Ref.get(updateStateRef).pipe( + Effect.map((state) => resolveStartupRecoveryOffer(state, lastStartedVersion)), + ); + + const render = Effect.gen(function* () { + const window = yield* Ref.get(windowRef); + const failure = yield* Ref.get(failureRef); + if (Option.isNone(window) || Option.isNone(failure) || window.value.isDestroyed()) return; + const html = buildStartupRecoveryHtml({ + appVersion: environment.appVersion, + failure: failure.value, + offer: yield* offer, + logDir: environment.logDir, + }); + if ((yield* Ref.getAndSet(renderedHtmlRef, html)) === html) return; + void window.value.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + }); + + const runAction = Effect.fn("desktop.startupRecovery.runAction")(function* ( + action: StartupRecoveryAction, + ) { + yield* logInfo("startup recovery action", { action }); + const current = yield* offer; + switch (action) { + case "update": { + if (current._tag !== "Update") return; + const state = yield* updates.getState; + if (state.downloadedVersion === null) { + const download = yield* updates.download; + if (!download.completed) return; + } + // Waits for a running background check; plain install would refuse. + yield* updates.installPrepared(current.version); + return; + } + case "reinstall": + if (current._tag === "Reinstall") { + yield* electronShell.openExternal(`${RELEASES_URL}/tag/v${current.version}`); + } + return; + case "releases": + yield* electronShell.openExternal(RELEASES_URL); + return; + case "copy": { + const failure = yield* Ref.get(failureRef); + if (Option.isNone(failure)) return; + yield* electronShell.copyText( + `T3 Code ${environment.appVersion} (${environment.platform} ${environment.processArch}) could not start.\n${failure.value.reason}\n\n${failure.value.output}`, + ); + return; + } + case "quit": + yield* electronApp.quit; + return; + } + }); + + const show = Effect.fn("desktop.startupRecovery.show")( + function* () { + if (yield* Ref.get(quitting)) return; + // A backend that crashes after the app opened is reported by the app itself. + if (Option.isSome(yield* electronWindow.main)) return; + const existing = yield* Ref.get(windowRef); + if (Option.isSome(existing) && !existing.value.isDestroyed()) { + yield* electronWindow.reveal(existing.value); + return; + } + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const window = yield* electronWindow.create( + { + width: 640, + height: 480, + minWidth: 420, + minHeight: 320, + show: false, + title: "T3 Code could not start", + autoHideMenuBar: true, + backgroundColor: shouldUseDarkColors ? "#000000" : "#ffffff", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + javascript: false, + }, + }, + { auxiliary: true }, + ); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event, url) => { + event.preventDefault(); + const action = STARTUP_RECOVERY_ACTIONS.find((name) => url === `${ACTION_ORIGIN}/${name}`); + if (action !== undefined) runFork(runAction(action)); + }); + window.once("ready-to-show", () => { + if (!window.isDestroyed()) window.show(); + }); + window.once("closed", () => { + runFork( + Ref.set(windowRef, Option.none()).pipe(Effect.andThen(Ref.set(renderedHtmlRef, ""))), + ); + }); + yield* Ref.set(windowRef, Option.some(window)); + yield* render; + yield* logInfo("startup recovery window shown"); + }, + Effect.catch((error) => + logWarning("could not show startup recovery", { errorTag: error._tag }), + ), + ); + + const close = Effect.gen(function* () { + const window = yield* Ref.getAndSet(windowRef, Option.none()); + if (Option.isSome(window) && !window.value.isDestroyed()) window.value.close(); + }); + + const rememberStartedVersion = Effect.gen(function* () { + if (lastStartedVersion === environment.appVersion) return; + yield* fileSystem.writeFileString(lastStartedVersionPath, environment.appVersion); + }).pipe( + Effect.catch((error) => + logWarning("could not record the started version", { errorTag: error._tag }), + ), + ); + + yield* updateChanges.pipe( + Stream.runForEach((state) => Ref.set(updateStateRef, state).pipe(Effect.andThen(render))), + Effect.forkScoped, + ); + + yield* SubscriptionRef.changes(primaryBackendStartup).pipe( + Stream.runForEach( + Effect.fn("desktop.startupRecovery.onStartupChange")(function* (startup) { + if (startup._tag === "Ready") { + yield* Ref.set(failureRef, Option.none()); + yield* close; + yield* rememberStartedVersion; + return; + } + if (startup._tag !== "Failing") return; + if (Option.isSome(yield* Ref.get(failureRef))) return; + yield* Ref.set(failureRef, Option.some({ reason: startup.reason, output: startup.output })); + // The backend manager already logs each failure's reason. + yield* logWarning("primary backend failing to start"); + yield* show(); + if (!(yield* Ref.getAndSet(checkedRef, true))) { + yield* updates.check("startup-recovery").pipe(Effect.forkScoped); + } + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("startup recovery stopped", cause).pipe( + Effect.annotateLogs({ component: "desktop-startup-recovery" }), + ), + ), + Effect.forkScoped, + ); + + // macOS keeps running with no windows; the Dock icon must bring the error back. + yield* electronApp.on("activate", () => { + runFork( + Ref.get(failureRef).pipe( + Effect.flatMap((failure) => (Option.isSome(failure) ? show() : Effect.void)), + ), + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts index cd2abe9106544..3452fd13c0807 100644 --- a/apps/desktop/src/app/DesktopState.ts +++ b/apps/desktop/src/app/DesktopState.ts @@ -2,18 +2,29 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { BackendStartupFailure } from "../backend/DesktopBackendManager.ts"; + +/** Startup health of the primary local backend, read by the startup recovery window. */ +export type PrimaryBackendStartup = + | { readonly _tag: "Starting" } + | { readonly _tag: "Ready" } + | ({ readonly _tag: "Failing" } & BackendStartupFailure); export class DesktopState extends Context.Service< DesktopState, { readonly backendReady: Ref.Ref; readonly quitting: Ref.Ref; + readonly primaryBackendStartup: SubscriptionRef.SubscriptionRef; } >()("@t3tools/desktop/app/DesktopState") {} const make = Effect.all({ backendReady: Ref.make(false), quitting: Ref.make(false), + primaryBackendStartup: SubscriptionRef.make({ _tag: "Starting" }), }); export const layer = Layer.effect(DesktopState, make); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index aa6cd0d12d980..cdfec5f50d0bd 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -74,7 +74,6 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_NO_BROWSER", "T3CODE_HOST", "T3CODE_DESKTOP_WS_URL", - "T3CODE_DESKTOP_LAN_ACCESS", "T3CODE_DESKTOP_LAN_HOST", "T3CODE_DESKTOP_HTTPS_ENDPOINTS", "T3CODE_TAILSCALE_SERVE", @@ -86,6 +85,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", "OTEL_SDK_DISABLED", diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 7b15346a754e2..54fb312ec308f 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -123,6 +123,9 @@ interface MakeInstanceInput { readonly onReady?: Effect.Effect; readonly onShutdown?: Effect.Effect; readonly onStateDirOwned?: Effect.Effect; + readonly onStartupFailure?: ( + failure: DesktopBackendManager.BackendStartupFailure, + ) => Effect.Effect; readonly onPreflightFailed?: ( failure: DesktopBackendManager.PreflightFailure, ) => Effect.Effect; @@ -182,6 +185,7 @@ function makeTestInstance(input: MakeInstanceInput) { ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), ...(input.onStateDirOwned ? { onStateDirOwned: () => input.onStateDirOwned! } : {}), ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), + ...(input.onStartupFailure ? { onStartupFailure: input.onStartupFailure } : {}), }); return instance.pipe(Effect.provide(layerServices)); @@ -575,4 +579,56 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("reports a crash loop with the server output on the third restart", () => + Effect.scoped( + Effect.gen(function* () { + const message = "Error: Cannot find module 'zod/v4' in /Users/René"; + let exitCount = 0; + const firstExit = yield* Deferred.make(); + const reports = yield* Queue.unbounded<{ + readonly exitCount: number; + readonly failure: DesktopBackendManager.BackendStartupFailure; + }>(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut + // lands inside the emoji's surrogate pair. + stderr: Stream.make( + new TextEncoder().encode( + `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, + ), + new Uint8Array([0xc3]), + new Uint8Array([0xa9]), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }), + ), + ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + backendOutputLog: { + persistFailure: () => { + exitCount += 1; + return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); + }, + }, + onStartupFailure: (failure) => + Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(firstExit); + yield* TestClock.adjust(Duration.minutes(1)); + const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); + assert.equal(exitsWhenReported, 3); + assert.equal(failure.reason, "The server could not start (code=1)."); + assert.isTrue(failure.output.endsWith(message)); + assert.isTrue(failure.output.isWellFormed()); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 8b5e8045ed371..cbc0516509d0b 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -41,6 +41,9 @@ const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; +// Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help. +const STARTUP_FAILURE_RESTART_ATTEMPTS = 3; +const STARTUP_FAILURE_OUTPUT_CHARS = 4_000; const { logWarning: logBackendProcessWarning } = DesktopObservability.makeComponentLogger("desktop-backend-process"); @@ -217,6 +220,12 @@ export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( PRIMARY_LOCAL_ENVIRONMENT_ID, ); +export interface BackendStartupFailure { + readonly reason: string; + /** Tail of the last run's stdout and stderr. */ + readonly output: string; +} + export interface DesktopBackendInstance { readonly id: BackendInstanceId; readonly label: Effect.Effect; @@ -236,6 +245,8 @@ export interface BackendInstanceSpec { readonly onShutdown?: () => Effect.Effect; readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; readonly onStateDirOwned?: () => Effect.Effect; + /** Fires on each repeated crash or readiness timeout until the backend becomes ready. */ + readonly onStartupFailure?: (failure: BackendStartupFailure) => Effect.Effect; } interface ActiveBackendRun { @@ -619,6 +630,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); const mutex = yield* Semaphore.make(1); + const outputTail = yield* Ref.make(""); + // One streaming decoder per stream keeps UTF-8 characters split across chunks intact. + const makeOutputDecoders = () => ({ stdout: new TextDecoder(), stderr: new TextDecoder() }); + let outputDecoders = makeOutputDecoders(); + const reportStartupFailure = (reason: string) => + Ref.get(outputTail).pipe( + Effect.flatMap((output) => spec.onStartupFailure?.({ reason, output }) ?? Effect.void), + ); const { logWarning: logInstanceWarning, logError: logInstanceError } = DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); @@ -875,6 +894,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...run, pid: Option.some(pid), })); + yield* Ref.set(outputTail, ""); + outputDecoders = makeOutputDecoders(); yield* backendOutputLog.beginSession({ details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, }); @@ -923,9 +944,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* backendOutputLog.persistFailureSnapshot({ details: error.message, }); + yield* reportStartupFailure("The server started but did not become ready in time."); }, ), - onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + onOutput: (streamName, chunk) => + Ref.update(outputTail, (tail) => + // A cut through a surrogate pair would make encodeURIComponent throw when rendering. + (tail + outputDecoders[streamName].decode(chunk, { stream: true })) + .slice(-STARTUP_FAILURE_OUTPUT_CHARS) + .toWellFormed(), + ).pipe(Effect.andThen(backendOutputLog.writeOutputChunk(streamName, chunk))), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -951,26 +979,29 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ) { const scheduled = yield* Ref.modify(state, (latest) => { if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { - return [Option.none(), latest] as const; + return [Option.none<{ delay: Duration.Duration; attempt: number }>(), latest] as const; } - const delay = calculateRestartDelay(latest.restartAttempt); + const attempt = latest.restartAttempt + 1; return [ - Option.some(delay), - { - ...latest, - restartAttempt: latest.restartAttempt + 1, - }, + Option.some({ delay: calculateRestartDelay(latest.restartAttempt), attempt }), + { ...latest, restartAttempt: attempt }, ] as const; }); yield* Option.match(scheduled, { onNone: () => Effect.void, - onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) { + onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* ({ + delay, + attempt, + }) { yield* logInstanceError("backend exited unexpectedly; restart scheduled", { reason, delayMs: Duration.toMillis(delay), }); + if (attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) { + yield* reportStartupFailure(`The server could not start (${reason}).`); + } const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 0583e8df21705..2b2dd841ab53f 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -9,6 +9,7 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; @@ -62,6 +63,7 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer(); @@ -132,7 +135,7 @@ export const layer = Layer.effect( }); yield* electronDialog.showErrorBox( "WSL backend couldn't start", - `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, + `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Sharing & access once the WSL distro is fixed.`, ); // Clear wslOnly too so enabling WSL cannot restore a failed mode. // Keep the in-memory fallback if persistence fails. @@ -158,12 +161,16 @@ export const layer = Layer.effect( // Absorb window errors so readiness can reset restartAttempt. onReady: (httpBaseUrl) => desktopWindow.handleBackendReady(httpBaseUrl).pipe( + // Only once the main window exists, so the recovery window never closes into nothing. + Effect.andThen(SubscriptionRef.set(primaryBackendStartup, { _tag: "Ready" })), Effect.catch((error) => logBackendPoolWarning("failed to open main window after backend readiness", { error: error.message, }), ), ), + onStartupFailure: (failure) => + SubscriptionRef.set(primaryBackendStartup, { _tag: "Failing", ...failure }), onShutdown: () => desktopWindow.handleBackendNotReady, onPreflightFailed: handlePrimaryPreflightFailure, onStateDirOwned: () => diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 4f17ca7e33010..478d490129014 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -162,4 +162,20 @@ describe("ElectronWindow", () => { assert.equal(vi.mocked(laterWindow.destroy).mock.calls.length, 1); }).pipe(Effect.provide(layerTest)), ); + + it.effect("never falls back to an auxiliary window as the main window", () => + Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const auxiliary = yield* electronWindow.create({}, { auxiliary: true }); + const ordinary = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; + Object.assign(auxiliary, { isDestroyed: () => false }); + + getAllWindowsMock.mockReturnValue([auxiliary]); + assert.isTrue((yield* electronWindow.currentMainOrFirst)._tag === "None"); + + getAllWindowsMock.mockReturnValue([auxiliary, ordinary]); + const current = yield* electronWindow.currentMainOrFirst; + assert.isTrue(current._tag === "Some" && current.value === ordinary); + }).pipe(Effect.provide(layerTest)), + ); }); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 35303ed935aef..985f9c00775a0 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -109,8 +109,10 @@ export class ElectronWindowOperationError extends Schema.TaggedError Effect.Effect; readonly main: Effect.Effect>; readonly currentMainOrFirst: Effect.Effect>; @@ -141,6 +143,7 @@ export const make = Effect.gen(function* () { const captureRevealWindows = new Set(); yield* Effect.addFinalizer(() => Effect.sync(() => windowsForegroundFocus?.close())); const mainWindowRef = yield* Ref.make>(Option.none()); + const auxiliaryWindows = new WeakSet(); const listWindows = Effect.try({ try: () => Electron.BrowserWindow.getAllWindows(), @@ -181,7 +184,9 @@ export const make = Effect.gen(function* () { return main; } - const first = Option.fromNullishOr((yield* listWindows)[0] ?? null); + const first = Option.fromNullishOr( + (yield* listWindows).find((window) => !auxiliaryWindows.has(window)) ?? null, + ); if (Option.isNone(first) || (yield* isWindowDestroyed(first.value))) { return Option.none(); } @@ -207,7 +212,7 @@ export const make = Effect.gen(function* () { }); return ElectronWindow.of({ - create: Effect.fn("ElectronWindow.create")(function (options) { + create: Effect.fn("ElectronWindow.create")(function (options, behavior) { const webPreferences = options.webPreferences; const diagnosticOptions = { title: options.title ?? null, @@ -232,7 +237,11 @@ export const make = Effect.gen(function* () { } satisfies typeof ElectronWindowCreateOptions.Type; return Effect.try({ - try: () => new Electron.BrowserWindow(options), + try: () => { + const window = new Electron.BrowserWindow(options); + if (behavior?.auxiliary === true) auxiliaryWindows.add(window); + return window; + }, catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), }); }), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2d67aa56e6ccd..35333ac3d33a4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -4,11 +4,7 @@ import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./metho import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; -import { - clearConnectionCatalog, - getConnectionCatalog, - setConnectionCatalog, -} from "./methods/connectionCatalog.ts"; +import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, @@ -26,7 +22,6 @@ import { ensureSshEnvironment, fetchSshEnvironmentDescriptor, fetchSshSessionState, - issueSshWebSocketTicket, resolveSshHost, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; @@ -120,7 +115,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(dismissSnapShotAnimation); yield* ipc.handle(acknowledgeSnapShot); yield* ipc.handle(setConnectionCatalog); - yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(resolveSshHost); @@ -129,7 +123,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(fetchSshEnvironmentDescriptor); yield* ipc.handle(bootstrapSshBearerSession); yield* ipc.handle(fetchSshSessionState); - yield* ipc.handle(issueSshWebSocketTicket); yield* ipc.handle(resolveSshPasswordPrompt); yield* ipc.handle(getServerExposureState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9b11a2283a273..bba4f6449b8c1 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -55,7 +55,6 @@ export const DISMISS_SNAP_SHOT_ANIMATION_CHANNEL = "desktop:dismiss-snap-shot-an export const ACKNOWLEDGE_SNAP_SHOT_CHANNEL = "desktop:acknowledge-snap-shot"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; -export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; @@ -63,7 +62,6 @@ export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-enviro export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; -export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; @@ -110,7 +108,6 @@ export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-fra export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; -export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts index 4e51496a6379a..0f913d0f300a3 100644 --- a/apps/desktop/src/ipc/methods/connectionCatalog.ts +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -25,13 +25,3 @@ export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ return yield* store.set(catalog); }), }); - -export const clearConnectionCatalog = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, - payload: Schema.Void, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () { - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - yield* store.clear; - }), -}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index c6c2cd8fbdf9a..20b7bb4877d59 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -47,9 +47,6 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeStateChanges((tabId, state) => electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), ); - yield* manager.subscribeRecordingFrames((frame) => - electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), - ); yield* manager.subscribeRecordingInputs((event) => electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, event), ); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 099b97ce7bf31..15e04f71282b1 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -1,7 +1,6 @@ import { bootstrapRemoteBearerSession, fetchRemoteSessionState, - issueRemoteWebSocketTicket, RemoteEnvironmentAuthUndeclaredStatusError, type RemoteEnvironmentAuthError, } from "@t3tools/client-runtime/authorization"; @@ -24,7 +23,6 @@ import { EnvironmentScopeRequiredError, AuthAccessTokenResult, AuthSessionState, - AuthWebSocketTicketResult, } from "@t3tools/contracts"; import { SshHttpBridgeError } from "@t3tools/ssh/errors"; import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; @@ -40,8 +38,7 @@ import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts. type DesktopSshEnvironmentRequestOperation = | "fetch-environment-descriptor" | "bootstrap-bearer-session" - | "fetch-session-state" - | "issue-websocket-ticket"; + | "fetch-session-state"; type DesktopSshEnvironmentRequestCause = RemoteEnvironmentAuthError | SshHttpBridgeError; @@ -203,23 +200,6 @@ export const fetchSshSessionState = DesktopIpc.makeIpcMethod({ }), }); -export const issueSshWebSocketTicket = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - payload: DesktopSshBearerRequestInputSchema, - result: AuthWebSocketTicketResult, - handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({ - httpBaseUrl, - bearerToken, - }) { - return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) => - issueRemoteWebSocketTicket({ - httpBaseUrl: resolvedHttpBaseUrl, - bearerToken, - }), - )(httpBaseUrl); - }), -}); - export const resolveSshPasswordPrompt = DesktopIpc.makeIpcMethod({ channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, payload: DesktopSshPasswordPromptResolutionInputSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4d57b30e2139a..a3e1e711cb2d3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,7 +3,6 @@ import type { DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, - DesktopPreviewRecordingFrame, DesktopPreviewTabState, DesktopSnapShotEvent, } from "@t3tools/contracts"; @@ -139,7 +138,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), setConnectionCatalog: (catalog) => ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), - clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias), ensureSshEnvironment: async (target, options) => @@ -160,8 +158,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { }), fetchSshSessionState: (httpBaseUrl, bearerToken) => ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), - issueSshWebSocketTicket: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), onSshPasswordPrompt: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { if (typeof request !== "object" || request === null) return; @@ -398,15 +394,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { mimeType, data, }), - onFrame: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { - if (typeof frame !== "object" || frame === null) return; - listener(frame as DesktopPreviewRecordingFrame); - }; - ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - return () => - ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - }, }, onStateChange: (listener) => { const wrappedListener = ( diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index d9b797dd0451f..22af4a829e049 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -168,12 +168,7 @@ describe("BrowserSession", () => { return granted; }; - for (const permission of [ - "clipboard-read", - "clipboard-sanitized-write", - "geolocation", - "fullscreen", - ]) { + for (const permission of ["clipboard-sanitized-write", "geolocation", "fullscreen"]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( checkHandler(null, permission) as boolean, @@ -226,6 +221,55 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("asks once per origin before a page reads the clipboard", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0]; + const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + assert.isFunction(checkHandler); + + const readClipboard = (requestingUrl: string) => + Effect.callback((resume) => { + requestHandler( + null, + "clipboard-read", + (granted: boolean) => resume(Effect.succeed(granted)), + { requestingUrl, isMainFrame: true }, + ); + }); + const checkClipboard = (origin: string): boolean => + checkHandler(null, "clipboard-read", origin, {}) as boolean; + + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1); + + assert.isFalse(checkClipboard("https://allowed.test")); + assert.isTrue(yield* readClipboard("https://allowed.test/page")); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + assert.strictEqual( + showMessageBox.mock.calls[0]?.[0].message, + "https://allowed.test wants to see text and images copied to the clipboard", + ); + assert.deepStrictEqual(showMessageBox.mock.calls[0]?.[0].buttons, ["Allow", "Block"]); + // Enter must not accept a prompt the page raised while the user was typing. + assert.strictEqual(showMessageBox.mock.calls[0]?.[0].defaultId, 1); + + assert.isTrue(yield* readClipboard("https://allowed.test/other")); + assert.isTrue(checkClipboard("https://allowed.test")); + + assert.isFalse(yield* readClipboard("https://blocked.test/")); + assert.isFalse(yield* readClipboard("https://blocked.test/again")); + assert.isFalse(checkClipboard("https://blocked.test")); + assert.isTrue(checkClipboard("https://allowed.test")); + assert.strictEqual(showMessageBox.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + it.effect("clears a partition whose session has not been opened yet", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 0f900d521b739..2ea7efdb88ee0 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -20,7 +20,6 @@ export type BrowserSessionPartitionNamespace = "profile"; // Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ - "clipboard-read", "clipboard-sanitized-write", "geolocation", // DesktopWindow confines HTML fullscreen to the guest's webview. @@ -55,6 +54,19 @@ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => const MAX_PROMPT_URL_LENGTH = 300; +const truncateForPrompt = (text: string): string => + text.length > MAX_PROMPT_URL_LENGTH ? `${text.slice(0, MAX_PROMPT_URL_LENGTH)}…` : text; + +const permissionOrigin = (rawUrl: string | undefined): string | null => { + if (!rawUrl) return null; + try { + const { origin } = new URL(rawUrl); + return origin === "null" ? null : origin; + } catch { + return null; + } +}; + export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { @@ -175,47 +187,70 @@ export const make = Effect.gen(function* BrowserSessionMake() { const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. - let externalPromptOpen = false; - /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ - const confirmOpenExternal = ( + let permissionPromptOpen = false; + /** Settles with `undefined` when the user gave no answer: another prompt was open or the dialog failed. */ + const promptPermission = ( requester: WebContents | null, - rawUrl: string | undefined, - callback: (granted: boolean) => void, + prompt: { + readonly buttons: readonly [allow: string, deny: string]; + readonly message: string; + readonly detail?: string; + }, + callback: (answer: boolean | undefined) => void, ): void => { - const url = externalProtocolPromptUrl(rawUrl); - if (url === null || externalPromptOpen) { - callback(false); + if (permissionPromptOpen) { + callback(undefined); return; } - externalPromptOpen = true; - const settle = (granted: boolean) => + permissionPromptOpen = true; + const settle = (answer: boolean | undefined) => Effect.sync(() => { - externalPromptOpen = false; - callback(granted); + permissionPromptOpen = false; + callback(answer); }); - const shownUrl = - url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url; runFork( electronDialog .showMessageBox( { type: "question", - buttons: ["Open", "Cancel"], - defaultId: 0, + buttons: [...prompt.buttons], + // Default to deny so an Enter typed into the page cannot accept a prompt it raised. + defaultId: 1, cancelId: 1, - message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, - detail: `A page in the browser wants to open another application:\n\n${shownUrl}`, + message: prompt.message, + ...(prompt.detail === undefined ? {} : { detail: prompt.detail }), }, previewHostWindow(requester), ) .pipe( - Effect.map(({ response }) => response === 0), - Effect.orElseSucceed(() => false), + Effect.map(({ response }): boolean | undefined => response === 0), + Effect.orElseSucceed(() => undefined), Effect.flatMap(settle), - Effect.onInterrupt(() => settle(false)), + Effect.onInterrupt(() => settle(undefined)), ), ); }; + /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ + const confirmOpenExternal = ( + requester: WebContents | null, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const url = externalProtocolPromptUrl(rawUrl); + if (url === null) { + callback(false); + return; + } + promptPermission( + requester, + { + buttons: ["Open", "Cancel"], + message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, + detail: `A page in the browser wants to open another application:\n\n${truncateForPrompt(url)}`, + }, + (answer) => callback(answer === true), + ); + }; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( @@ -248,6 +283,35 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); + // Like Chrome, clipboard reads ask once per origin; both answers last for the session. + const clipboardReadDecisions = new Map(); + const confirmClipboardRead = ( + requester: WebContents, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const origin = permissionOrigin(rawUrl); + if (origin === null) { + callback(false); + return; + } + const remembered = clipboardReadDecisions.get(origin); + if (remembered !== undefined) { + callback(remembered); + return; + } + promptPermission( + requester, + { + buttons: ["Allow", "Block"], + message: `${truncateForPrompt(origin)} wants to see text and images copied to the clipboard`, + }, + (answer) => { + if (answer !== undefined) clipboardReadDecisions.set(origin, answer); + callback(answer === true); + }, + ); + }; // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010. browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { @@ -259,11 +323,21 @@ export const make = Effect.gen(function* BrowserSessionMake() { ); return; } + if (permission === "clipboard-read") { + confirmClipboardRead( + webContents, + "requestingUrl" in details ? details.requestingUrl : undefined, + callback, + ); + return; + } callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }, ); - browserSession.setPermissionCheckHandler((_webContents, permission) => - ALLOWED_PREVIEW_PERMISSIONS.has(permission), + browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => + permission === "clipboard-read" + ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true + : ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); const next = new Map(sessions); next.set(partition, browserSession); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d0221e9be0e6e..036c0d4afd932 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -359,7 +359,6 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { }; type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } @@ -536,9 +535,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const recordingInputListenersRef = yield* Ref.make>( new Set(), ); - const recordingFrameListenersRef = yield* Ref.make>( - new Set(), - ); const pickSessionsRef = yield* Ref.make>(new Map()); const controlSessionsRef = yield* SynchronizedRef.make< ReadonlyMap @@ -801,12 +797,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: - | "state-change" - | "recording-frame" - | "recording-input" - | "pointer-event" - | "open-link", + eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link", tabId: string, delivery: () => Effect.Effect, ) => @@ -1077,35 +1068,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get( - tabId, - ); - if (captureSession?.consumers.has("recording")) { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: - typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: - typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => - deliverEvent("recording-frame", frame.tabId, () => listener(frame)), - { discard: true }, - ); - } - } } if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { yield* Queue.offer(consoleReleases, undefined); @@ -1505,7 +1467,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); if (forwarded && host) { event.preventDefault(); - if (!input.isAutoRepeat) host.send(MENU_ACTION_CHANNEL, forwarded.command); + if (!input.isAutoRepeat) { + if ( + forwarded.command === "workspace.focusNext" || + forwarded.command === "workspace.focusPrevious" + ) { + // DOM focus in the renderer cannot by itself release the guest's native keyboard focus. + host.focus(); + } + host.send(MENU_ACTION_CHANNEL, forwarded.command); + } return; } if (isPreviewRefreshShortcut(input)) { @@ -2517,78 +2488,66 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function height: size.height, receivedAt, }; - const deliveries: Array> = []; - if (pictureInPicture) { - const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( - tabId, - )?.window; - if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) { - deliveries.push( - Effect.gen(function* () { - const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get( - tabId, - ); - const aspectRatio = frame.width / frame.height; - if ( - previousAspectRatio === undefined || - Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON - ) { - yield* attempt( - { - operation: "pictureInPicture.setAspectRatio", - tabId, - webContentsId: wc.id, - }, - () => { - const contentSize = fitPictureInPictureContentSize( - pictureInPictureWindow.getContentSize(), - aspectRatio, - ); - pictureInPictureWindow.setAspectRatio(0); - pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); - pictureInPictureWindow.setAspectRatio(aspectRatio); - }, - ); - yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => - replaceMap(aspectRatios, (copy) => { - copy.set(tabId, aspectRatio); - }), - ); - } - yield* attempt( - { - operation: "pictureInPicture.deliverFrame", - tabId, - webContentsId: wc.id, - }, - () => { - pictureInPictureWindow.webContents.send( - PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, - frame, - ); - }, + const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( + tabId, + )?.window; + if (!pictureInPictureWindow || pictureInPictureWindow.isDestroyed()) return; + yield* Effect.gen(function* () { + const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get(tabId); + const aspectRatio = frame.width / frame.height; + if ( + previousAspectRatio === undefined || + Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON + ) { + yield* attempt( + { + operation: "pictureInPicture.setAspectRatio", + tabId, + webContentsId: wc.id, + }, + () => { + const contentSize = fitPictureInPictureContentSize( + pictureInPictureWindow.getContentSize(), + aspectRatio, ); - yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { - if (sessions.get(tabId) !== frameSession) return sessions; - return replaceMap(sessions, (copy) => { - copy.set(tabId, { - ...frameSession, - lastPictureInPictureFingerprint: fingerprint, - }); - }); - }); - }).pipe( - Effect.catch((error) => - Effect.logWarning("Picture-in-picture frame delivery failed.", { - tabId, - error, - }), - ), - ), + pictureInPictureWindow.setAspectRatio(0); + pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); + pictureInPictureWindow.setAspectRatio(aspectRatio); + }, + ); + yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => + replaceMap(aspectRatios, (copy) => { + copy.set(tabId, aspectRatio); + }), ); } - } - yield* Effect.all(deliveries, { concurrency: 2, discard: true }); + yield* attempt( + { + operation: "pictureInPicture.deliverFrame", + tabId, + webContentsId: wc.id, + }, + () => { + pictureInPictureWindow.webContents.send(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, frame); + }, + ); + yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { + if (sessions.get(tabId) !== frameSession) return sessions; + return replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...frameSession, + lastPictureInPictureFingerprint: fingerprint, + }); + }); + }); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Picture-in-picture frame delivery failed.", { + tabId, + error, + }), + ), + ); }); const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* ( @@ -3306,7 +3265,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), - Ref.set(recordingFrameListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, @@ -3354,8 +3312,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), - subscribeRecordingFrames: (listener: RecordingFrameListener) => - subscribe(recordingFrameListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")), zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")), @@ -3597,9 +3553,6 @@ export class PreviewManager extends Context.Service< readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; - readonly subscribeRecordingFrames: ( - listener: RecordingFrameListener, - ) => Effect.Effect; } >()("@t3tools/desktop/preview/Manager/PreviewManager") {} @@ -3733,7 +3686,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, - subscribeRecordingFrames: operations.subscribeRecordingFrames, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 51c3d31daae85..c80498c14c1fb 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,8 +34,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, confirmThreadUnpin: false, contextWindowMeterEnabled: false, - composerCollapseOnScroll: true, - dismissedProviderUpdateNotificationKeys: [], diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", @@ -64,7 +62,9 @@ const clientSettings: ClientSettings = { }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, + boringSidebarEnabled: false, + boringSidebarMultilineRows: false, + threadDetailsUsageEnabled: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index c80fe9b87cd6a..25c37ecefc051 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -95,6 +95,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; @@ -450,6 +451,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 30edacb073c36..e31d347084209 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -1,5 +1,6 @@ // @effect-diagnostics globalTimers:off -- Capture timeouts and Electron overlay animation timers run at native callback boundaries outside Effect fibers. +import { pipe } from "effect/Function"; import { DEFAULT_CLIENT_SETTINGS, DesktopPendingSnapShot, @@ -605,7 +606,7 @@ function createSnapShotFlashWindow(bounds: Electron.Rectangle): Electron.BaseWin return window; } -export class SnapShotFlash { +class SnapShotFlash { private flashWindow: Electron.BaseWindow | undefined; private animationTimer: ReturnType | undefined; private closeTimer: ReturnType | undefined; @@ -1288,10 +1289,10 @@ export const make = Effect.gen(function* () { if (isModifierPairShortcut(shortcut)) { registered = yield* Effect.tryPromise(() => startPairShortcutProcess(snapShotShortcutModifierPair(shortcut), onCurrentShortcut, () => { - void runPromise( - setShortcutFailure( - snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), - ), + void pipe( + snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), + setShortcutFailure, + runPromise, ).catch(() => undefined); }), ).pipe( diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 26ae8fbf9ebe9..7c63a8b9aadef 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -8,8 +8,6 @@ import { accessibleWindowText, compactAccessibilityTree, findAccessibleWindow, - findCaptureSource, - hideAndWaitForBlur, isWaylandSession, } from "./snapShot.ts"; @@ -450,68 +448,6 @@ describe("findAccessibleWindow", () => { }); }); -describe("hideAndWaitForBlur", () => { - it("waits for a delayed blur after hiding the window", async () => { - let blur: (() => void) | undefined; - let settled = false; - const hidden = hideAndWaitForBlur({ - hide: () => undefined, - once: (_event, listener) => { - blur = listener; - }, - removeListener: () => undefined, - }).then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(settled).toBe(false); - blur?.(); - await hidden; - expect(settled).toBe(true); - }); -}); - -describe("findCaptureSource", () => { - const sources = [ - { id: "window:42:0", name: "Terminal" }, - { id: "window:84:0", name: "Editor" }, - ]; - - it("matches the native window id before its title", () => { - expect( - findCaptureSource(sources, { - id: 84, - title: "Changed title", - }), - ).toEqual(sources[1]); - }); - - it("falls back to a unique title match", () => { - expect( - findCaptureSource(sources, { - id: 100, - title: "Terminal", - }), - ).toEqual(sources[0]); - }); - - it("does not guess when a title is ambiguous", () => { - expect( - findCaptureSource( - [ - { id: "window:42:0", name: "Editor" }, - { id: "window:84:0", name: "Editor" }, - ], - { - id: 100, - title: "Editor", - }, - ), - ).toBeUndefined(); - }); -}); - describe("isWaylandSession", () => { effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 28bd301c557e6..872f99e1d6a72 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -23,7 +23,6 @@ interface AccessibilityTreeNode { } const MAX_ACCESSIBILITY_TREE_NODES = 10_000; -const WINDOW_BLUR_TIMEOUT_MS = 1_000; export const WINDOWS_MODIFIER_PAIR_VIRTUAL_KEYS: Record< SnapShotModifier, @@ -438,25 +437,6 @@ export async function accessibleWindowElementTree( return { root: compacted.root, truncated: completedTruncated }; } -export function hideAndWaitForBlur(window: { - readonly hide: () => void; - readonly once: (event: "blur", listener: () => void) => unknown; - readonly removeListener: (event: "blur", listener: () => void) => unknown; -}): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - window.removeListener("blur", onBlur); - reject(new Error("Timed out waiting for T3 Code to lose focus.")); - }, WINDOW_BLUR_TIMEOUT_MS); - const onBlur = () => { - clearTimeout(timeout); - resolve(); - }; - window.once("blur", onBlur); - window.hide(); - }); -} - type WindowBounds = { readonly x: number; readonly y: number; @@ -591,30 +571,6 @@ export function toElectronAccelerator(shortcut: SnapShotKeyChord): string { return parts.join("+"); } -interface CaptureSourceLike { - readonly id: string; - readonly name: string; -} - -interface ActiveWindowLike { - readonly id: number; - readonly title: string; -} - -export function findCaptureSource( - sources: readonly T[], - activeWindow: ActiveWindowLike, -): T | undefined { - const idPrefix = `window:${activeWindow.id}:`; - const idMatch = sources.find((source) => source.id.startsWith(idPrefix)); - if (idMatch) return idMatch; - - const title = activeWindow.title.trim(); - if (!title) return undefined; - const titleMatches = sources.filter((source) => source.name.trim() === title); - return titleMatches.length === 1 ? titleMatches[0] : undefined; -} - export function isWaylandSession( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 3d6556ffae9a3..aeda73f2bfad3 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -156,8 +156,6 @@ export class DesktopUpdateUnexpectedActionError extends Schema.TaggedError; readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; - readonly configure: Effect.Effect; + readonly configure: Effect.Effect; readonly setChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index df7e1fd2e85ac..9d8efd056665b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -199,6 +199,41 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("never binds Cmd/Ctrl+W to closing the window", () => + Effect.gen(function* () { + for (const platform of ["darwin", "linux", "win32"] as const) { + const applicationMenuTemplate = + yield* Deferred.make(); + yield* configureMenu(yield* Deferred.make(), applicationMenuTemplate, { + platform, + }); + + const items: Electron.MenuItemConstructorOptions[] = []; + const collect = (template: readonly Electron.MenuItemConstructorOptions[]) => { + for (const item of template) { + items.push(item); + if (Array.isArray(item.submenu)) collect(item.submenu); + } + }; + collect(yield* Deferred.await(applicationMenuTemplate)); + + assert.isUndefined( + items.find((item) => item.role === "close"), + platform, + ); + // Electron's default windowMenu submenu includes a close role. + assert.isUndefined( + items.find((item) => item.role === "windowMenu" && item.submenu === undefined), + platform, + ); + assert.isUndefined( + items.find((item) => item.accelerator?.toLowerCase().endsWith("+w")), + platform, + ); + } + }), + ); + it.effect("reloads the main window even while a browser page has focus", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 7c70656c1ac4c..07fd8654af6be 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -200,7 +200,10 @@ export const make = Effect.gen(function* () { }, { type: "separator" as const }, ]), - { role: environment.platform === "darwin" ? "close" : "quit" }, + // Cmd/Ctrl+W belongs to the renderer's tab close; it must never close the window. + environment.platform === "darwin" + ? { label: "Close Window", click: (_item, window) => window?.close() } + : { role: "quit" }, ], }, { @@ -289,7 +292,17 @@ export const make = Effect.gen(function* () { { role: "togglefullscreen" }, ], }, - { role: "windowMenu" }, + { + // Electron's default Window submenu adds a Ctrl+W close role off macOS. + role: "windowMenu", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(environment.platform === "darwin" + ? [{ type: "separator" as const }, { role: "front" as const }] + : []), + ], + }, { role: "help", submenu: [ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6270f80a4a7e6..ab5fc8be26a92 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -131,7 +131,7 @@ function getIconOption( } function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { - return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; + return shouldUseDarkColors ? "#000000" : "#ffffff"; } type DisplayBounds = Pick; @@ -619,7 +619,6 @@ export const make = Effect.gen(function* () { } }); - // Terminal-close repeats can outlive the terminal; reject them before the native close-window accelerator. const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, getMode: () => @@ -645,6 +644,7 @@ export const make = Effect.gen(function* () { }); window.webContents.on("before-input-event", (event, input) => { quitShortcutHandler(event, input); + // A held Cmd/Ctrl+W closes one tab or terminal, not each one behind it. if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 36939729682ba..ab52ca07a0d59 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -437,10 +437,8 @@ cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at // launch (which, in wsl-only mode, would just fail to launch with no fallback). // -// The sentinel must be a package the CLI bundle leaves external. It used to be -// "effect", back when the bundle externalized its runtime deps and the whole -// node_modules tree was unpacked. The bundle now inlines its JS dependencies, -// so "effect" no longer exists on disk and only the native packages do. +// The sentinel must be a package the CLI bundle leaves external. The bundle +// inlines its JS dependencies, so only the native packages exist on disk. // Resolving node-pty is what actually validates the unpacked tree. try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp deleted file mode 100644 index a05a5738a6cd7..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp deleted file mode 100644 index 1a16b037cf0b0..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp deleted file mode 100644 index dd72a9110265c..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp deleted file mode 100644 index 3aa1c274507b9..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp deleted file mode 100644 index e2510c829bb15..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp deleted file mode 100644 index f3ba3edb51909..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp deleted file mode 100644 index 8971323e7af55..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp deleted file mode 100644 index aa535e600a573..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp and /dev/null differ diff --git a/apps/marketing/src/assets/app-desktop.webp b/apps/marketing/src/assets/app-desktop.webp index 11b51331eef34..48c0c3e9abc50 100644 Binary files a/apps/marketing/src/assets/app-desktop.webp and b/apps/marketing/src/assets/app-desktop.webp differ diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index a4767e7bb4bca..883afb9752468 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -98,7 +98,7 @@ const mobileEndorsementRows = [ src={desktopScreenshot.src} srcset={`${screenshot.srcSet.attribute}, ${desktopScreenshot.src} ${desktopScreenshot.width}w`} sizes="(max-width: 960px) min(864px, calc(100vw - 20px)), min(1180px, calc(100vw - 60px))" - alt="T3 Code with a settled thread beside its pull request diff" + alt="T3 Code running Claude, Codex, Muse, and Gemini subagents in one thread" width={desktopScreenshot.width} height={desktopScreenshot.height} loading="eager" diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 92e587f171c7b..ea885f819984f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -17,6 +17,12 @@ const runtimeVersionPolicy = const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; +// Compiled into the native project. Without a relay, Observe's native side could dispatch to +// Expo's own ingest before observe.ts turns it off; `.invalid` never resolves (RFC 6761). +const observeEndpointUrl = repoEnv.T3CODE_RELAY_URL?.trim() + ? new URL("/v1/mobile/observe", repoEnv.T3CODE_RELAY_URL.trim()).toString() + : "https://observe.invalid"; + const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; const androidAdaptiveForeground = "./assets/android-icon-foreground.png"; @@ -220,7 +226,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.1.0", + version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.2.0", runtimeVersion: { // Skip costly fingerprinting on dev launches; preview and production OTAs require matching native projects. policy: runtimeVersionPolicy, @@ -419,7 +425,7 @@ const config: ExpoConfig = { ["expo-sensors", { motionPermission: false }], // Mods run in reverse order; expo-widgets must create and wipe the target before assets are added. ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []), - "./plugins/withAndroidCleartextTraffic.cjs", + "./plugins/withAndroidNetworkSecurityConfig.cjs", "./plugins/withAndroidGradleHeap.cjs", "./plugins/withAndroidInputBackground.cjs", "./plugins/withAndroidModernPopupMenu.cjs", @@ -450,6 +456,7 @@ const config: ExpoConfig = { }, eas: { projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", + observe: { endpointUrl: observeEndpointUrl }, }, }, owner: "pingdotgg", diff --git a/apps/mobile/assets/context-chip-icons/server.png b/apps/mobile/assets/context-chip-icons/server.png new file mode 100644 index 0000000000000..92bc2ce457f16 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/server.png differ diff --git a/apps/mobile/assets/icons/chevron-down.xml b/apps/mobile/assets/icons/chevron-down.xml new file mode 100644 index 0000000000000..285eda9c1b463 --- /dev/null +++ b/apps/mobile/assets/icons/chevron-down.xml @@ -0,0 +1,4 @@ + + + diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index 46b0539e5c97b..939ac2d9147ed 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,3 +1,3 @@ -The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), and development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)). +The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)), and `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [md4c#5](https://github.com/software-mansion-labs/md4c/pull/5)). To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz new file mode 100644 index 0000000000000..06ac560ff2eba Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz differ diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz deleted file mode 100644 index 3cf77c4adf971..0000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz deleted file mode 100644 index 9346f85dd023b..0000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz new file mode 100644 index 0000000000000..f1eb3858e9865 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index f694fc8d6ef26..f773c2c5bbc3a 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -66,7 +66,8 @@ "corepack": true, "env": { "APP_VARIANT": "production", - "NODE_OPTIONS": "--max-old-space-size=4096" + "NODE_OPTIONS": "--max-old-space-size=4096", + "SOURCEMAP_FILE": "/tmp/t3code-ios-main.jsbundle.map" }, "channel": "production", "environment": "production", diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 6aac4869d9717..9367ade408e95 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,8 @@ -// Must stay first: the render counter wraps the DevTools hook before any React commit. +// Must stay first: the render counter and behavior probe wrap the DevTools hook before any React commit. import "./src/features/development/render-counter-entry"; +import "./src/features/development/behavior-probe-entry"; +// Next, so errors thrown while the rest of the app loads are recorded too. +import "./src/features/observability/observe"; import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; import { LogBox } from "react-native"; diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index afbef3362d6d5..6371da34fd190 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -56,10 +56,6 @@ async function generateMobileThirdPartyLicenses() { const manifest = await generateThirdPartyLicenseManifest({ configFile: path.join(workspaceRoot, "third-party-licenses.config.json"), packageManifests: [{ bundle: "mobile", path: path.join(__dirname, "package.json") }], - allowMissingGeneratedNotices: - process.env.NODE_ENV !== "production" && - process.env.EAS_BUILD !== "true" && - process.env.T3CODE_LICENSES_STRICT !== "1", }); await Promise.all([ diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index 315d25c130072..736f7cd3019f0 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable @@ -71,6 +72,11 @@ class T3WorkspaceNavigationModule : Module() { Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String -> view.selectedTab = value } + Prop("floating") { view: T3WorkspaceNavigationView, value: Boolean -> + view.floating = value + view.clipToPadding = !value + view.clipChildren = !value + } Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value } Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value } Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value } @@ -123,6 +129,7 @@ class T3WorkspaceNavigationView( var tabs by mutableStateOf(listOf("threads", "pullRequests", "usage", "settings")) var selectedTab by mutableStateOf("threads") var rail by mutableStateOf(false) + var floating by mutableStateOf(false) var dark by mutableStateOf(false) var textScale by mutableStateOf(1f) var newEnabled by mutableStateOf(false) @@ -161,6 +168,7 @@ class T3WorkspaceNavigationView( init { addView( ComposeView(context).apply { + clipChildren = false setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool ) @@ -168,6 +176,8 @@ class T3WorkspaceNavigationView( MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) { if (rail) { rail() + } else if (floating) { + floatingTabs() } else { NavigationBar( modifier = Modifier.fillMaxSize(), @@ -199,6 +209,48 @@ class T3WorkspaceNavigationView( ) } + @Composable + private fun floatingTabs() { + Surface( + modifier = Modifier.fillMaxSize().padding(8.dp), + shape = CircleShape, + color = Color(containerColor), + shadowElevation = 6.dp, + ) { + Row( + modifier = Modifier.fillMaxSize().selectableGroup(), + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEach { tab -> + val selected = selectedTab == tab + val tint = Color(if (selected) selectedColor else unselectedColor) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(CircleShape) + .selectable( + selected = selected, + role = Role.Tab, + onClick = { onTabSelected(mapOf("tab" to tab)) }, + ) + .semantics { contentDescription = expandedTabTitle(tab) } + .padding(4.dp) + .background(if (selected) Color(indicatorColor) else Color.Transparent, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + tabIconPainter(tab), + contentDescription = null, + modifier = Modifier.size((24 * textScale).dp), + tint = tint, + ) + } + } + } + } + } + @Composable private fun rail() { // One rail surface widens in place over the list. Material's ModalWideNavigationRail @@ -232,6 +284,7 @@ class T3WorkspaceNavigationView( }, ) { val fraction = progress.coerceIn(0f, 1f) + val railShape = RoundedCornerShape(topEnd = 16.dp * fraction, bottomEnd = 16.dp * fraction) if (fraction > 0f) { Scrim( contentDescription = "Close navigation rail", @@ -243,8 +296,8 @@ class T3WorkspaceNavigationView( Modifier .fillMaxHeight() .width(lerp(RAIL_WIDTH, EXPANDED_RAIL_WIDTH, progress.coerceAtLeast(0f))) - .shadow(RAIL_SHADOW * fraction, RAIL_EXPANDED_SHAPE, clip = false) - .background(Color(containerColor), RAIL_EXPANDED_SHAPE) + .shadow(RAIL_SHADOW * fraction, railShape, clip = false) + .background(Color(containerColor), railShape) .padding(top = safeAreaTop.dp, bottom = safeAreaBottom.dp + 12.dp) .selectableGroup(), ) { @@ -267,11 +320,14 @@ class T3WorkspaceNavigationView( ) } } + // Other tabs keep the button's slot so the destinations below never move between tabs. if (selectedTab == "threads") { newThreadButton(fraction) { expanded = false onNewThread(emptyMap()) } + } else { + Spacer(Modifier.padding(top = 12.dp).height(FAB_SIZE)) } Spacer(Modifier.weight(1f)) tabs.forEach { tab -> @@ -385,7 +441,6 @@ class T3WorkspaceNavigationView( val FAB_SIZE = 48.dp val ICON_SIZE = 24.dp val RAIL_SHADOW = 6.dp - val RAIL_EXPANDED_SHAPE = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp) const val DISABLED_ALPHA = 0.4f // Labels fade in over the second half of the widening, once there is room for them, and diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index 2d92537bf4312..549331a56bef8 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -53,7 +53,7 @@ public final class T3KeyboardCommandsView: ExpoView { input: "c", modifiers: [.command, .shift], action: #selector(copyThreadReference), - title: "Copy PR Link or Thread ID" + title: "Copy PR Link or Thread" ), enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), enabledCommand("cycleHost", input: "h", modifiers: [.command, .shift], action: #selector(cycleHost), title: "Next Machine"), diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 8426ddb565a40..8cb7ad17e0d0b 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -1423,13 +1423,24 @@ private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f } +// A single-line preview never shows this many characters, so longer text is cut before measuring. +private const val ELLIPSIZE_MAX_CHARS = 500 + private fun ellipsize(value: String, paint: Paint, width: Float): String { - if (paint.measureText(value) <= width) return value + val truncated = value.length > ELLIPSIZE_MAX_CHARS + val capEnd = surrogateSafeEnd(value, ELLIPSIZE_MAX_CHARS) + val text = if (truncated) value.substring(0, capEnd) else value + if (!truncated && paint.measureText(text) <= width) return value val suffix = "..." val available = max(0f, width - paint.measureText(suffix)) - var end = value.length - while (end > 0 && paint.measureText(value, 0, end) > available) end -= 1 - return value.substring(0, end) + suffix + val end = surrogateSafeEnd(text, paint.breakText(text, true, available, null)) + return text.substring(0, end) + suffix +} + +private fun surrogateSafeEnd(value: String, end: Int): Int { + val splitsPair = end in 1 until value.length && + Character.isSurrogatePair(value[end - 1], value[end]) + return if (splitsPair) end - 1 else end } private fun indexFileHeaders(rows: List): IntArray { diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index b8c92f2d9332f..703bcdf361b49 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -66,8 +66,16 @@ class T3TerminalModule : Module() { Prop("captureRequest") { view: T3TerminalView, request: Double -> view.captureRequest = request } + + Prop("sharedGrid") { view: T3TerminalView, sharedGrid: List -> + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") + OnViewDidUpdateProps { view: T3TerminalView -> + view.applyPendingBuffer() + } + OnViewDestroys { view: T3TerminalView -> view.cleanup() } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index f4de25460d5c6..1deb3a6170350 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -58,6 +58,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private var streamedOutput: TerminalOutputBuffer? = null private var cols = 0 private var rows = 0 + private var measuredCols = 0 + private var measuredRows = 0 private var clearingInput = false private var isCleanedUp = false private var backgroundColorValue = Color.parseColor("#24292E") @@ -74,12 +76,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex recreateTerminal() } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" set(value) { if (field == value) return field = value - feedPendingBuffer() + hasPendingBuffer = true } + private var hasPendingBuffer = false var fontSize: Float = 10f set(value) { @@ -91,6 +97,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex var appearanceScheme: String = "dark" + // The PTY grid shared by every attached client, or empty before the server reports one. + // The terminal draws this grid, clamped to the view's own fit (the server's grid lags a + // local resize, and read-only observers never constrain it), so its wrapping and cursor + // match the PTY; the view's own fit still goes out through onResize. + var sharedGrid: List = emptyList() + set(value) { + if (field == value) return + field = value + applyGrid() + } + var themeConfig: String = "" set(value) { field = value @@ -327,6 +344,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex val nextRows = (terminalCanvas.usableHeight() / terminalCanvas.cellHeightPx) .toInt() .coerceIn(2, 200) + if (nextCols != measuredCols || nextRows != measuredRows) { + measuredCols = nextCols + measuredRows = nextRows + onResize(mapOf("cols" to measuredCols, "rows" to measuredRows)) + } + applyGrid() + } + + private fun applyGrid() { + if (measuredCols <= 0 || measuredRows <= 0 || isCleanedUp) return + val shared = sharedGrid.takeIf { it.size == 2 && it[0] > 0 && it[1] > 0 } + val nextCols = shared?.get(0)?.coerceAtMost(measuredCols) ?: measuredCols + val nextRows = shared?.get(1)?.coerceAtMost(measuredRows) ?: measuredRows if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return cols = nextCols rows = nextRows @@ -343,8 +373,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex ) } emitResponse(response) - onResize(mapOf("cols" to cols, "rows" to rows)) - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -369,7 +398,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (terminalHandle == 0L) return destroyTerminal() createTerminal() - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -381,6 +410,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalCanvas.resetSelectionState() } + fun applyPendingBuffer() { + if (!hasPendingBuffer) return + hasPendingBuffer = false + feedPendingBuffer() + } + private fun feedPendingBuffer() { if (streamedOutput != null || terminalHandle == 0L || initialBuffer == fedBuffer) return if (!initialBuffer.startsWith(fedBuffer)) { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 2f7445265886c..d68e46a5416b1 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -63,7 +63,15 @@ public class T3TerminalModule: Module { Prop("captureRequest") { (view: T3TerminalView, request: Double) in view.captureRequest = request } + + Prop("sharedGrid") { (view: T3TerminalView, sharedGrid: [Int]) in + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") + + OnViewDidUpdateProps { (view: T3TerminalView) in + view.applyPendingBuffer() + } } } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 364880aaeb39b..0ae146bb1241a 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -243,9 +243,23 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" { didSet { - applyRemoteBuffer(initialBuffer) + hasPendingBuffer = true + } + } + private var hasPendingBuffer = false + + // The PTY grid shared by every attached client, or empty before the server reports one. + // The surface draws this grid so its wrapping and cursor match the PTY; the view's own + // fit still goes out through onResize. + var sharedGrid: [Int] = [] { + didSet { + guard oldValue != sharedGrid else { return } + resizeSurface() } } @@ -529,7 +543,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { resizeSurface() if let streamedOutput { feedData(streamedOutput.data) - } else { + } else if !hasPendingBuffer { feedBuffer(initialBuffer) } } @@ -559,6 +573,19 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } surface = nil app = nil + // Ghostty adds its IOSurface layer to the viewport on iOS but never removes it on + // free, leaving the last frame on screen under the next surface's layer. The + // viewport has no subviews, so every sublayer belongs to a freed surface. + CATransaction.begin() + CATransaction.setDisableActions(true) + terminalViewport.layer.sublayers?.forEach { $0.removeFromSuperlayer() } + CATransaction.commit() + } + + func applyPendingBuffer() { + guard hasPendingBuffer else { return } + hasPendingBuffer = false + applyRemoteBuffer(initialBuffer) } private func applyRemoteBuffer(_ buffer: String) { @@ -647,10 +674,25 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { terminalViewport.contentScaleFactor = scale ghostty_surface_set_content_scale(surface, Double(scale), Double(scale)) ghostty_surface_set_size(surface, width, height) + let fitted = ghostty_surface_size(surface) + let fittedCols = max(1, Int(fitted.columns)) + let fittedRows = max(1, Int(fitted.rows)) + // Trimming whole cells keeps ghostty's padding, and the surface can only shrink + // within the view, so a stale larger grid falls back to the fitted one. + var surfaceWidth = width + var surfaceHeight = height + if sharedGrid.count == 2, sharedGrid[0] > 0, sharedGrid[1] > 0 { + surfaceWidth -= UInt32(max(fittedCols - sharedGrid[0], 0)) * fitted.cell_width_px + surfaceHeight -= UInt32(max(fittedRows - sharedGrid[1], 0)) * fitted.cell_height_px + if surfaceWidth != width || surfaceHeight != height { + ghostty_surface_set_size(surface, surfaceWidth, surfaceHeight) + } + } ghostty_surface_set_occlusion(surface, window != nil) - configureIOSurfaceLayers() + configureIOSurfaceLayers( + size: CGSize(width: CGFloat(surfaceWidth) / scale, height: CGFloat(surfaceHeight) / scale)) redrawSurface() - emitGhosttyResize() + emitResize(cols: fittedCols, rows: fittedRows) } private func redrawSurface() { @@ -658,19 +700,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_surface_refresh(surface) ghostty_surface_draw(surface) markIOSurfaceLayersForDisplay() - emitGhosttyResize() - } - - private func emitGhosttyResize() { - guard let surface else { - emitEstimatedResize() - return - } - - let size = ghostty_surface_size(surface) - let cols = max(1, Int(size.columns)) - let rows = max(1, Int(size.rows)) - emitResize(cols: cols, rows: rows) } private func emitEstimatedResize() { @@ -721,8 +750,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_app_keyboard_changed(app) } - private func configureIOSurfaceLayers() { - let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size) + private func configureIOSurfaceLayers(size: CGSize) { + let targetBounds = CGRect(origin: .zero, size: size) CATransaction.begin() CATransaction.setDisableActions(true) terminalViewport.layer.sublayers?.forEach { sublayer in diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9b28ed178b650..f5c1c6521812e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -46,6 +46,7 @@ "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", "generate:highlights": "node scripts/generate-highlights.mts", + "eas-build-on-success": "node scripts/upload-sourcemaps.mts build", "test": "vp test run --project unit --project react", "typecheck": "tsc --noEmit" }, @@ -73,13 +74,6 @@ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-source-text": "file:./modules/t3-source-text", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", - "@t3tools/provider-acp-registry": "workspace:*", - "@t3tools/provider-core": "workspace:*", - "@t3tools/provider-cursor": "workspace:*", - "@t3tools/provider-grok": "workspace:*", - "@t3tools/provider-muse": "workspace:*", - "@t3tools/provider-opencode": "workspace:*", - "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tabler/icons-react-native": "^3.44.0", "clsx": "^2.1.1", @@ -110,6 +104,7 @@ "expo-linking": "~58.0.13", "expo-network": "~58.0.3", "expo-notifications": "~58.1.5", + "expo-observe": "~58.0.15", "expo-paste-input": "^0.1.15", "expo-quick-actions": "^6.0.2", "expo-secure-store": "~58.0.2", @@ -126,7 +121,7 @@ "react-dom": "catalog:", "react-native": "0.88.0", "react-native-blob-util": "0.25.1", - "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz", + "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz", "react-native-gesture-handler": "~3.3.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.22.8", @@ -134,7 +129,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.44.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.48.tgz", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", "react-native-worklets": "0.13.0", @@ -152,6 +147,7 @@ "@types/mdast": "^4.0.4", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "aws4fetch": "1.0.20", "babel-preset-expo": "~58.0.17", "jsdom": "^30.0.1", "tailwindcss": "^4.0.0", diff --git a/apps/mobile/plugins/withAndroidCleartextTraffic.cjs b/apps/mobile/plugins/withAndroidCleartextTraffic.cjs deleted file mode 100644 index f612f751e9151..0000000000000 --- a/apps/mobile/plugins/withAndroidCleartextTraffic.cjs +++ /dev/null @@ -1,18 +0,0 @@ -const { withAndroidManifest } = require("expo/config-plugins"); - -module.exports = function withAndroidCleartextTraffic(config) { - return withAndroidManifest(config, (nextConfig) => { - const application = nextConfig.modResults.manifest.application?.[0]; - - if (application == null) { - throw new Error( - "AndroidManifest.xml is missing the application element required for cleartext traffic configuration.", - ); - } - - application.$ ??= {}; - application.$["android:usesCleartextTraffic"] = "true"; - - return nextConfig; - }); -}; diff --git a/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs b/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs new file mode 100644 index 0000000000000..90fea189e8d01 --- /dev/null +++ b/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs @@ -0,0 +1,56 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { withAndroidManifest, withDangerousMod } = require("expo/config-plugins"); + +// Environments behind a private CA installed in the user store must work like they do in Chrome. +// Android ignores usesCleartextTraffic once a network security config exists, so permit it here. +const NETWORK_SECURITY_CONFIG = ` + + + + + + + + +`; + +function withNetworkSecurityConfigManifest(config) { + return withAndroidManifest(config, (nextConfig) => { + const application = nextConfig.modResults.manifest.application?.[0]; + + if (application == null) { + throw new Error( + "AndroidManifest.xml is missing the application element required for network security configuration.", + ); + } + + application.$ ??= {}; + application.$["android:networkSecurityConfig"] = "@xml/network_security_config"; + + return nextConfig; + }); +} + +function withNetworkSecurityConfigResource(config) { + return withDangerousMod(config, [ + "android", + async (config) => { + const xmlDir = path.join( + config.modRequest.platformProjectRoot, + "app", + "src", + "main", + "res", + "xml", + ); + fs.mkdirSync(xmlDir, { recursive: true }); + fs.writeFileSync(path.join(xmlDir, "network_security_config.xml"), NETWORK_SECURITY_CONFIG); + return config; + }, + ]); +} + +module.exports = function withAndroidNetworkSecurityConfig(config) { + return withNetworkSecurityConfigResource(withNetworkSecurityConfigManifest(config)); +}; diff --git a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift b/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift deleted file mode 100644 index db279d0c62e75..0000000000000 --- a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation - -enum NWInterface { - enum InterfaceType { - case other, wifi, cellular, loopback, wiredEthernet - } -} - -struct NWPath { - enum Status { - case satisfied, unsatisfied - } - - // A reference inside the value models the native path's retained storage. - final class Storage { - let type: NWInterface.InterfaceType - init(_ type: NWInterface.InterfaceType) { self.type = type } - } - - let status: Status - let storage: Storage - - init(_ type: NWInterface.InterfaceType, status: Status = .satisfied) { - self.status = status - storage = Storage(type) - } - - func usesInterfaceType(_ type: NWInterface.InterfaceType) -> Bool { - storage.type == type - } -} - -final class NWPathMonitor { - static var scenario = "first" - static let deliveries = DispatchGroup() - var pathUpdateHandler: ((NWPath) -> Void)? - - func start(queue: DispatchQueue) { - guard let handler = pathUpdateHandler else { fatalError("Missing path handler") } - if Self.scenario == "offline" { - handler(NWPath(.wifi, status: .unsatisfied)) - } else if Self.scenario == "first" { - handler(NWPath(.wifi)) - handler(NWPath(.cellular)) - } else { - queue.async(group: Self.deliveries) { - handler(NWPath(.wifi)) - DispatchQueue.concurrentPerform(iterations: 128) { index in - handler(NWPath(index.isMultiple(of: 2) ? .cellular : .wiredEthernet)) - } - } - } - } - - // Already queued callbacks can run after cancellation. - func cancel() {} -} - -@main -enum NetworkModuleRegression { - static func main() { - NWPathMonitor.scenario = CommandLine.arguments[1] - let module = NetworkModule() - if NWPathMonitor.scenario == "concurrent" { - DispatchQueue.concurrentPerform(iterations: 64) { _ in - let state = module.getNetworkStateAsync() - precondition(state["type"] as? String == "WIFI", "The first path must survive late callbacks") - precondition(state["isConnected"] as? Bool == true) - } - NWPathMonitor.deliveries.wait() - } else { - let state = module.getNetworkStateAsync() - let connected = NWPathMonitor.scenario != "offline" - precondition(state["type"] as? String == (connected ? "WIFI" : "NONE")) - precondition(state["isConnected"] as? Bool == connected) - precondition(state["isInternetReachable"] as? Bool == connected) - } - print("passed") - } -} diff --git a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift deleted file mode 100644 index 6d13972d5742e..0000000000000 --- a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift +++ /dev/null @@ -1,189 +0,0 @@ -import Foundation - -// The registry runs on macOS without starting a simulator. Only the OS-facing -// types are replaced; the tests compile the dependency's actual Swift source. -public enum UIBackgroundFetchResult { - case noData -} - -public struct UNNotificationPresentationOptions: OptionSet { - public let rawValue: Int - public init(rawValue: Int) { self.rawValue = rawValue } -} - -public final class UNNotification: NSObject {} - -public final class UNNotificationResponse: NSObject { - let identifier: String - init(_ identifier: String) { self.identifier = identifier } -} - -public protocol UNUserNotificationCenterDelegate: AnyObject {} - -public final class UNUserNotificationCenter: NSObject { - private static let instance = UNUserNotificationCenter() - public weak var delegate: UNUserNotificationCenterDelegate? - public static func current() -> UNUserNotificationCenter { instance } -} - -private final class TestDelegate: NotificationDelegate { - private let lock = NSLock() - private var recordedEvents: [String] = [] - var onEvent: ((String) -> Void)? - var onResponse: ((UNNotificationResponse) -> Bool)? - - var events: [String] { lock.withLock { recordedEvents } } - - private func record(_ event: String) { - lock.withLock { recordedEvents.append(event) } - onEvent?(event) - } - - func didRegister(_ deviceToken: String) { record("registered") } - func didFailRegistration(_ error: Error) { record("failed") } - func openSettings(_ notification: UNNotification?) { record("settings") } - - func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { - record("present") - return false - } - - func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { - record("background") - return false - } - - func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { - record(response.identifier) - return onResponse?(response) ?? false - } -} - -private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { - if !condition() { - throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) - } -} - -private func deliver(_ identifier: String) { - NotificationCenterManager.shared.userNotificationCenter( - UNUserNotificationCenter.current(), - didReceive: UNNotificationResponse(identifier), - withCompletionHandler: {} - ) -} - -private func reentrantCallbacks() throws { - let manager = NotificationCenterManager.shared - let center = UNUserNotificationCenter.current() - let callbacks: [(String, () -> Void)] = [ - ("registered", { manager.didRegister("token") }), - ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }), - ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }), - ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }), - ("background", { manager.didReceive([:], completionHandler: { _ in }) }) - ] - - for (event, callback) in callbacks { - let original = TestDelegate() - let replacement = TestDelegate() - original.onEvent = { [weak original] _ in - guard let original else { return } - manager.removeDelegate(original) - manager.addDelegate(replacement) - } - manager.addDelegate(original) - callback() - try require(original.events == [event], "The original delegate must receive the callback once") - try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot") - callback() - try require(replacement.events == [event], "Reentrant registration must take effect for the next callback") - manager.removeDelegate(replacement) - } -} - -private func registrationDuringDelivery() throws { - let manager = NotificationCenterManager.shared - let original = TestDelegate() - let receiver = TestDelegate() - receiver.onResponse = { _ in true } - original.onResponse = { _ in - manager.addDelegate(receiver) - return false - } - manager.addDelegate(original) - deliver("handoff") - try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response") - manager.removeDelegate(original) - manager.removeDelegate(receiver) -} - -private func responseDuringReplay() throws { - let manager = NotificationCenterManager.shared - deliver("first") - let original = TestDelegate() - original.onResponse = { response in - if response.identifier == "first" { - deliver("second") - return true - } - return false - } - manager.addDelegate(original) - let receiver = TestDelegate() - receiver.onResponse = { _ in true } - manager.addDelegate(receiver) - try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks") - manager.removeDelegate(original) - manager.removeDelegate(receiver) -} - -private func concurrentRegistrations() throws { - let manager = NotificationCenterManager.shared - let delegates = (0..<512).map { _ in - let delegate = TestDelegate() - delegate.onResponse = { _ in true } - return delegate - } - DispatchQueue.concurrentPerform(iterations: delegates.count) { index in - manager.addDelegate(delegates[index]) - if index.isMultiple(of: 16) { - manager.didRegister("during-add") - deliver("during-add") - } - } - let before = delegates.map { $0.events.count } - manager.didRegister("after-add") - for (index, delegate) in delegates.enumerated() { - try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once") - } - DispatchQueue.concurrentPerform(iterations: delegates.count) { index in - manager.removeDelegate(delegates[index]) - if index.isMultiple(of: 16) { - manager.didRegister("during-remove") - deliver("during-remove") - } - } - let removed = delegates.map { $0.events.count } - manager.didRegister("after-remove") - try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks") -} - -@main -private enum RegressionTests { - static func main() { - do { - switch CommandLine.arguments.last { - case "reentrant": try reentrantCallbacks() - case "handoff": try registrationDuringDelivery() - case "pending": try responseDuringReplay() - case "concurrent": try concurrentRegistrations() - default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2) - } - print("passed") - } catch { - FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) - exit(1) - } - } -} diff --git a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m b/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m deleted file mode 100644 index 7124178383d49..0000000000000 --- a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m +++ /dev/null @@ -1,55 +0,0 @@ -@interface TestPermissionRequester : NSObject -@end - -@implementation TestPermissionRequester -+ (NSString *)permissionType { return NSStringFromClass(self); } -- (NSDictionary *)getPermissions { return @{ @"status": @(EXPermissionStatusGranted) }; } -- (void)requestPermissionsWithResolver:(EXPromiseResolveBlock)resolve rejecter:(EXPromiseRejectBlock)reject { - resolve([self getPermissions]); -} -@end - -typedef struct { - __unsafe_unretained EXPermissionsService *service; - __unsafe_unretained NSArray *requesters; - size_t offset; -} Worker; - -static void *exerciseRegistry(void *context) { - Worker *worker = context; - for (size_t index = 0; index < 250; index++) { - @autoreleasepool { - id requester = worker->requesters[(index + worker->offset) % worker->requesters.count]; - [worker->service registerRequesters:@[requester]]; - id resolved = [worker->service getPermissionRequesterForType:[[requester class] permissionType]]; - assert(resolved == requester); - NSDictionary *permissions = [worker->service getPermissionUsingRequesterClass:[requester class]]; - assert([permissions[@"status"] isEqualToString:@"granted"]); - } - } - return NULL; -} - -int main(void) { - @autoreleasepool { - EXPermissionsService *service = [EXPermissionsService new]; - NSMutableArray *requesters = [NSMutableArray new]; - for (int i = 0; i < 32; i++) { - NSString *name = [NSString stringWithFormat:@"TestPermission%d", i]; - Class cls = objc_allocateClassPair([TestPermissionRequester class], name.UTF8String, 0); - objc_registerClassPair(cls); - [requesters addObject:[cls new]]; - } - Worker workers[8]; - pthread_t threads[8]; - for (size_t index = 0; index < 8; index++) { - workers[index] = (Worker){ service, requesters, index }; - assert(pthread_create(&threads[index], NULL, exerciseRegistry, &workers[index]) == 0); - } - for (size_t index = 0; index < 8; index++) { - assert(pthread_join(threads[index], NULL) == 0); - } - puts("passed"); - } - return 0; -} diff --git a/apps/mobile/scripts/sourcemap-upload.mts b/apps/mobile/scripts/sourcemap-upload.mts new file mode 100644 index 0000000000000..a962f98a37805 --- /dev/null +++ b/apps/mobile/scripts/sourcemap-upload.mts @@ -0,0 +1,165 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { mobileSourceMapKey, type MobilePlatform } from "@t3tools/shared/mobileSourceMaps"; +import { AwsClient } from "aws4fetch"; + +export interface SourceMapUpload { + readonly key: string; + readonly path: string; +} + +/** The `MOBILE_SOURCEMAPS_R2` secret: the relay stack's `mobileSourceMapsUploadCredentials`. */ +interface UploadCredentials { + readonly endpoint: string; + readonly bucketName: string; + readonly accessKeyId: string; + readonly secretAccessKey: string; +} + +const isPlatform = (value: unknown): value is MobilePlatform => + value === "ios" || value === "android"; + +const readJson = (path: string): unknown => JSON.parse(NodeFS.readFileSync(path, "utf8")); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export function parseCredentials(value: string | undefined): UploadCredentials | undefined { + if (!value?.trim()) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + // JSON.parse's message quotes the input, which here is the secret. + throw new Error("MOBILE_SOURCEMAPS_R2 is not valid JSON."); + } + if (!isRecord(parsed)) throw new Error("MOBILE_SOURCEMAPS_R2 is not a JSON object."); + const { endpoint, bucketName, accessKeyId, secretAccessKey } = parsed; + if ( + typeof endpoint !== "string" || + typeof bucketName !== "string" || + typeof accessKeyId !== "string" || + typeof secretAccessKey !== "string" + ) { + throw new Error( + "MOBILE_SOURCEMAPS_R2 needs endpoint, bucketName, accessKeyId and secretAccessKey.", + ); + } + return { endpoint, bucketName, accessKeyId, secretAccessKey }; +} + +/** One map per update `eas update --emit-metadata` published, next to the bundle `metadata.json` names. */ +export function otaSourceMapUploads(distDir: string): ReadonlyArray { + const metadata = readJson(NodePath.join(distDir, "metadata.json")); + const easMetadata = readJson(NodePath.join(distDir, "eas-update-metadata.json")); + const fileMetadata = isRecord(metadata) ? metadata.fileMetadata : undefined; + const updates = isRecord(easMetadata) ? easMetadata.updates : undefined; + if (!isRecord(fileMetadata) || !Array.isArray(updates)) { + throw new Error(`${distDir} is missing Expo export or EAS update metadata.`); + } + return updates.flatMap((update): Array => { + if (!isRecord(update) || !isPlatform(update.platform) || typeof update.id !== "string") { + return []; + } + const platformFiles = fileMetadata[update.platform]; + const bundle = isRecord(platformFiles) ? platformFiles.bundle : undefined; + if (typeof bundle !== "string") return []; + return [ + { + key: mobileSourceMapKey(update.platform, "ota", update.id), + path: NodePath.join(distDir, `${bundle}.map`), + }, + ]; + }); +} + +/** Android's Gradle plugin writes the map under the release variant; iOS to `SOURCEMAP_FILE` (eas.json). */ +export function buildSourceMapUpload( + env: Readonly>, + projectDir: string, +): SourceMapUpload { + const platform = env.EAS_BUILD_PLATFORM; + const buildId = env.EAS_BUILD_ID; + if (!isPlatform(platform) || !buildId) { + throw new Error("EAS_BUILD_PLATFORM and EAS_BUILD_ID are only set on EAS Build."); + } + if (platform === "ios") { + if (!env.SOURCEMAP_FILE) throw new Error("SOURCEMAP_FILE is not set for this iOS build."); + return { key: mobileSourceMapKey("ios", "build", buildId), path: env.SOURCEMAP_FILE }; + } + const variantsDir = NodePath.join(projectDir, "android/app/build/generated/sourcemaps/react"); + const variant = NodeFS.existsSync(variantsDir) + ? NodeFS.readdirSync(variantsDir).find((name) => /release$/iu.test(name)) + : undefined; + if (!variant) throw new Error(`No release source map under ${variantsDir}.`); + return { + key: mobileSourceMapKey("android", "build", buildId), + path: NodePath.join(variantsDir, variant, "index.android.bundle.map"), + }; +} + +/** + * EAS writes the app's debug symbols next to the `.ipa`, so native frames in iOS crash + * reports can be read with `atos`. Undefined when the build left none. + */ +export function buildDsymUpload( + env: Readonly>, + projectDir: string, +): SourceMapUpload | undefined { + if (env.EAS_BUILD_PLATFORM !== "ios" || !env.EAS_BUILD_ID) return undefined; + const buildDir = NodePath.join(projectDir, "ios/build"); + const dsym = NodeFS.existsSync(buildDir) + ? NodeFS.readdirSync(buildDir).find((name) => name.endsWith(".app.dSYM.zip")) + : undefined; + if (!dsym) return undefined; + return { key: `ios/dsym/${env.EAS_BUILD_ID}.zip`, path: NodePath.join(buildDir, dsym) }; +} + +/** Only what the relay reads: source text stays out of the bucket, and the rest is dead weight. */ +function stripToMappings(text: string): string { + const map: unknown = JSON.parse(text); + if (!isRecord(map)) throw new Error("Source map is not a JSON object."); + const { version, sources, sourceRoot, mappings } = map; + return JSON.stringify({ version, sources, sourceRoot, mappings }); +} + +export function uploadSourceMap( + credentials: UploadCredentials, + upload: SourceMapUpload, +): Promise { + return putObject( + credentials, + upload.key, + stripToMappings(NodeFS.readFileSync(upload.path, "utf8")), + "application/json", + ); +} + +export function uploadDsym(credentials: UploadCredentials, upload: SourceMapUpload) { + const body = new Uint8Array(NodeFS.readFileSync(upload.path)); + return putObject(credentials, upload.key, body, "application/zip"); +} + +async function putObject( + credentials: UploadCredentials, + key: string, + body: string | Uint8Array, + contentType: string, +): Promise { + const client = new AwsClient({ + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + service: "s3", + region: "auto", + }); + const url = `${credentials.endpoint.replace(/\/$/u, "")}/${credentials.bucketName}/${key}`; + const response = await client.fetch(url, { + method: "PUT", + body, + headers: { "content-type": contentType }, + }); + if (!response.ok) { + throw new Error(`R2 answered ${response.status}: ${(await response.text()).slice(0, 500)}`); + } + return body.length; +} diff --git a/apps/mobile/scripts/sourcemap-upload.test.ts b/apps/mobile/scripts/sourcemap-upload.test.ts new file mode 100644 index 0000000000000..ee8ae57d690ae --- /dev/null +++ b/apps/mobile/scripts/sourcemap-upload.test.ts @@ -0,0 +1,136 @@ +import * as NodeFS from "node:fs"; +import * as NodeHttp from "node:http"; +import type * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + buildDsymUpload, + buildSourceMapUpload, + otaSourceMapUploads, + uploadSourceMap, +} from "./sourcemap-upload.mts"; + +const directories: Array = []; +const tempDir = () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "sourcemap-upload-")); + directories.push(dir); + return dir; +}; +const writeFile = (path: string, contents: string) => { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, contents); +}; + +afterEach(() => { + for (const dir of directories.splice(0)) NodeFS.rmSync(dir, { recursive: true, force: true }); +}); + +describe("uploadSourceMap", () => { + it("PUTs each published update's map, only what the relay reads, under its update id", async () => { + const dist = tempDir(); + writeFile( + NodePath.join(dist, "metadata.json"), + JSON.stringify({ + fileMetadata: { + android: { bundle: "_expo/static/js/android/index-abc.hbc" }, + ios: { bundle: "_expo/static/js/ios/index-def.hbc" }, + }, + }), + ); + writeFile( + NodePath.join(dist, "eas-update-metadata.json"), + JSON.stringify({ + updates: [ + { id: "update-android", platform: "android" }, + { id: "update-ios", platform: "ios" }, + { id: "update-web", platform: "web" }, + ], + }), + ); + for (const platform of ["android", "ios"]) { + writeFile( + NodePath.join( + dist, + `_expo/static/js/${platform}/index-${platform === "ios" ? "def" : "abc"}.hbc.map`, + ), + JSON.stringify({ + version: 3, + sources: [`${platform}.ts`], + sourcesContent: ["secret"], + names: ["render"], + x_facebook_sources: [[null]], + mappings: "", + }), + ); + } + + const requests: Array<{ method?: string; url?: string; auth?: string; body: string }> = []; + const server = NodeHttp.createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + requests.push({ + method: request.method, + url: request.url, + auth: request.headers.authorization, + body, + }); + response.end(); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address() as NodeNet.AddressInfo; + const credentials = { + endpoint: `http://127.0.0.1:${address.port}`, + bucketName: "maps", + accessKeyId: "key-id", + secretAccessKey: "secret-key", + }; + for (const upload of otaSourceMapUploads(dist)) { + await uploadSourceMap(credentials, upload); + } + expect(requests).toEqual( + ["android", "ios"].map((platform) => ({ + method: "PUT", + url: `/maps/${platform}/ota/update-${platform}.map`, + auth: expect.stringMatching(/^AWS4-HMAC-SHA256 Credential=key-id\/\d{8}\/auto\/s3\//), + body: JSON.stringify({ version: 3, sources: [`${platform}.ts`], mappings: "" }), + })), + ); + } finally { + server.close(); + } + }); +}); + +describe("buildSourceMapUpload", () => { + it("names the EAS build's embedded bundle map under its build id", () => { + const project = tempDir(); + const androidMap = NodePath.join( + project, + "android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", + ); + writeFile(androidMap, "{}"); + expect( + buildSourceMapUpload({ EAS_BUILD_PLATFORM: "android", EAS_BUILD_ID: "build-1" }, project), + ).toEqual({ key: "android/build/build-1.map", path: androidMap }); + expect( + buildSourceMapUpload( + { EAS_BUILD_PLATFORM: "ios", EAS_BUILD_ID: "build-2", SOURCEMAP_FILE: "/tmp/main.map" }, + project, + ), + ).toEqual({ key: "ios/build/build-2.map", path: "/tmp/main.map" }); + }); + + it("names an iOS build's dSYMs under its build id", () => { + const project = tempDir(); + const dsym = NodePath.join(project, "ios/build/T3Code.app.dSYM.zip"); + writeFile(dsym, "zip"); + expect( + buildDsymUpload({ EAS_BUILD_PLATFORM: "ios", EAS_BUILD_ID: "build-3" }, project), + ).toEqual({ key: "ios/dsym/build-3.zip", path: dsym }); + }); +}); diff --git a/apps/mobile/scripts/sync-context-chip-icons.mjs b/apps/mobile/scripts/sync-context-chip-icons.mjs index aac4983d75e2c..d99d4d3c98478 100644 --- a/apps/mobile/scripts/sync-context-chip-icons.mjs +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -43,6 +43,7 @@ const lucideIcons = [ "message-circle", "messages-square", "mouse-pointer-click", + "server", "terminal", ]; diff --git a/apps/mobile/scripts/upload-sourcemaps.mts b/apps/mobile/scripts/upload-sourcemaps.mts new file mode 100644 index 0000000000000..1656035906e44 --- /dev/null +++ b/apps/mobile/scripts/upload-sourcemaps.mts @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Usage: `ota ` after `eas update --emit-metadata`, or `build` from eas-build-on-success. +// Problems are warnings, never failures: an unreadable stack is not worth failing a release over. +import { + buildDsymUpload, + buildSourceMapUpload, + otaSourceMapUploads, + parseCredentials, + uploadDsym, + uploadSourceMap, + type SourceMapUpload, +} from "./sourcemap-upload.mts"; + +const warn = (message: string) => { + // GitHub Actions shows this as an annotation; elsewhere it is a log line. + console.warn(process.env.GITHUB_ACTIONS ? `::warning::${message}` : `warning: ${message}`); +}; + +const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +async function main() { + const [mode, distDir] = process.argv.slice(2); + const credentials = parseCredentials(process.env.MOBILE_SOURCEMAPS_R2); + if (!credentials) { + warn("MOBILE_SOURCEMAPS_R2 is not set; crash stacks from this bundle stay minified."); + return; + } + let uploads: ReadonlyArray; + if (mode === "ota" && distDir) { + uploads = otaSourceMapUploads(distDir); + } else if (mode === "build") { + uploads = [buildSourceMapUpload(process.env, process.cwd())]; + } else { + throw new Error("Usage: upload-sourcemaps.mts ota | build"); + } + if (uploads.length === 0) warn("Found no source maps to upload."); + for (const upload of uploads) { + try { + const bytes = await uploadSourceMap(credentials, upload); + console.log(`Uploaded ${upload.key} (${(bytes / 1e6).toFixed(1)} MB)`); + } catch (error) { + warn(`Could not upload ${upload.key}: ${errorMessage(error)}`); + } + } + if (mode !== "build" || process.env.EAS_BUILD_PLATFORM !== "ios") return; + const dsym = buildDsymUpload(process.env, process.cwd()); + if (!dsym) { + warn("Found no dSYM under ios/build; native iOS frames from this build stay unreadable."); + return; + } + try { + const bytes = await uploadDsym(credentials, dsym); + console.log(`Uploaded ${dsym.key} (${(bytes / 1e6).toFixed(1)} MB)`); + } catch (error) { + warn(`Could not upload ${dsym.key}: ${errorMessage(error)}`); + } +} + +await main().catch((error: unknown) => { + warn(`Source map upload skipped: ${errorMessage(error)}`); +}); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index f3ade720277f3..225496bb7a407 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,4 +1,6 @@ import { installPermissionUpdateNotice } from "./features/connection/permissionUpdateNotice"; +import { installComposerAttachmentUploads } from "./state/composer-attachment-uploads"; +import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; @@ -7,11 +9,15 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { - createNavigationContainerRef, + CommonActions, + createPathConfigForStaticNavigation, createStaticNavigation, getActionFromState, getStateFromPath, + type NavigationAction, + type NavigationState, } from "@react-navigation/native"; +import { pipe } from "effect/Function"; import { RegistryContext } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; @@ -19,12 +25,26 @@ import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet import * as Dialog from "./components/ui/Dialog"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; -import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; +import { installShowcaseCapture } from "./features/showcase/ShowcaseCaptureCoordinator"; +import { + presentPendingIncomingShare, + pruneIncomingShareImports, + startIncomingShareInbox, +} from "./features/sharing/IncomingShareProvider"; +import { + installAgentNotificationNavigation, + setAgentNotificationPathname, +} from "./features/agent-awareness/notificationNavigation"; +import { + installAppShortcuts, + recordAppShortcutNavigationState, +} from "./features/shortcuts/appShortcutsRuntime"; import { AppearancePreferencesProvider, + installAppearanceRuntime, useAppearancePreferences, } from "./features/settings/appearance/AppearancePreferencesProvider"; -import { RootStack } from "./Stack"; +import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts"; import { environmentThreadShells } from "./state/threads"; @@ -33,14 +53,27 @@ import { OverlayPortalHost } from "./components/ui/OverlayPortal"; import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; import { hasCloudPublicConfig } from "./features/cloud/publicConfig"; import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state"; +import { + pairingFormLinkAction, + startDevAutoConnect, + withoutPairingTarget, +} from "./features/connection/devAutoConnect"; +import { + resolvePendingNewTaskPromptLink, + takeNewTaskPromptLink, +} from "./features/prompt-links/newTaskPromptLink"; +import { + resolvePendingThreadPromptLink, + takeThreadPromptLink, +} from "./features/prompt-links/threadPromptLink"; import { disposeOnFoundationReplace, type FoundationHotModule, } from "./lib/foundation-fast-refresh"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; -import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; +import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; -import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider"; +import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; import { @@ -51,6 +84,15 @@ import { } from "./features/workspace/workspace-selection-store"; import { workspaceDetailLinkAction } from "./features/workspace/workspace-detail-selection"; import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; +import { + attachRootNavigation, + flushRootNavigationQueue, + linkToRootPath, + rootNavigationRef, +} from "./lib/rootNavigation"; +import { pruneNewTaskFlowStates } from "./features/threads/new-task-flow-state"; +import { pruneNewTaskBranchCheckouts } from "./features/threads/NewTaskDraftRouteScreen"; +import { installAppUpdateChecks } from "./features/updates/app-updates"; import "../global.css"; @@ -61,6 +103,24 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { void SplashScreen.preventAutoHideAsync().catch(() => {}); void hydrateWorkspaceSelections(); +const linkingConfig = { + initialRouteName: "Home", + screens: createPathConfigForStaticNavigation(RootStack, { initialRouteName: "Home" }) ?? {}, +} as const; + +function getAppStateFromPath(path: string, options: Parameters[1]) { + return pipe( + getStateFromPath(path, options), + (state) => restoreDraftLinkState(state, path), + (state) => restoreSettingsLinkState(state, settingsLinkPage()), + takeThreadPromptLink, + takeNewTaskPromptLink, + withoutPairingTarget, + startDevAutoConnect, + takeScheduledTaskNewLink, + ); +} + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], config: { initialRouteName: "Home" }, @@ -72,30 +132,73 @@ const appLinking = { ): ReturnType => // Our router handles the selection; React Navigation's type only lists its own actions. (workspaceDetailLinkAction(state) as ReturnType) ?? + pairingFormLinkAction( + state, + rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined, + ) ?? getActionFromState(state, options), - getStateFromPath: (path: string, options: Parameters[1]) => - takeScheduledTaskNewLink( - restoreSettingsLinkState( - restoreDraftLinkState(getStateFromPath(path, options), path), - settingsLinkPage(), - ), - ), + getStateFromPath: getAppStateFromPath, }; +function appLinkAction(href: string): NavigationAction { + const state = getAppStateFromPath(href, linkingConfig); + if (!state) throw new Error(`Failed to parse the href to a navigation state (${href}).`); + return appLinking.getActionFromState(state, linkingConfig) ?? CommonActions.reset(state); +} + +function linkToAppPath(href: string): void { + linkToRootPath(href, appLinkAction); +} + const Navigation = createStaticNavigation(RootStack); -const navigationRef = createNavigationContainerRef(); + +function handleNavigationStateChange(state: NavigationState | undefined): void { + recordWorkspaceSelections(state); + presentPendingIncomingShare(); + resolvePendingNewTaskPromptLink(state); + resolvePendingThreadPromptLink(state); + if (!state) return; + setAgentNotificationPathname(workspaceLocationFromState(state).pathname); + recordAppShortcutNavigationState(state); + pruneNewTaskFlowStates(state); + pruneNewTaskBranchCheckouts(state); + pruneIncomingShareImports(state); +} + +function handleNavigationReady(): void { + handleNavigationStateChange(rootNavigationRef.getRootState()); + flushRootNavigationQueue(); +} declare const module: { readonly hot?: FoundationHotModule } | undefined; -const disposePermissionUpdateNotice = installPermissionUpdateNotice(); -disposeOnFoundationReplace( - typeof module === "undefined" ? undefined : module.hot, - disposePermissionUpdateNotice, -); +const appServiceDisposers = [ + installUiRuntimeMemoryWarningGc(), + subscribeComposerDraftModelReconciliation({ + registry: appAtomRegistry, + threadShellAtom: environmentThreadShells.threadShellAtom, + threadRefsAtom: environmentThreadShells.threadRefsAtom, + shellStateAtom: environmentShell.stateAtom, + }), + installAppearanceRuntime(), + installSubscriptionUsageRefresh(), + installPermissionUpdateNotice(), + installThreadOutboxDelivery(), + installComposerAttachmentUploads(), + startIncomingShareInbox(), + installAgentNotificationNavigation(linkToAppPath), + installAppShortcuts(linkToAppPath), + installAppUpdateChecks(), + installShowcaseCapture(rootPathnameFromState), +]; +disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => { + for (const dispose of appServiceDisposers) dispose(); +}); function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once appearance preferences have loaded useEffect(() => { if (isReady) void SplashScreen.hide(); }, [isReady]); @@ -104,18 +207,6 @@ function SplashScreenCoordinator() { } export default function App() { - useUiRuntimeMemoryWarningGc(); - useEffect( - () => - subscribeComposerDraftModelReconciliation({ - registry: appAtomRegistry, - threadShellAtom: environmentThreadShells.threadShellAtom, - threadRefsAtom: environmentThreadShells.threadRefsAtom, - shellStateAtom: environmentShell.stateAtom, - }), - [], - ); - return ( @@ -138,27 +229,23 @@ function AppContent() { - - - {/* native-stack needs the theme's dark flag to select dark native headers. */} - - - - recordWorkspaceSelections(navigationRef.getRootState())} - theme={navigationTheme} - onStateChange={recordWorkspaceSelections} - /> - - - - - - - - + + {/* native-stack needs the theme's dark flag to select dark native headers. */} + + + + + + + + + diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 83d075f34893e..7a0042903a3c3 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,7 +15,7 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; -import { useEffect, useRef, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { Platform, Pressable, ScrollView, StyleSheet, useWindowDimensions } from "react-native"; import { useResolveClassNames } from "uniwind"; @@ -24,6 +24,11 @@ import { androidWorkspaceDestinationAnimation } from "./lib/android-workspace-na import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { WorkspaceConnectionStatusProvider } from "./features/home/WorkspaceConnectionTitle"; import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; +import { + recordHomeFocus, + recordHomeTransitionEnd, + recordHomeTransitionStart, +} from "./features/home/home-route-visibility"; import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; import { PullRequestRouteScreen, @@ -41,9 +46,7 @@ import { type RenderFailureProps, } from "./components/RenderErrorBoundary"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; -import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; -import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; @@ -82,11 +85,11 @@ import { NewTaskEnvironmentPickerRouteScreen, } from "./features/threads/NewTaskContextPickerScreens"; import { - ExistingThreadSettingsRouteProvider, ExistingThreadSettingsRouteScreen, NewTaskThreadSettingsRouteScreen, } from "./features/threads/ThreadSettingsSheet"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; +import { newTaskFlowKey } from "./features/threads/new-task-flow-state"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsNavigationRouteScreen } from "./features/settings/SettingsNavigationRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; @@ -98,9 +101,9 @@ import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/Settin import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsFollowUpRouteScreen } from "./features/settings/SettingsFollowUpRouteScreen"; import { - SettingsEnvironmentAgentBehaviorRouteScreen, SettingsEnvironmentMaintenanceRouteScreen, - SettingsEnvironmentNewThreadsRouteScreen, + SettingsThreadsRouteScreen, + SettingsWorktreesRouteScreen, SettingsEnvironmentSourceControlRouteScreen, } from "./features/settings/SettingsServerControlsRouteScreen"; import { @@ -126,7 +129,7 @@ import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsAboutRouteScreen } from "./features/settings/SettingsAboutRouteScreen"; import { SettingsNotificationsRouteScreen } from "./features/settings/SettingsNotificationsRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; -import { SettingsThreadsRouteScreen } from "./features/settings/SettingsThreadsRouteScreen"; +import { SettingsLabsRouteScreen } from "./features/settings/ThreadSettingsSections"; import { SettingsEnvironmentFilterProvider } from "./features/settings/settings-environment-filter"; import { openSettings, settingsOpenIn } from "./features/settings/settings-navigation"; import { DEFAULT_SETTINGS_PAGE } from "./features/workspace/workspace-tab-navigation"; @@ -134,28 +137,17 @@ import { isWorkspaceOverlayRoute, registerPresentedRoutes, } from "./features/layout/workspace-overlay-routes"; -import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; +import { ShowcaseReadyMarker } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, SettingsLegalDocumentExternalHeaderButton, } from "./features/settings/components/SettingsLegalDocumentRouteScreen"; -import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; -import { useIncomingShare } from "./features/sharing/IncomingShareProvider"; -import { - EMPTY_INCOMING_SHARE_PRESENTATION_STATE, - transitionIncomingSharePresentation, -} from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; -import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; -import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; -import { - selectWorkspaceDetail, - workspaceDetailRouter, -} from "./features/workspace/workspace-detail-selection"; +import { workspaceDetailRouter } from "./features/workspace/workspace-detail-selection"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -264,10 +256,11 @@ const SETTINGS_SCREENS = { linking: "settings/environments/:environmentId", options: { ...SETTINGS_PAGE_OPTIONS, title: "Environment" }, }), + // Old links to New threads and Agent behavior open Threads, which absorbed them. SettingsEnvironmentNewThreads: createNativeStackScreen({ - screen: SettingsEnvironmentNewThreadsRouteScreen, + screen: SettingsThreadsRouteScreen, linking: "settings/new-threads", - options: { ...SETTINGS_PAGE_OPTIONS, title: "New threads" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, }), SettingsEnvironmentSourceControl: createNativeStackScreen({ screen: SettingsEnvironmentSourceControlRouteScreen, @@ -275,14 +268,24 @@ const SETTINGS_SCREENS = { options: { ...SETTINGS_PAGE_OPTIONS, title: "Source control" }, }), SettingsEnvironmentAgentBehavior: createNativeStackScreen({ - screen: SettingsEnvironmentAgentBehaviorRouteScreen, + screen: SettingsThreadsRouteScreen, linking: "settings/agent-behavior", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Agent behavior" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, + }), + SettingsWorktrees: createNativeStackScreen({ + screen: SettingsWorktreesRouteScreen, + linking: "settings/worktrees", + options: { ...SETTINGS_PAGE_OPTIONS, title: "Worktrees" }, + }), + SettingsLabs: createNativeStackScreen({ + screen: SettingsLabsRouteScreen, + linking: "settings/labs", + options: { ...SETTINGS_PAGE_OPTIONS, title: "Labs" }, }), SettingsProviderAccounts: createNativeStackScreen({ screen: SettingsProviderAccountsRouteScreen, linking: "settings/provider-accounts", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Provider accounts" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Providers" }, }), SettingsEnvironmentMaintenance: createNativeStackScreen({ screen: SettingsEnvironmentMaintenanceRouteScreen, @@ -297,12 +300,12 @@ const SETTINGS_SCREENS = { SettingsThreads: createNativeStackScreen({ screen: SettingsThreadsRouteScreen, linking: "settings/thread-preferences", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Thread behavior" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, }), SettingsAbout: createNativeStackScreen({ screen: SettingsAboutRouteScreen, linking: "settings/about", - options: { ...SETTINGS_PAGE_OPTIONS, title: "About T3 Code" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "About" }, }), SettingsEnvironmentNew: createNativeStackScreen({ screen: ConnectionsNewRouteScreen, @@ -317,7 +320,7 @@ const SETTINGS_SCREENS = { linking: "settings/archive", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Archived Threads", + title: "Archive", }, }), SettingsNavigation: createNativeStackScreen({ @@ -338,7 +341,7 @@ const SETTINGS_SCREENS = { linking: "settings/project-grouping", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Organization", + title: "General", }, }), SettingsOrganization: createNativeStackScreen({ @@ -346,13 +349,13 @@ const SETTINGS_SCREENS = { linking: "settings/organization", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Organization", + title: "General", }, }), SettingsProjectOverview: createNativeStackScreen({ screen: SettingsProjectOverviewRouteScreen, linking: "settings/project", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Project overview" }, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Projects" }, }), SettingsKeyboard: createNativeStackScreen({ screen: SettingsKeyboardRouteScreen, @@ -375,7 +378,7 @@ const SETTINGS_SCREENS = { linking: "settings/follow-ups", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Follow-ups", + title: "Chat", }, }), SettingsScheduledTasks: createNativeStackScreen({ @@ -383,7 +386,7 @@ const SETTINGS_SCREENS = { linking: "settings/scheduled-tasks", options: { ...SETTINGS_PAGE_OPTIONS, - title: "Scheduled Tasks", + title: "Scheduled tasks", headerTitleStyle: { fontSize: 16, fontWeight: "800" }, }, }), @@ -473,97 +476,74 @@ const SETTINGS_SCREENS = { // iOS 26 header morphing requires one UINavigationBar; nested stacks make the whole screen slide. const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; -function workspaceLocationFromState(state: NavigationState) { +export function workspaceLocationFromState(state: NavigationState) { const routes = state.routes.filter((route) => !isWorkspaceOverlayRoute(route.name)); const effectiveState = routes.length > 0 && routes.length !== state.routes.length ? ({ ...state, routes, index: routes.length - 1 } as NavigationState) : state; const path = getPathFromState(effectiveState, navigationPathConfig); + const route = effectiveState.routes[effectiveState.index]; return { pathname: path.startsWith("/") ? path : `/${path}`, - routeKey: effectiveState.routes[effectiveState.index]?.key, + routeKey: route?.key, + routeName: route?.name, + routeKeys: effectiveState.routes.map((stackRoute) => stackRoute.key), }; } -// Isolate subscriptions so streaming updates cannot re-render RootStackLayout and every screen. -function ThreadOutboxDrainWorker() { - useThreadOutboxDrain(); - useComposerAttachmentUploadWorker(); - return null; +export function rootPathnameFromState(state: NavigationState): string { + const path = getPathFromState(state, navigationPathConfig); + return path.startsWith("/") ? path : `/${path}`; } function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; }) { - const navigation = useNavigation(); - const { pendingShare } = useIncomingShare(); - const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); - const path = getPathFromState(props.state, navigationPathConfig); - const pathname = path.startsWith("/") ? path : `/${path}`; + const pathname = rootPathnameFromState(props.state); const workspaceLocation = workspaceLocationFromState(props.state); const settingsOpen = settingsOpenIn( props.state.routes .slice(0, props.state.index + 1) .filter((route) => !isWorkspaceOverlayRoute(route.name)), ); - const draftRoute = props.state.routes.findLast((route) => route.name === "Thread"); + // The flow follows the draft route even while a pushed Thread covers it, so + // its state lives exactly as long as the route. + const draftRoute = props.state.routes.findLast(isDraftThreadRoute); + const draftRouteOnTop = + draftRoute !== undefined && + props.state.routes.findLast((route) => route.name === "Thread") === draftRoute; const draftParams = ( - draftRoute?.params as - | { draft?: { draftId?: string | string[]; pendingTaskId?: string | string[] } } - | undefined + draftRoute?.params as { draft?: { draftId?: string | string[] } } | undefined )?.draft; const routeDraftId = Array.isArray(draftParams?.draftId) ? draftParams.draftId[0] : draftParams?.draftId; - const routePendingTaskId = Array.isArray(draftParams?.pendingTaskId) - ? draftParams.pendingTaskId[0] - : draftParams?.pendingTaskId; - useAgentNotificationNavigation(workspaceLocation.pathname); - useConnectOnboardingNavigation(); - useAppShortcuts(props.state); - useEffect(() => { - const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { - isShareSheetPresented: isDraftThreadRoute(props.state.routes[props.state.index]), - pendingShareId: pendingShare?.id ?? null, - }); - sharePresentationRef.current = transition.state; - if (!transition.shareIdToPresent) { - return; - } - navigation.dispatch( - selectWorkspaceDetail({ - name: "Thread", - params: { draft: { incomingShareId: transition.shareIdToPresent } }, - }), - ); - }, [navigation, pendingShare, props.state]); return ( - - + - - - - - {props.children} - - - - - + + + + {props.children} + + + + @@ -630,6 +610,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ Home: createNativeStackScreen({ screen: WorkspaceRootScreen, linking: "", + listeners: { + transitionStart: ({ data }) => recordHomeTransitionStart(data.closing), + transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing), + focus: recordHomeFocus, + }, options: { ...GLASS_HEADER_OPTIONS, ...FIXED_DUO_HEADER, diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 272d3be266dd7..7a251a2e57c16 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -10,6 +10,8 @@ import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../lib/mobileBranding"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; +const BRAND_MAX_FONT_SCALE = 1.4; + export function brandTitleOffset(): number { if (Platform.OS !== "ios") return 0; return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0; @@ -49,8 +51,10 @@ export function CompactBrandTitle( ]} > + {/* A logo beside a fixed-size wordmark: it may grow with Dynamic Type, not crowd the bar. */} @@ -67,6 +71,7 @@ export function CompactBrandTitle( > @@ -89,7 +94,7 @@ export function CompactBrandTitle( ); } -export function renderCompactBrandTitle() { +function renderCompactBrandTitle() { return ; } diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 8cac0b59cfdf9..923afb87a3a1f 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -3,10 +3,13 @@ import { composerImageGallery } from "../lib/composerImageGallery"; import { SymbolView } from "./ui/AppSymbol"; import { imageMimeType } from "@t3tools/shared/image"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEffect, useMemo, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { Paths } from "expo-file-system"; import { Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./ui/AppText"; +import { MaterializedPreviewKey, materializedPreviewAtom } from "../lib/composerImagePreview"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { isFileBackedComposerAttachment, @@ -93,65 +96,31 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr ); } -const PREVIEW_CACHE_DIRECTORY = "t3-composer-previews"; - /** Fabric re-parses URLs each layout; large data URLs can stall and abort commits. */ const INLINE_PREVIEW_FALLBACK_MAX_CHARS = 256_000; -async function materializeDataUrlPreview(id: string, dataUrl: string): Promise { - const comma = dataUrl.indexOf(","); - if (comma < 0) return null; - const { Directory, File, Paths } = await import("expo-file-system"); - const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/jpeg"; - const extension = (mimeType.split("/")[1] ?? "jpg").replace("jpeg", "jpg"); - const directory = new Directory(Paths.cache, PREVIEW_CACHE_DIRECTORY); - directory.create({ idempotent: true, intermediates: true }); - const file = new File(directory, `${id}.${extension}`); - if (!file.exists) { - file.create(); - await file.write(dataUrl.slice(comma + 1), { encoding: "base64" }); - } - return file.uri; -} +const noMaterializedPreview = Atom.make(AsyncResult.initial()).pipe( + Atom.withLabel("composer-image-preview:none"), +); function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string | null { - const { id, fileUri, previewUri } = attachment; - const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null); - const [materialized, setMaterialized] = useState<{ id: string; uri: string | null } | null>(null); + const { fileUri, previewUri } = attachment; const inlinePreview = fileUri === undefined && previewUri.startsWith("data:"); - useEffect(() => { - if (fileUri === undefined) return; - let cancelled = false; - void (async () => { - const { Paths } = await import("expo-file-system"); - const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri); - if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned }); - })(); - return () => { - cancelled = true; - }; - }, [fileUri, previewUri]); - useEffect(() => { - if (!inlinePreview) return; - let cancelled = false; - void materializeDataUrlPreview(id, previewUri) - .then((uri) => { - if (!cancelled && uri !== null) setMaterialized({ id, uri }); - }) - .catch((error: unknown) => { - console.warn("[composer-attachments] could not cache an image preview", error); - if (!cancelled) setMaterialized({ id, uri: null }); - }); - return () => { - cancelled = true; - }; - }, [id, inlinePreview, previewUri]); - if (fileUri !== undefined && rebased?.fileUri === fileUri) return rebased.uri; - if (fileUri !== undefined) return previewUri.startsWith("data:") ? fileUri : previewUri; + const materialized = useAtomValue( + inlinePreview + ? materializedPreviewAtom(new MaterializedPreviewKey(attachment)) + : noMaterializedPreview, + ); + if (fileUri !== undefined) { + // Reinstalls move the app container, so a stored file URI may point at the old one. + const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri); + if (owned !== null) return owned; + return previewUri.startsWith("data:") ? fileUri : previewUri; + } if (inlinePreview) { - if (materialized?.id !== id) return null; + if (!AsyncResult.isSuccess(materialized)) return null; return ( - materialized.uri ?? + materialized.value ?? (previewUri.length <= INLINE_PREVIEW_FALLBACK_MAX_CHARS ? previewUri : null) ); } @@ -280,15 +249,12 @@ function ComposerVideoAttachment(props: { const { attachment } = props; const sourceIdentifier = `draft:${attachment.id}`; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; - const actionsSource = useMemo( - () => ({ - name: attachment.name, - mimeType: videoMimeType(attachment) ?? attachment.mimeType, - sourceIdentifier, - attachment, - }), - [attachment, sourceIdentifier], - ); + const actionsSource: MediaActionsSource = { + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + sourceIdentifier, + attachment, + }; return ( composerImageGallery(props.attachments), [props.attachments]); + const gallery = composerImageGallery(props.attachments); const size = props.imageSize ?? 72; const radius = props.imageBorderRadius ?? 16; const removeButtonPlacement = props.removeButtonPlacement ?? "overlay"; diff --git a/apps/mobile/src/components/ComposerContextAttachment.tsx b/apps/mobile/src/components/ComposerContextAttachment.tsx index b198c5add654c..edb5522f3d0f8 100644 --- a/apps/mobile/src/components/ComposerContextAttachment.tsx +++ b/apps/mobile/src/components/ComposerContextAttachment.tsx @@ -5,10 +5,13 @@ import * as Dialog from "./ui/Dialog"; import type { ComposerContextRecord, EnvironmentId } from "@t3tools/contracts"; import { Pressable, View } from "react-native"; import { Image } from "expo-image"; -import { useEffect, useId, useState } from "react"; +import { useId, useState } from "react"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages"; -import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { + loadLocalAttachmentPreview, + useLocalAttachmentPreview, +} from "../lib/localAttachmentPreview"; import { downloadAndShareAttachment } from "../lib/attachmentDownload"; import { assetImageSource } from "../lib/assetImageSource"; import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; @@ -35,31 +38,13 @@ export function ComposerContextAttachment(props: { const remoteEnvironmentId = local || inlineUri ? null : (props.environmentId ?? null); const asset = useAssetUrlState(remoteEnvironmentId, resource); const refresh = useRefreshAssetUrl(remoteEnvironmentId, resource); - const [localUri, setLocalUri] = useState(null); - const [error, setError] = useState(null); + const localPreview = useLocalAttachmentPreview(local ?? null); + const error = localPreview.error ? "The local file is unavailable. Attach it again." : null; const [sharing, setSharing] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); - useEffect(() => { - if (!local) return; - const controller = new AbortController(); - let dispose: (() => void) | undefined; - void loadLocalAttachmentPreview(local, controller.signal) - .then((preview) => { - if (!preview) return; - if (controller.signal.aborted) return preview.dispose(); - dispose = preview.dispose; - setLocalUri(preview.uri); - setError(null); - }) - .catch(() => { - if (!controller.signal.aborted) setError("The local file is unavailable. Attach it again."); - }); - return () => { - controller.abort(); - dispose?.(); - }; - }, [local]); - const uri = local ? localUri : (inlineUri ?? (asset._tag === "Success" ? asset.url : null)); + const uri = local + ? localPreview.uri + : (inlineUri ?? (asset._tag === "Success" ? asset.url : null)); const share = async () => { if (sharing) return; setSharing(true); diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 20c7485ef15d6..8121bfdece763 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -324,6 +324,9 @@ export function ComposerContextSheet(props: { ) : null} ) : null} + {record.kind === "environment" ? ( + + ) : null} {record.kind === "skill" ? ( diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 0098605414bd2..59c0a74e4525f 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -27,6 +27,7 @@ import { startComposerDraftOperation } from "../state/composerDraftOperations"; import { appAtomRegistry } from "../state/atom-registry"; import { mobilePreferencesAtom } from "../state/preferences"; import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread"; +import { useLinkedEnvironments } from "../state/peerLinks"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./ui/AppText"; import { @@ -61,6 +62,7 @@ export function ComposerEditor({ ? preferencesResult.value.composerEnterBehavior : undefined; const contextHistory = createComposerDraftContextHistory(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the attachment file leases held for native text undo when the history is replaced or unmounts useEffect(() => () => contextHistory.dispose(), [contextHistory]); const changeText = (text: string) => { const restored = contextHistory.restore( @@ -76,6 +78,7 @@ export function ComposerEditor({ const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( null, ); + const linkedEnvironments = useLinkedEnvironments(environmentId ?? null); const contextImports = useAtomValue(composerContextImportsAtom); const importing = draftKey ? contextImports[draftKey] === true : false; const pasteContext = async ( @@ -100,6 +103,7 @@ export function ComposerEditor({ signal, retained.context?.records.length ?? 0, environmentId, + linkedEnvironments, ); if (signal.aborted) return; if (!result) { diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index 431cf61b1ec62..e422cfabd4591 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -70,7 +70,7 @@ export function ComposerModelControl(props: { /> ) } - label={label} + label={props.modelOption?.shortName ?? label} iconOnly={props.iconOnly} suffix={ summary ? ( diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index f2b92eaf89260..8963a4039d5f3 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -1,5 +1,5 @@ import type { ComponentProps, ReactNode } from "react"; -import { useCallback, useMemo, useState } from "react"; +import { useState } from "react"; import { Pressable, ScrollView, @@ -154,33 +154,31 @@ export function ComposerToolbarScroller(props: { viewportWidth: 0, }); - const scrollEdges = useMemo(() => { - const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); - return { - showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, - showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, - }; - }, [metrics]); + const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); + const scrollEdges = { + showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, + showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, + }; - const handleLayout = useCallback((event: LayoutChangeEvent) => { + const handleLayout = (event: LayoutChangeEvent) => { const viewportWidth = event.nativeEvent.layout.width; setMetrics((current) => current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth }, ); - }, []); + }; - const handleContentSizeChange = useCallback((contentWidth: number) => { + const handleContentSizeChange = (contentWidth: number) => { setMetrics((current) => current.contentWidth === contentWidth ? current : { ...current, contentWidth }, ); - }, []); + }; - const handleScroll = useCallback((event: NativeSyntheticEvent) => { + const handleScroll = (event: NativeSyntheticEvent) => { const offsetX = event.nativeEvent.contentOffset.x; setMetrics((current) => Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX }, ); - }, []); + }; return ( diff --git a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx index 901a032845f9a..f4dce3c978081 100644 --- a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx +++ b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx @@ -11,7 +11,7 @@ export const ENVIRONMENT_MACHINE_SYMBOLS = { "mac-studio": "macstudio", } as const satisfies Record; -export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { +const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { server: "Server", cloud: "Cloud VM", linux: "Linux/WSL", diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index ed5c6e56ee891..45f09779f13be 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -34,6 +34,7 @@ function NativeFilePreview(props: { else Dialog.alert("Could not open preview", "The file could not be loaded. Please try again."); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- presents the native file preview and dismisses it on cleanup useEffect(() => { let canceled = false; const presentation = diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index b0cf1fc5c3ab0..486cd9e236b8e 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -55,6 +55,7 @@ function DocumentPreview(props: { "A compatible viewer must be installed. Check your connection and try again.", ); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the attachment in an external viewer and aborts the open on cleanup useEffect(() => { const controller = new AbortController(); const input = { diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 77116c8800126..849bacd8ac41b 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -1,15 +1,5 @@ import * as Dialog from "./ui/Dialog"; -import { useNavigation } from "@react-navigation/native"; -import { - useCallback, - useContext, - useEffect, - useEffectEvent, - useState, - useSyncExternalStore, - useMemo, -} from "react"; -import { Keyboard } from "react-native"; +import { useContext, useEffect, useEffectEvent, useState } from "react"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { @@ -17,7 +7,8 @@ import { nativeViewerErrorMessage, shareLocalAttachment, } from "../lib/attachmentDownload"; -import { useRefreshAssetUrl } from "../state/assets"; +import { usePreviewPresentation } from "../lib/previewPresentation"; +import { refreshAssetUrl } from "../state/assets"; import { filePreviewGallery, filePreviewKey, filePreviewName } from "../lib/filePreviewGallery"; import { FilePreviewGallery } from "./FilePreviewGallery"; import { FilePreview } from "./FilePreview"; @@ -33,77 +24,47 @@ export type { ResolvedFilePreviewSource, } from "./FilePreviewModal.types"; -function PreviewItemResolver(props: { - readonly source: FilePreviewItem; - readonly onResolved: (source: ResolvedFilePreviewSource) => void; - readonly onRequestClose: () => void; - readonly onOpenError?: (error: unknown) => void; -}) { - const { source } = props; - const environmentId = "environmentId" in source ? source.environmentId : null; - const refreshAssetUrl = useRefreshAssetUrl( - environmentId, - "resource" in source ? source.resource : null, - ); - // Resolve once per presentation; background URL refreshes must not reopen the native viewer. - const [uri, setUri] = useState("uri" in source ? source.uri : null); - const onRequestClose = useEffectEvent(props.onRequestClose); - const onResolutionError = useEffectEvent((error: unknown, fallbackMessage: string) => { - if (props.onOpenError) props.onOpenError(error); - else Dialog.alert("Could not open preview", fallbackMessage); - onRequestClose(); - }); - useEffect(() => Keyboard.dismiss(), []); - useEffect(() => { - if (environmentId === null || uri !== null) return; - let cancelled = false; - // Refresh expired URLs before handing them to Quick Look or ACTION_VIEW, which retain them. - void refreshAssetUrl() - .then((url) => { - if (cancelled) return; - if (!url) throw new Error("Reconnect to this environment and try again."); - setUri(url + (source.srcFragment ?? "")); - }) - .catch((error: unknown) => { - if (cancelled) return; - onResolutionError( - error, - "Reconnect to this environment and try again. The file may have been moved or deleted.", - ); - }); - return () => { - cancelled = true; - }; - }, [environmentId, uri, refreshAssetUrl, source.srcFragment]); - useEffect(() => { - if (!("attachment" in source)) return; - const controller = new AbortController(); - let release: (() => void) | undefined; - void loadLocalAttachmentPreview(source.attachment, controller.signal) - .then((file) => { - if (!file) return; - if (controller.signal.aborted) { - file.dispose(); - return; - } - release = file.dispose; - setUri(file.uri); - }) - .catch((error: unknown) => { - if (controller.signal.aborted) return; - onResolutionError(error, "Attach the file again and retry."); - }); - return () => { - controller.abort(); - release?.(); - }; - }, [source]); +class PreviewResolutionError extends Error { + constructor( + override readonly cause: unknown, + readonly fallbackMessage: string, + ) { + super(fallbackMessage, { cause }); + } +} - const onResolved = useEffectEvent(props.onResolved); - useEffect(() => { - if (uri !== null) onResolved({ ...source, name: filePreviewName(source), uri }); - }, [uri, source]); - return null; +interface ResolvedPreviewItem { + readonly source: ResolvedFilePreviewSource; + readonly dispose?: () => void; +} + +async function resolvePreviewItem( + item: FilePreviewItem, + signal: AbortSignal, +): Promise { + const name = filePreviewName(item); + if ("attachment" in item) { + const file = await loadLocalAttachmentPreview(item.attachment, signal).catch( + (error: unknown) => { + throw new PreviewResolutionError(error, "Attach the file again and retry."); + }, + ); + return file && { source: { ...item, name, uri: file.uri }, dispose: file.dispose }; + } + if (!("environmentId" in item)) return { source: { ...item, name } }; + // Refresh expired URLs before handing them to Quick Look or ACTION_VIEW, which retain them. + const url = await refreshAssetUrl(item.environmentId, item.resource, signal) + .then((value) => { + if (!value) throw new Error("Reconnect to this environment and try again."); + return value; + }) + .catch((error: unknown) => { + throw new PreviewResolutionError( + error, + "Reconnect to this environment and try again. The file may have been moved or deleted.", + ); + }); + return { source: { ...item, name, uri: url + (item.srcFragment ?? "") } }; } function ResolvedFilePreview(props: { @@ -113,25 +74,61 @@ function ResolvedFilePreview(props: { }) { // Freeze the group at opening; streaming messages and URL refreshes must not reopen the viewer. const [items] = useState(() => filePreviewGallery(props.source, [])); - const [resolved, setResolved] = useState>({}); - const onResolved = useCallback((item: ResolvedFilePreviewSource, key: string) => { - setResolved((current) => ({ ...current, [key]: item })); - }, []); - const selected = resolved[filePreviewKey(props.source)]; - const gallery = useMemo( - () => - items.flatMap((item) => { - const value = resolved[filePreviewKey(item)]; - return value ? [value] : []; - }), - [items, resolved], + const selectedKey = filePreviewKey(props.source); + const [gallery, setGallery] = useState(() => + items.every((item) => "uri" in item) + ? items.map((item) => ({ ...item, name: filePreviewName(item) })) + : null, ); - const ready = selected && gallery.length === items.length; + const onResolutionError = useEffectEvent((error: PreviewResolutionError) => { + if (props.onOpenError) props.onOpenError(error.cause); + else Dialog.alert("Could not open preview", error.fallbackMessage); + props.onRequestClose(); + }); + // Resolves once per presentation and holds local file leases until it closes. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resolves preview files and holds their local file leases until the preview closes + useEffect(() => { + if (items.every((item) => "uri" in item)) return; + const controller = new AbortController(); + // The first failure closes the preview at once and cancels the rest of the gallery. + const fail = (error: unknown) => { + if (controller.signal.aborted) return; + controller.abort(); + onResolutionError( + error instanceof PreviewResolutionError + ? error + : new PreviewResolutionError(error, "Try again."), + ); + }; + const resolving = Promise.allSettled( + items.map((item) => + resolvePreviewItem(item, controller.signal).catch((error: unknown) => { + fail(error); + throw error; + }), + ), + ); + void resolving.then((settled) => { + if (controller.signal.aborted) return; + const sources = settled.flatMap((item) => + item.status === "fulfilled" && item.value ? [item.value.source] : [], + ); + if (sources.length === items.length) setGallery(sources); + }); + return () => { + controller.abort(); + void resolving.then((settled) => { + for (const item of settled) if (item.status === "fulfilled") item.value?.dispose?.(); + }); + }; + }, [items]); + const selected = gallery?.[items.findIndex((item) => filePreviewKey(item) === selectedKey)]; + if (!gallery || !selected) return null; const onOpenError = (error: unknown) => { if (props.onOpenError) return props.onOpenError(error); Dialog.alert("Could not open preview", nativeViewerErrorMessage(error), [ { text: "Cancel", style: "cancel" }, - ...(selected?.kind === "document" + ...(selected.kind === "document" ? [ { text: "Save or share", @@ -154,24 +151,11 @@ function ResolvedFilePreview(props: { ]); }; return ( - <> - {items.map((item) => ( - onResolved(value, filePreviewKey(item))} - onRequestClose={props.onRequestClose} - onOpenError={props.onOpenError} - /> - ))} - {ready ? ( - - ) : null} - + ); } @@ -182,41 +166,13 @@ export function FilePreviewModal(props: { readonly onOpenError?: (error: unknown) => void; }) { const group = useContext(FilePreviewGallery); - const source = useMemo( - () => - props.source === null - ? null - : { - ...props.source, - gallery: filePreviewGallery(props.source, group), - }, - [props.source, group], - ); - const navigation = useNavigation(); - const subscribeToFocus = useCallback( - (listener: () => void) => { - const offFocus = navigation.addListener("focus", listener); - const offBlur = navigation.addListener("blur", listener); - return () => { - offFocus(); - offBlur(); - }; - }, - [navigation], - ); - // The legacy envelope's focus context may describe a different route than this v5 card. - const isFocused = useSyncExternalStore( - subscribeToFocus, - navigation.isFocused, - navigation.isFocused, - ); - const hasSource = props.source !== null; - const onRequestClose = useEffectEvent(props.onRequestClose); - useEffect(() => { - if (!isFocused && hasSource) onRequestClose(); - }, [isFocused, hasSource]); + const source = + props.source === null + ? null + : { ...props.source, gallery: filePreviewGallery(props.source, group) }; + usePreviewPresentation(source && filePreviewKey(source), props.onRequestClose); - if (!source || !isFocused) return null; + if (!source) return null; return ( createAudioPlaybackSession(player, true), [player]); + const playback = createAudioPlaybackSession(player, true); const { active, fullscreen, createPlayRequest, onFullscreenEnter, onFullscreenExit } = useMediaVideoPlayback(player, props, playback); const previousPlaybackRequested = useRef(false); const onPlaybackStart = useEffectEvent(() => props.onPlaybackStart?.()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to native video player events to keep the shared audio session in step useEffect(() => { const subscription = player.addListener("playingChange", ({ isPlaying }) => { if (isPlaying && !active.current && !fullscreen.current) playback.pause(); @@ -74,6 +75,7 @@ function LoadedMediaVideo(props: { if (!signal.aborted && props.playRequested) await playIfUninterrupted(); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the video source into the native player and aborts the load on cleanup useEffect(() => { const controller = new AbortController(); // oxlint-disable-next-line react/set-state-in-effect -- Starting a native media load publishes pending before its async result. diff --git a/apps/mobile/src/components/PdfPreview.android.tsx b/apps/mobile/src/components/PdfPreview.android.tsx index 5492c4ce7c64f..32e79d576e11e 100644 --- a/apps/mobile/src/components/PdfPreview.android.tsx +++ b/apps/mobile/src/components/PdfPreview.android.tsx @@ -1,4 +1,8 @@ -import { useEffect, useRef, useState } from "react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Atom, AsyncResult } from "effect/reactivity"; +import { useState } from "react"; import { View } from "react-native"; import Pdf from "react-native-pdf-jsi"; @@ -7,39 +11,55 @@ import { pdfInitialPage, pdfLinkUrl, preparePdfPreview } from "../lib/pdfPreview import { tryOpenExternalUrl } from "../lib/openExternalUrl"; import { ControlPill } from "./ui/ControlPill"; -function NativePdfPreview(props: { readonly uri: string; readonly onRetry: () => void }) { - const [localUri, setLocalUri] = useState(null); - const [error, setError] = useState(null); - const cached = useRef>>(null); +const preparedPdfAtom = Atom.family((uri: string) => + Atom.make( + // The downloaded copy lives as long as a viewer reads it; Retry refreshes the atom, + // which closes the scope and discards the copy before downloading again. Acquisition + // stays interruptible so closing the viewer aborts the download. + Effect.acquireRelease( + Effect.tryPromise({ + try: (signal) => { + const prepared = preparePdfPreview(uri, signal); + // An interrupt can land after the copy exists but before acquireRelease owns it. + signal.addEventListener("abort", () => { + void prepared.then( + (file) => file?.dispose(), + () => undefined, + ); + }); + return prepared; + }, + catch: (cause) => (cause instanceof Error ? cause.message : "The PDF could not be loaded."), + }).pipe( + Effect.flatMap((file) => (file === null ? Effect.interrupt : Effect.succeed(file))), + Effect.interruptible, + ), + (file) => Effect.sync(file.dispose), + ), + ).pipe(Atom.withLabel(`android-pdf-preview:${uri}`)), +); - useEffect(() => { - const controller = new AbortController(); - void preparePdfPreview(props.uri, controller.signal) - .then((file) => { - if (controller.signal.aborted) file?.dispose(); - else { - cached.current = file; - setLocalUri(file?.uri ?? null); - } - }) - .catch((cause: unknown) => { - if (!controller.signal.aborted) { - setError(cause instanceof Error ? cause.message : "The PDF could not be loaded."); - } - }); - return () => { - controller.abort(); - cached.current?.dispose(); - cached.current = null; - }; - }, [props.uri]); +export function PdfPreview(props: { readonly uri: string }) { + const atom = preparedPdfAtom(props.uri); + const prepared = useAtomValue(atom); + const refresh = useAtomRefresh(atom); + // A render failure belongs to the downloaded copy that failed; Retry replaces it. + const [unrenderable, setUnrenderable] = useState(null); + const file = prepared._tag === "Success" && !prepared.waiting ? prepared.value : null; + const error = + file !== null && unrenderable === file + ? "The PDF could not be rendered. It may be damaged or password protected." + : prepared._tag === "Failure" && !prepared.waiting + ? Option.getOrElse(AsyncResult.error(prepared), () => "The PDF could not be loaded.") + : null; + const localUri = file?.uri ?? null; if (error) { return ( {error} - + ); @@ -59,22 +79,9 @@ function NativePdfPreview(props: { readonly uri: string; readonly onRetry: () => if (url !== null) void tryOpenExternalUrl(url, "file-preview"); }} onError={() => { - cached.current?.dispose(); - cached.current = null; - setError("The PDF could not be rendered. It may be damaged or password protected."); + setUnrenderable(file); }} style={{ flex: 1, width: "100%" }} /> ); } - -export function PdfPreview(props: { readonly uri: string }) { - const [attempt, setAttempt] = useState(0); - return ( - setAttempt((value) => value + 1)} - /> - ); -} diff --git a/apps/mobile/src/components/ProjectFavicon.test.tsx b/apps/mobile/src/components/ProjectFavicon.test.tsx new file mode 100644 index 0000000000000..62eb2bb6b2ba4 --- /dev/null +++ b/apps/mobile/src/components/ProjectFavicon.test.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { act, Profiler } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + Passthrough: ({ children }: { children?: unknown }) => children, +})); + +vi.mock("react-native", () => ({ View: harness.Passthrough })); +vi.mock("expo-image", () => ({ Image: () => })); +vi.mock("./ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("./ui/AppText", () => ({ + AppText: ({ children }: { children?: ReactNode }) => {children}, +})); +vi.mock("../state/assets", async () => { + const { Atom } = await import("effect/reactivity"); + const url = Atom.make("https://example.test/favicon.png"); + return { projectFaviconUrlAtom: () => url }; +}); + +import { EnvironmentId } from "@t3tools/contracts"; + +import { ProjectFavicon } from "./ProjectFavicon"; + +it("mounts the favicon image in the first commit", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement("div"); + const root = createRoot(container); + const commits: string[] = []; + try { + await act(async () => + root.render( + commits.push(phase)}> + + , + ), + ); + expect(container.querySelector("img")).not.toBeNull(); + expect(commits).toEqual(["mount"]); + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 3e0f09741266f..1aa33197321c7 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,7 +1,7 @@ import { SymbolView } from "./ui/AppSymbol"; import { AppText } from "./ui/AppText"; import { Image } from "expo-image"; -import { memo, useLayoutEffect, useMemo, useState } from "react"; +import { memo, useLayoutEffect, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; import { @@ -52,28 +52,14 @@ export const ProjectFavicon = memo(function ProjectFavicon(props: { faviconPath: props.faviconPath, }), ); - const renderableFaviconUrl = useMemo( - () => (isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl), - [faviconUrl], - ); + const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; // Key remote images by revision so token rotation reuses the cache while changed icons reload. - const cacheKey = useMemo( - () => - renderableFaviconUrl && props.workspaceRoot - ? renderableFaviconUrl.startsWith("data:") - ? getProjectFaviconResourceKey( - props.environmentId, - props.workspaceRoot, - props.faviconPath, - ) - : getProjectFaviconCacheKey( - props.environmentId, - props.workspaceRoot, - renderableFaviconUrl, - ) - : null, - [renderableFaviconUrl, props.environmentId, props.workspaceRoot, props.faviconPath], - ); + const cacheKey = + renderableFaviconUrl && props.workspaceRoot + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + : null; if (symbol !== null) { return ( @@ -159,18 +145,13 @@ function ProjectFaviconImage(props: { readonly projectTitle: string; readonly size: number; }) { - const faviconRequest = useMemo( - () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), - [props.cacheKey, props.faviconUrl], - ); - const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); + const faviconRequest = createProjectFaviconRequest(props.cacheKey, props.faviconUrl); + // Registers with the module-level loader before the Image's native onLoad/onError can fire, + // so stale loads for a superseded URL under the same cacheKey are ignored. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- marks this favicon URL as the current request in the module registry before the native Image reports load or error useLayoutEffect(() => { if (faviconRequest === null) return; - - const endRequest = beginProjectFaviconRequest(faviconRequest); - // oxlint-disable-next-line react/set-state-in-effect -- Publishes the favicon request registered with the external loader until its cleanup runs. - setActiveFaviconRequest(faviconRequest); - return endRequest; + return beginProjectFaviconRequest(faviconRequest); }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => @@ -179,8 +160,7 @@ function ProjectFaviconImage(props: { : "loading", ); - const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; - const showImage = requestIsActive && status === "loaded"; + const showImage = faviconRequest !== null && status === "loaded"; const fallbackGlyph = showImage ? null : projectAutoMonogram(props.projectTitle); return ( @@ -203,7 +183,7 @@ function ProjectFaviconImage(props: { /> )} - {requestIsActive ? ( + {faviconRequest !== null ? ( { + const rememberWidth = ({ width }: { width: number }) => { setButtonWidth((previous) => Math.max(previous, width)); - }, []); + }; return ( (resolvedUrl); - const loadError = - resource !== null && playbackUrl === null - ? preparedConnection._tag === "None" + // Mints a fresh URL for each presentation: AVKit retains the URL and offers no retry, + // so a cached link that expired while the app was suspended would fail to play. + const resolvePlaybackUrl = useEffectEvent(async (): Promise => { + if (source.type !== "media") throw new Error("Could not load this video."); + if (!("resource" in source)) return source.uri; + const url = await refreshAssetUrl(source.environmentId, source.resource); + const playbackUrl = mediaVideoPreviewUri(source, url); + if (playbackUrl !== null) return playbackUrl; + throw new Error( + preparedConnection._tag === "None" ? "Reconnect to this environment and open the video again." - : assetUrl._tag === "Failure" - ? "Could not load this video. Check the connection and try again." - : null - : null; + : "Could not load this video. Check the connection and try again.", + ); + }); + const reauthorize = useEffectEvent(() => { + if (source.type === "media" && "resource" in source) { + void refreshAssetUrl(source.environmentId, source.resource); + } + }); - useEffect(() => Keyboard.dismiss(), []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- presents the native fullscreen video and owns its audio session and file lease until cleanup useEffect(() => { - fullscreenVideoOpened(); - return () => fullscreenVideoClosed(); - }, []); - useEffect(() => { - // oxlint-disable-next-line react/set-state-in-effect -- The native player latches the first resolved URL to keep its playback session. - if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl); - }, [playbackUrl, resolvedUrl]); - useEffect(() => { - if (!loadError) return; - Dialog.alert("Could not open video", loadError); - onRequestClose(); - }, [loadError]); - - useEffect(() => { - if (localAttachment === null && playbackUrl === null) return; const controller = new AbortController(); let token: ReturnType | null = null; const release = () => { @@ -106,12 +92,14 @@ function NativeVideoPreview(props: { } else release(); }); let ready = false; + fullscreenVideoOpened(); void (async () => { const file = localAttachment !== null ? await loadLocalAttachmentPreview(localAttachment, controller.signal) : null; if (localAttachment !== null && !file) return; + const uri = file?.uri ?? (await resolvePlaybackUrl()); if (controller.signal.aborted) { file?.dispose(); return; @@ -125,12 +113,7 @@ function NativeVideoPreview(props: { } ready = true; const presented = await settlePromise(() => - NativeControls.presentVideo( - file?.uri ?? playbackUrl!, - name, - source.sourceIdentifier ?? "", - identifier, - ), + NativeControls.presentVideo(uri, name, source.sourceIdentifier ?? "", identifier), ); // Native completion follows dismissal, so local playback keeps its file lease. file?.dispose(); @@ -140,8 +123,8 @@ function NativeVideoPreview(props: { })().catch((error: unknown) => { release(); if (controller.signal.aborted) return; - // AVKit gives no retry, so re-mint now; the cached URL may simply have expired. - if (ready) void refreshAssetUrl(); + // AVKit gives no retry, so re-mint now; the minted URL may already be unusable. + if (ready) reauthorize(); Dialog.alert( "Could not open video", ready @@ -154,11 +137,12 @@ function NativeVideoPreview(props: { }); return () => { controller.abort(); + fullscreenVideoClosed(); subscription.remove(); release(); void NativeControls.dismissVideo(identifier).catch(() => undefined); }; - }, [localAttachment, name, source.sourceIdentifier, playbackUrl, identifier]); + }, [localAttachment, name, source.sourceIdentifier, identifier]); return null; } @@ -167,13 +151,10 @@ export function VideoPreviewModal(props: { readonly source: VideoPreviewSource | null; readonly onRequestClose: () => void; }) { - const isFocused = useIsFocused(); - const hasSource = props.source !== null; - const onRequestClose = useEffectEvent(props.onRequestClose); - useEffect(() => { - if (!isFocused && hasSource) onRequestClose(); - }, [isFocused, hasSource]); - - if (!props.source || !isFocused) return null; + usePreviewPresentation( + props.source === null ? null : videoPreviewKey(props.source), + props.onRequestClose, + ); + if (!props.source) return null; return ; } diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index 9b2b64c68c4bc..4e63851dcd3eb 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -1,11 +1,10 @@ -import { useIsFocused } from "@react-navigation/native"; import { videoMimeType } from "@t3tools/shared/video"; -import { useEffect, useEffectEvent, useState } from "react"; -import { Keyboard, Modal, Pressable, View } from "react-native"; +import { Modal, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; -import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { useLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { usePreviewPresentation, videoPreviewKey } from "../lib/previewPresentation"; import { useMediaActions } from "../state/mediaActions"; import { type MediaActionsSource } from "../lib/mediaActionsSource"; import { @@ -56,40 +55,9 @@ function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { const { attachment } = source; - const [uri, setUri] = useState(null); - const [error, setError] = useState(null); - // Keep the file lease across metadata updates while Android is playing it. - const loadAttachment = useEffectEvent((signal: AbortSignal) => - loadLocalAttachmentPreview(attachment, signal), - ); - const { id: attachmentId, fileUri } = attachment; - useEffect(() => { - // oxlint-disable-next-line react/set-state-in-effect -- A new attachment clears the previous attachment's result before loading. - setUri(null); - setError(null); - const controller = new AbortController(); - const loading = loadAttachment(controller.signal); - void loading.then( - (file) => { - if (file === null) return; - if (controller.signal.aborted) file.dispose(); - else setUri(file.uri); - }, - (cause: unknown) => { - if (!controller.signal.aborted) { - setError(cause instanceof Error ? cause.message : "Could not load this video."); - } - }, - ); - return () => { - controller.abort(); - void loading.then( - (file) => file?.dispose(), - () => undefined, - ); - }; - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A different attachment restarts the load even when it resolves to the same cached file URI. - }, [attachmentId, fileUri]); + const preview = useLocalAttachmentPreview(attachment); + const { uri } = preview; + const error = preview.error ? preview.error.message || "Could not load this video." : null; const mimeType = videoMimeType(attachment) ?? attachment.mimeType; return { uri, @@ -137,7 +105,6 @@ function OpenVideoPreviewModal(props: { const insets = useSafeAreaInsets(); const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose); - useEffect(() => Keyboard.dismiss(), []); return ( void; }) { - const isFocused = useIsFocused(); - const hasSource = props.source !== null; - const { onRequestClose } = props; - useEffect(() => { - if (!isFocused && hasSource) onRequestClose(); - }, [isFocused, hasSource, onRequestClose]); + usePreviewPresentation( + props.source === null ? null : videoPreviewKey(props.source), + props.onRequestClose, + ); const { source } = props; - if (source === null || !isFocused) return null; + if (source === null) return null; return source.type === "local" ? ( ) : ( diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx index cfb8ceeb2aca2..6f6fea83063ca 100644 --- a/apps/mobile/src/components/VideoThumbnailImage.tsx +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -1,38 +1,53 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as Effect from "effect/Effect"; +import { Atom, AsyncResult } from "effect/reactivity"; import { Image } from "expo-image"; import { useIsFocused } from "@react-navigation/native"; import type { VideoThumbnail } from "expo-video"; -import { useEffect, useState } from "react"; import { StyleSheet } from "react-native"; import type { DraftComposerFileAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; +interface VideoThumbnailTarget { + readonly cacheKey: string; + readonly source: string | DraftComposerFileAttachment; +} + +// Plain-object keys compare structurally (Effect `Equal`/`Hash`). +const videoThumbnailAtom = Atom.family((target: VideoThumbnailTarget) => + Atom.make( + Effect.promise((signal) => + loadVideoThumbnail( + target.cacheKey, + async (sourceSignal) => + typeof target.source === "string" + ? { uri: target.source, dispose: () => undefined } + : loadLocalAttachmentPreview(target.source, sourceSignal), + signal, + ), + ), + ).pipe(Atom.withLabel(`video-thumbnail:${target.cacheKey}`)), +); + +const noVideoThumbnail = Atom.make(AsyncResult.initial()).pipe( + Atom.withLabel("video-thumbnail:none"), +); + export function VideoThumbnailImage(props: { readonly cacheKey: string; readonly source: string | DraftComposerFileAttachment | null; readonly contentFit?: "cover" | "contain"; }) { const { cacheKey, source } = props; + // Extraction runs only while the screen is focused; leaving it interrupts the load. const isFocused = useIsFocused(); - const [loaded, setLoaded] = useState<{ key: string; thumbnail: VideoThumbnail } | null>(null); - const thumbnail = loaded?.key === cacheKey ? loaded.thumbnail : cachedVideoThumbnail(cacheKey); - - useEffect(() => { - if (!source || !isFocused) return; - const controller = new AbortController(); - void loadVideoThumbnail( - cacheKey, - async (signal) => - typeof source === "string" - ? { uri: source, dispose: () => undefined } - : loadLocalAttachmentPreview(source, signal), - controller.signal, - ).then((thumbnail) => { - if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); - }); - return () => controller.abort(); - }, [cacheKey, source, isFocused]); + const loaded = useAtomValue( + source && isFocused ? videoThumbnailAtom({ cacheKey, source }) : noVideoThumbnail, + ); + const thumbnail = + (loaded._tag === "Success" ? loaded.value : null) ?? cachedVideoThumbnail(cacheKey); return thumbnail ? ( + {inFormSheet ? ( + + ) : null} ({ id: String(index), title: action.accessibilityLabel, + subtitle: action.subtitle, + state: action.selected ? "on" : undefined, attributes: { disabled: Boolean(action.disabled), destructive: action.destructive, - state: action.selected ? "on" : undefined, }, }))} onPressAction={({ nativeEvent }) => diff --git a/apps/mobile/src/components/ui/AppBar.android.tsx b/apps/mobile/src/components/ui/AppBar.android.tsx index d5df66d22d6a6..d156b7ef064b7 100644 --- a/apps/mobile/src/components/ui/AppBar.android.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Keyboard, Pressable, TextInput, type TextInputInstance, View } from "react-native"; import { useHardwareBack } from "../../lib/backHandler"; import { useToolbarLayout } from "./useToolbarLayout"; @@ -23,11 +23,11 @@ export function AppBar(props: AppBarProps) { const [searchOpen, setSearchOpen] = useState(false); const searching = search !== undefined && (searchOpen || search.value.length > 0); const onSearchChange = search?.onChangeText; - const closeSearch = useCallback(() => { + const closeSearch = () => { onSearchChange?.(""); setSearchOpen(false); Keyboard.dismiss(); - }, [onSearchChange]); + }; useHardwareBack(searching && search?.mode !== "inline", () => { closeSearch(); return true; diff --git a/apps/mobile/src/components/ui/ListChoiceRow.tsx b/apps/mobile/src/components/ui/ListChoiceRow.tsx index 748752f5cd415..9e231b7c25b90 100644 --- a/apps/mobile/src/components/ui/ListChoiceRow.tsx +++ b/apps/mobile/src/components/ui/ListChoiceRow.tsx @@ -26,10 +26,8 @@ export function ListChoiceRow(props: { onPress={props.onPress} android_ripple={{ color: rippleStyle.backgroundColor }} unstable_pressDelay={Platform.OS === "android" ? 50 : undefined} - className={cn( - props.selected ? "bg-subtle" : "bg-grouped-card", - props.separated && "border-t border-border-subtle", - )} + // Like UIKit and Material lists, only the checkmark or radio marks the selected row. + className={cn("bg-grouped-card", props.separated && "border-t border-border-subtle")} > , +) { const { appearance, themeVariables: colors } = useAppearancePreferences(); const { scale, menuItemHeight } = useAndroidControlSizing(); const body = resolveScaledTextRole("body", appearance.baseFontSize); @@ -64,7 +66,7 @@ export function MenuPopup(props: MenuPopupProps) { const muted = colors["--color-foreground-muted"]; // A fixed native item height clips wrapped labels; a minimum lets each row grow. const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })]; - const items = ( + return ( <> {props.parent ? ( @@ -146,6 +148,11 @@ export function MenuPopup(props: MenuPopupProps) { ))} ); +} + +export function MenuPopup(props: MenuPopupProps) { + const { themeVariables: colors } = useAppearancePreferences(); + const items = ; if (props.inline) { return ( , "colorScheme" | "seedColor" | "colorOverrides">, ) { const { colors, colorScheme, systemColorsActive } = useControlTheme(); - const colorOverrides = useMemo( - () => (systemColorsActive ? undefined : createNativeThemeColors(colors)), - [colors, systemColorsActive], - ); + const colorOverrides = systemColorsActive ? undefined : createNativeThemeColors(colors); return ; } diff --git a/apps/mobile/src/components/ui/Notice.tsx b/apps/mobile/src/components/ui/Notice.tsx index 44ca6c8cf7fe7..90a31f7054363 100644 --- a/apps/mobile/src/components/ui/Notice.tsx +++ b/apps/mobile/src/components/ui/Notice.tsx @@ -16,6 +16,7 @@ export function Notice(props: { readonly dismissLabel?: string; }) { const danger = props.tone === "danger"; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- announces the message to VoiceOver through AccessibilityInfo useEffect(() => { // Live regions reach TalkBack; VoiceOver needs an explicit announcement. if (Platform.OS === "ios") AccessibilityInfo.announceForAccessibility(props.message); diff --git a/apps/mobile/src/components/ui/OverlayPortal.tsx b/apps/mobile/src/components/ui/OverlayPortal.tsx index a4f07f46024f1..b5cd51e6c9058 100644 --- a/apps/mobile/src/components/ui/OverlayPortal.tsx +++ b/apps/mobile/src/components/ui/OverlayPortal.tsx @@ -38,6 +38,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { // happen after commit. No dependency array: re-project after every render so // the host always shows the current content (menus re-render while open — // drill-in, theme). + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- projects children into the out-of-tree overlay host store after every commit useEffect(() => { entries.set(key, props.children); emit(); @@ -45,6 +46,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { // Removal is unmount-only: deleting on every re-projection would move the // entry to the end of the map and restack it above overlays opened later. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- removes this entry from the overlay host store on unmount useEffect( () => () => { entries.delete(key); diff --git a/apps/mobile/src/components/ui/RoutedFormSheetContext.ts b/apps/mobile/src/components/ui/RoutedFormSheetContext.ts new file mode 100644 index 0000000000000..29ee63d996e57 --- /dev/null +++ b/apps/mobile/src/components/ui/RoutedFormSheetContext.ts @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const RoutedFormSheetContext = createContext(false); diff --git a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx index 4862e99cdabfa..15883d300da41 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx @@ -1,11 +1,8 @@ import { requireNativeView } from "expo"; import { View, type NativeSyntheticEvent, type ViewProps } from "react-native"; -import { - flattenThemeColor, - themeColorWithAlpha, - themeColorWithContrast, -} from "../../lib/mobileTheme"; +import { SegmentedControl as FlatSegmentedControl } from "./SegmentedControl.shared"; +import { segmentedControlColors } from "./segmentedControlColors"; import { useControlTheme } from "./useControlTheme"; import type { SegmentedControlProps } from "./SegmentedControl.types"; @@ -27,17 +24,16 @@ const NativeSegmentedControl = requireNativeView< export function SegmentedControl( props: SegmentedControlProps, ) { - const { colors, colorScheme } = useControlTheme(); - const trackColor = flattenThemeColor( - themeColorWithAlpha(colors["--color-prominent-action"], colorScheme === "dark" ? 0.18 : 0.07), - colors["--color-screen"], + const { colors, colorScheme, themeId } = useControlTheme(); + // UIKit adds a gray fill over the sunken card track. + if (props.variant === "card") return ; + const { trackColor, selectedColor, selectedTextColor, mutedColor } = segmentedControlColors( + colors, + colorScheme, + props.variant, + "ios", + themeId, ); - const selectedColor = - colorScheme === "dark" ? colors["--color-prominent-action"] : colors["--color-secondary"]; - const selectedTextColor = - colorScheme === "dark" - ? colors["--color-prominent-action-foreground"] - : colors["--color-secondary-foreground"]; return ( ( trackColor={trackColor} selectedColor={selectedColor} textColor={selectedTextColor} - mutedColor={themeColorWithContrast( - colors["--color-foreground-muted"], - trackColor, - colorScheme === "dark" ? 7 : 4.5, - )} + mutedColor={mutedColor} appearance={colorScheme} onSelectionChange={({ nativeEvent }) => { const option = props.options[nativeEvent.index]; diff --git a/apps/mobile/src/components/ui/SegmentedControl.shared.tsx b/apps/mobile/src/components/ui/SegmentedControl.shared.tsx new file mode 100644 index 0000000000000..9eac685a8ca21 --- /dev/null +++ b/apps/mobile/src/components/ui/SegmentedControl.shared.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { I18nManager, Platform, Pressable, View } from "react-native"; +import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated"; + +import { segmentedControlColors } from "./segmentedControlColors"; +import { AppText } from "./AppText"; +import { useControlTheme } from "./useControlTheme"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; + +export type { SegmentedControlProps } from "./SegmentedControl.types"; + +export function SegmentedControl( + props: SegmentedControlProps, +) { + const { colorScheme, colors, themeId } = useControlTheme(); + const [layout, setLayout] = useState({ width: 0, height: 0 }); + const { width } = layout; + const reduceMotion = useReducedMotion(); + const selectedIndex = props.options.findIndex((option) => option.value === props.selected); + const segmentWidth = props.options.length ? Math.max(0, width - 6) / props.options.length : 0; + const selectionOffset = selectedIndex * segmentWidth * (I18nManager.isRTL ? -1 : 1); + const selectionStyle = useAnimatedStyle(() => ({ + width: segmentWidth, + transform: [{ translateX: withTiming(selectionOffset, { duration: reduceMotion ? 0 : 160 }) }], + })); + const { trackColor, selectedColor, selectedTextColor, mutedColor } = segmentedControlColors( + colors, + colorScheme, + props.variant, + Platform.OS, + themeId, + ); + const minHeight = props.size === "compact" ? 32 : Platform.OS === "android" ? 40 : 36; + + return ( + + setLayout(event.nativeEvent.layout)} + className="flex-row rounded-full p-[3px]" + style={{ + minHeight, + backgroundColor: trackColor, + }} + > + {selectedIndex >= 0 && width > 0 ? ( + + ) : null} + {props.options.map((option) => { + const selected = option.value === props.selected; + return ( + props.onSelect(option.value)} + className="flex-1 items-center justify-center rounded-full px-2 py-1 active:opacity-70" + style={{ minHeight: minHeight - 6 }} + > + + {option.label} + + + ); + })} + + + ); +} diff --git a/apps/mobile/src/components/ui/SegmentedControl.tsx b/apps/mobile/src/components/ui/SegmentedControl.tsx index 0fdc46c0f27ef..bbcbc4a5f4593 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.tsx @@ -1,78 +1 @@ -import { useState } from "react"; -import { I18nManager, Platform, Pressable, View } from "react-native"; -import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated"; - -import { AppText } from "./AppText"; -import { useControlTheme } from "./useControlTheme"; -import type { SegmentedControlProps } from "./SegmentedControl.types"; - -export type { SegmentedControlProps } from "./SegmentedControl.types"; - -export function SegmentedControl( - props: SegmentedControlProps, -) { - const { colorScheme, colors } = useControlTheme(); - const [layout, setLayout] = useState({ width: 0, height: 0 }); - const { width } = layout; - const reduceMotion = useReducedMotion(); - const selectedIndex = props.options.findIndex((option) => option.value === props.selected); - const segmentWidth = props.options.length ? Math.max(0, width - 6) / props.options.length : 0; - const selectionOffset = selectedIndex * segmentWidth * (I18nManager.isRTL ? -1 : 1); - const selectionStyle = useAnimatedStyle(() => ({ - width: segmentWidth, - transform: [{ translateX: withTiming(selectionOffset, { duration: reduceMotion ? 0 : 160 }) }], - })); - const dark = colorScheme === "dark"; - const minHeight = props.size === "compact" ? 32 : Platform.OS === "android" ? 40 : 36; - - return ( - - setLayout(event.nativeEvent.layout)} - className="flex-row rounded-full p-[3px]" - style={{ - minHeight, - backgroundColor: colors[dark ? "--color-screen" : "--color-grouped-card"], - }} - > - {selectedIndex >= 0 && width > 0 ? ( - - ) : null} - {props.options.map((option) => { - const selected = option.value === props.selected; - return ( - props.onSelect(option.value)} - className="flex-1 items-center justify-center rounded-full px-2 py-1 active:opacity-70" - style={{ minHeight: minHeight - 6 }} - > - - {option.label} - - - ); - })} - - - ); -} +export { SegmentedControl, type SegmentedControlProps } from "./SegmentedControl.shared"; diff --git a/apps/mobile/src/components/ui/SegmentedControl.types.ts b/apps/mobile/src/components/ui/SegmentedControl.types.ts index 779b49fe1a4b4..472ed46b0e2f2 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.types.ts +++ b/apps/mobile/src/components/ui/SegmentedControl.types.ts @@ -8,6 +8,7 @@ export interface SegmentedControlProps { readonly onSelect: (value: Value) => void; /** Compact uses a 32-point capsule; default uses 36 points on iOS and 40 on Android. */ readonly size?: "default" | "compact"; + readonly variant?: "standalone" | "card"; readonly role?: "tab" | "button"; readonly className?: string; } diff --git a/apps/mobile/src/components/ui/Sheet.android.tsx b/apps/mobile/src/components/ui/Sheet.android.tsx index 08f8b8ad9a4a6..aaafffb8013da 100644 --- a/apps/mobile/src/components/ui/Sheet.android.tsx +++ b/apps/mobile/src/components/ui/Sheet.android.tsx @@ -3,26 +3,38 @@ import { View } from "react-native"; import { FormSheet } from "react-native-screens"; import { useResolveClassNames } from "uniwind"; import { AndroidSheetHeader } from "./AndroidScreenHeader"; +import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; +import { ScreenContent } from "./ScreenContent"; import type { SheetProps } from "./Sheet.types"; export type { SheetProps } from "./Sheet.types"; export function Sheet(props: SheetProps) { - const containerStyle = useResolveClassNames("bg-sheet"); + const containerStyle = useResolveClassNames("bg-header"); const [isOpen, setIsOpen] = useState(true); return ( - {/* Android FormSheet does not bound flex children. Material drags target the first scroll view, so place content before the header. */} - - {props.children} - setIsOpen(false)} /> - + + {/* Android FormSheet does not bound flex children. Material drags target the first scroll view, so place content before the header. */} + + {props.children} + setIsOpen(false)} + hideBottomBorder + /> + + + ); } diff --git a/apps/mobile/src/components/ui/SplitButton.android.tsx b/apps/mobile/src/components/ui/SplitButton.android.tsx new file mode 100644 index 0000000000000..6f183ede86481 --- /dev/null +++ b/apps/mobile/src/components/ui/SplitButton.android.tsx @@ -0,0 +1,145 @@ +import { + Box, + DropdownMenu, + Icon, + LoadingIndicator, + RNHostView, + Row, + SplitButtonLayout, + Text, +} from "@expo/ui/jetpack-compose"; +import { + defaultMinSize, + fillMaxWidth, + rotate, + semantics, + size, +} from "@expo/ui/jetpack-compose/modifiers"; +import { useState } from "react"; +import { Pressable } from "react-native"; + +import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; +import { SymbolView } from "./AppSymbol"; +import { Button } from "./Button"; +import { MenuPopupItems } from "./MenuPopup.android"; +import { NativeThemeHost } from "./NativeThemeHost.android"; +import type { SplitButtonProps } from "./SplitButton.types"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; +import { useControlTheme } from "./useControlTheme"; + +export function SplitButton(props: SplitButtonProps) { + const { colors } = useControlTheme(); + const typography = useScaledTextRole("footnote"); + const { scale, iconSize, mediumIconSize, menuWidth } = useAndroidControlSizing(); + const [expanded, setExpanded] = useState(false); + if (props.actions.length === 0) return + ), + TextInput: (props: { + readonly accessibilityLabel?: string; + readonly value: string; + readonly onChangeText: (value: string) => void; + }) => ( + props.onChangeText(event.target.value)} + /> + ), + }; +}); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { + readonly data: ZipTreeRow[]; + readonly ListHeaderComponent: ReactNode; + readonly ListEmptyComponent: ReactNode; + readonly keyExtractor: (row: ZipTreeRow) => string; + readonly renderItem: (input: { item: ZipTreeRow }) => ReactNode; + }) => ( +
+ {props.ListHeaderComponent} + {props.data.length === 0 ? props.ListEmptyComponent : null} + {props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} +
+ ), +})); +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + Paths: { document: { uri: "file:///documents" } }, + File: class { + constructor(private readonly uri: string) {} + get exists() { + return false; + } + get size() { + return files.get(this.uri)?.byteLength ?? null; + } + async arrayBuffer() { + const bytes = files.get(this.uri); + if (!bytes) throw new Error(`Missing ZIP fixture: ${this.uri}`); + return Uint8Array.from(bytes).buffer; + } + }, +})); +vi.mock("expo/fetch", () => ({ fetch: vi.fn() })); +vi.mock("expo-crypto", () => ({ randomUUID: vi.fn() })); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ canGoBack: () => false }), + usePreventRemove: (enabled: boolean, remove: () => void) => { + navigation.remove = enabled ? remove : null; + }, +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); +vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: () => null })); +vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../components/ui/Toolbar", () => { + const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; + return { + Toolbar: Object.assign(Slot, { + Button: () => null, + Menu: Slot, + MenuAction: (props: { + readonly children?: ReactNode; + readonly disabled?: boolean; + readonly onPress?: () => void; + }) => ( + + ), + }), + }; +}); +vi.mock("../../components/ui/AndroidScreenHeader", () => ({ AndroidScreenHeader: () => null })); +vi.mock("../../components/ui/Menu", () => ({ Menu: () => null })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../components/ui/Progress", () => ({ Progress: () => null })); +vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("../../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +vi.mock("../../lib/copyTextWithHaptic", () => ({})); +vi.mock("./SourceFileSurface", () => ({ + SourceFileSurface: (props: { readonly contents: string }) =>
{props.contents}
, +})); +vi.mock("./AudioFilePreview", () => ({ AudioFilePreview: () => null })); +vi.mock("./FileMarkdownPreview", () => ({ FileMarkdownPreview: () => null })); +vi.mock("./WorkspaceFileWebPreview", () => ({ WorkspaceFileWebPreview: () => null })); +vi.mock("../threads/HtmlRenderWebView", () => ({ HtmlRenderWebView: () => null })); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ + appearance: { codeWordBreak: false }, + themeVariables: { "--color-icon": "#000000" }, + setCodeWordBreak: () => {}, + }), +})); + +import { ZipArchiveView } from "./ZipArchiveView"; +import { AttachmentFileScreen } from "./AttachmentFileScreen"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { composerDraftsAtom } from "../../state/use-composer-drafts"; + +function setup() { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers({ toFake: ["setImmediate", "clearImmediate"] }); + files.clear(); + navigation.remove = null; + server.capabilities = { repositoryIdentity: false }; + server.refresh.mockClear(); + appAtomRegistry.set(composerDraftsAtom, {}); + files.set( + "file:///first.zip", + Uint8Array.from( + atob( + "UEsDBBQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAZmlyc3QudHh0Rmlyc3QgYXJjaGl2ZSBjb250ZW50c1BLAQIUAxQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAAAAAAAAAAACAAQAAAABmaXJzdC50eHRQSwUGAAAAAAEAAQA3AAAAPQAAAAAA", + ), + (character) => character.charCodeAt(0), + ), + ); + + const container = document.createElement("div"); + const root = createRoot(container); + onTestFinished(() => { + act(() => root.unmount()); + files.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + const props: ComponentProps = { + name: "archive.zip", + subtitle: "ZIP", + tintColor: "#000000", + sizeBytes: 0, + uri: "file:///first.zip", + isLocal: true, + refresh: async () => null, + environmentId: null, + draftKey: null, + maxAttachmentBytes: undefined, + onSave: () => {}, + onRemove: null, + }; + return { + container, + root, + props, + render: async (input = props) => { + await act(async () => root.render()); + }, + settle: async (assert: () => void) => { + await act(async () => vi.runAllTimersAsync()); + assert(); + }, + }; +} + +it("opens a ZIP chat attachment as a browsable archive on the attachment file screen", async () => { + const screen = setup(); + server.capabilities = { + repositoryIdentity: false, + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 1024 }, + }; + const { fetch } = await import("expo/fetch"); + vi.mocked(fetch, { partial: true }).mockImplementation(async () => ({ + ok: true, + body: new Response(Uint8Array.from(files.get("file:///first.zip")!)).body, + })); + await act(async () => + screen.root.render( + , + ), + ); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + const add = Array.from(screen.container.querySelectorAll("button")).find( + (button) => button.textContent === "Add to chat", + ); + expect(add?.disabled).toBe(false); +}); + +it("keeps the ZIP filter and archive open when native Back closes a selected entry", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + const filter = screen.container.querySelector( + '[aria-label="Filter files in ZIP"]', + )!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + filter, + "first", + ); + filter.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + + expect(navigation.remove).not.toBeNull(); + await act(async () => navigation.remove!()); + + expect(screen.container.querySelector("pre")).toBeNull(); + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(); + expect( + screen.container.querySelector('[aria-label="Filter files in ZIP"]')?.value, + ).toBe("first"); +}); + +it("explains why an encrypted ZIP cannot be browsed", async () => { + const screen = setup(); + const bytes = files.get("file:///first.zip")!; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const directory = view.getUint32(bytes.length - 6, true); + view.setUint16(directory + 8, view.getUint16(directory + 8, true) | 1, true); + await screen.render(); + await screen.settle(() => expect(screen.container.textContent).toContain("Encrypted zip")); + expect(screen.container.querySelector('[aria-label="first.txt"]')).toBeNull(); +}); + +it("keeps the archive and selected entry when the parent re-renders with a new refresh", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + files.delete("file:///first.zip"); + + await screen.render({ ...screen.props, refresh: async () => null }); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + expect(navigation.remove).not.toBeNull(); +}); diff --git a/apps/mobile/src/features/files/ZipArchiveView.tsx b/apps/mobile/src/features/files/ZipArchiveView.tsx new file mode 100644 index 0000000000000..bb9931d4e3c00 --- /dev/null +++ b/apps/mobile/src/features/files/ZipArchiveView.tsx @@ -0,0 +1,870 @@ +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { + ARCHIVE_ENTRY_MAX_BYTES, + ARCHIVE_MAX_BYTES, + boundedZip, +} from "@t3tools/client-runtime/zip/bounded-zip"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; +import { + decodeFilePreviewText, + filePreviewKind, + hostPreviewMimeTypeFromExtension, +} from "@t3tools/shared/filePreview"; +import { LegendList } from "@legendapp/list/react-native"; +import { fetch } from "expo/fetch"; +import { File } from "expo-file-system"; +import { useNavigation, usePreventRemove } from "@react-navigation/native"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { Keyboard, Platform, Pressable, ScrollView, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import { EmptyState } from "../../components/ui/EmptyState"; +import { Progress } from "../../components/ui/Progress"; +import { Toolbar } from "../../components/ui/Toolbar"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import * as Dialog from "../../components/ui/Dialog"; +import { FilePreviewModal } from "../../components/FilePreviewModal"; +import { + nativeViewerErrorMessage, + shareGeneratedAttachment, + writeGeneratedAttachmentFile, +} from "../../lib/attachmentDownload"; +import { attachmentDocumentPresentation } from "../../lib/attachmentDocumentPresentation"; +import { + createComposerFileAttachment, + removePersistedComposerAttachmentFile, +} from "../../lib/composerImages"; +import { appendComposerDraftAttachments } from "../../state/use-composer-drafts"; +import { AttachmentDocumentBody, nativeViewerKind } from "./AttachmentDocumentBody"; +import { FilePreviewLoading } from "./FilePreviewFeedback"; +import { + buildZipTree, + flattenZipTree, + zipDownloadTimeLeft, + zipFolderLabel, + type ZipEntry, + type ZipTreeRow, +} from "./zipTree"; + +export function isZipFile(file: { readonly name: string; readonly mimeType: string }) { + const mime = file.mimeType.split(";", 1)[0]?.trim().toLowerCase(); + return ( + mime === "application/zip" || + mime === "application/x-zip-compressed" || + file.name.toLowerCase().endsWith(".zip") + ); +} + +interface ZipArchive { + readonly entries: ReadonlyArray; + readonly fileCount: number; +} + +interface ZipFailure { + readonly title: string; + readonly detail: string; + readonly retry: boolean; +} + +const SAVE_ZIP_HINT = "Save the ZIP to open it in another app."; +const TOO_LARGE: ZipFailure = { + title: "Too large to browse here", + detail: `Browsing works up to ${formatAttachmentSize(ARCHIVE_MAX_BYTES)}. ${SAVE_ZIP_HINT}`, + retry: false, +}; +const CANCELLED: ZipFailure = { + title: "Download cancelled", + detail: "The ZIP wasn't downloaded. Try again to list its files.", + retry: true, +}; +const NETWORK: ZipFailure = { + title: "Couldn't download this ZIP", + detail: "Check the connection to this environment and try again.", + retry: true, +}; + +class ZipFailureError extends Error { + constructor(readonly failure: ZipFailure) { + super(failure.title); + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error ? cause.message : fallback; +} + +async function readArchiveBody( + body: ReadableStream, + onProgress: (receivedBytes: number) => void, +) { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + chunks.push(value); + onProgress(size); + } + } finally { + void reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes.buffer; +} + +async function openArchive(bytes: ArrayBuffer): Promise { + let files: Awaited>; + try { + files = await boundedZip(bytes); + } catch (cause) { + throw new ZipFailureError({ + title: "Couldn't browse this ZIP", + detail: `${errorMessage(cause, "It may be incomplete or damaged.")} ${SAVE_ZIP_HINT}`, + retry: false, + }); + } + const entries = [...files].map(([path, file]): ZipEntry => { + const name = path.split("/").findLast(Boolean) ?? path; + const dot = name.lastIndexOf("."); + return { + path, + name, + isDirectory: file.directory, + sizeBytes: file.bytes.byteLength, + mimeType: + (dot > 0 ? hostPreviewMimeTypeFromExtension(name.slice(dot).toLowerCase()) : null) ?? + "application/octet-stream", + bytes: file.bytes, + }; + }); + return { entries, fileCount: entries.filter((entry) => !entry.isDirectory).length }; +} + +type ArchiveState = + | { readonly status: "loading"; readonly receivedBytes: number; readonly timeLeft: string | null } + | { readonly status: "ready"; readonly archive: ZipArchive } + | { readonly status: "failed"; readonly failure: ZipFailure }; + +function useZipArchive(input: { + readonly uri: string | null; + readonly isLocal: boolean; + readonly sizeBytes: number; + readonly refresh: () => Promise; +}) { + const [state, setState] = useState({ + status: "loading", + receivedBytes: 0, + timeLeft: null, + }); + const [revision, setRevision] = useState(0); + const controller = useRef(null); + const { uri, isLocal, sizeBytes } = input; + // The parent mints a new refresh callback on every render; it must not reload the archive. + const refresh = useEffectEvent(input.refresh); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Load archive bytes and cancel the stream on cleanup. + useEffect(() => { + if (!uri) return; + const abort = new AbortController(); + controller.current = abort; + const startedAt = Date.now(); + // oxlint-disable-next-line react/set-state-in-effect -- A new archive load resets its progress. + setState({ status: "loading", receivedBytes: 0, timeLeft: null }); + void (async () => { + if (sizeBytes > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + if (isLocal) { + const file = new File(uri); + if ((file.size ?? 0) > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + return openArchive(await file.arrayBuffer()); + } + const url = (await refresh()) ?? uri; + const response = await fetch(url, { signal: abort.signal }); + if (!response.ok || !response.body) throw new ZipFailureError(NETWORK); + const bytes = await readArchiveBody(response.body, (receivedBytes) => + setState({ + status: "loading", + receivedBytes, + timeLeft: zipDownloadTimeLeft({ + receivedBytes, + totalBytes: sizeBytes, + elapsedMs: Date.now() - startedAt, + }), + }), + ); + abort.signal.throwIfAborted(); + return openArchive(bytes); + })() + .then((archive) => { + if (!abort.signal.aborted) setState({ status: "ready", archive }); + }) + .catch((cause: unknown) => { + if (!abort.signal.aborted) + setState({ + status: "failed", + failure: cause instanceof ZipFailureError ? cause.failure : NETWORK, + }); + }); + return () => abort.abort(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reload the archive. + }, [uri, isLocal, sizeBytes, revision]); + return { + state, + retry: () => setRevision((value) => value + 1), + cancel: () => { + controller.current?.abort(); + setState({ status: "failed", failure: CANCELLED }); + }, + }; +} + +function ZipTreeRowView(props: { + readonly row: ZipTreeRow; + readonly collapsed: boolean; + readonly onToggle: (path: string) => void; + readonly onOpen: (entry: ZipEntry) => void; +}) { + const { node, depth } = props.row; + const entry = node.entry; + const folder = entry ? null : zipFolderLabel(node.label); + return ( + (entry ? props.onOpen(entry) : props.onToggle(node.path))} + className="mx-2 h-11 flex-row items-center gap-1.5 rounded-lg pr-2.5 active:bg-subtle" + style={{ paddingLeft: 8 + depth * 18 }} + > + + {entry ? null : ( + + )} + + + + + + {folder ? ( + <> + {folder.prefix} + {folder.name} + + ) : ( + node.label + )} + + + {entry + ? formatAttachmentSize(entry.sizeBytes) + : props.collapsed + ? `${node.fileCount} ${node.fileCount === 1 ? "file" : "files"}` + : ""} + + + ); +} + +function ZipArchiveHeader(props: { + readonly name: string; + readonly subtitle: string; + readonly tintColor: string; + readonly onSave: () => void; + readonly onRemove: (() => void) | null; +}) { + const navigation = useNavigation(); + const isAndroid = Platform.OS === "android"; + return ( + <> + + {isAndroid ? ( + { + if (navigation.canGoBack()) navigation.goBack(); + }} + actions={[ + { accessibilityLabel: "Save ZIP", icon: "square.and.arrow.up", onPress: props.onSave }, + ...(props.onRemove + ? [ + { + accessibilityLabel: "Remove from draft", + icon: "trash" as const, + onPress: props.onRemove, + destructive: true, + overflowOnly: true, + }, + ] + : []), + ]} + /> + ) : ( + + {props.onRemove ? ( + + + Save ZIP + + + Remove from draft + + + ) : ( + + )} + + )} + + ); +} + +function ZipArchiveProgress(props: { + readonly receivedBytes: number; + readonly totalBytes: number; + readonly timeLeft: string | null; + readonly onCancel: () => void; +}) { + const { timeLeft } = props; + return ( + + + {formatAttachmentSize(props.receivedBytes)} + {props.totalBytes > 0 ? ( + + {` of ${formatAttachmentSize(props.totalBytes)}`} + + ) : null} + + 0 ? props.receivedBytes / props.totalBytes : undefined} + label="ZIP download progress" + /> + + {`Downloading the ZIP so its files can be listed.${timeLeft ? ` ${timeLeft} on this connection.` : ""}`} + + + ), +})); +vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0 }) })); +vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: Nothing })); +vi.mock("../../native/native-layout-metrics", () => ({ useNativeColumnLayoutMetrics: () => null })); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { + data: ReadonlyArray; + keyExtractor: (item: T) => string; + renderItem: (info: { item: T }) => ReactNode; + ListHeaderComponent?: ReactNode; + ListEmptyComponent?: ReactNode; + }) => ( +
+ {props.ListHeaderComponent} + {props.data.length === 0 + ? props.ListEmptyComponent + : props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} +
+ ), +})); +vi.mock("../../components/ui/Button", () => ({ + Button: ({ + label, + onPress, + disabled, + }: { + label: string; + onPress: () => void; + disabled?: boolean; + }) => ( + + ), +})); +vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: Nothing })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: Nothing })); +vi.mock("../../components/ui/AppText", () => ({ AppText: Children })); +vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: Nothing })); +vi.mock("../../lib/cn", () => ({ cn: () => "" })); + +import { FileTreeBrowser } from "./FileTreeBrowser"; +import { useFileTreeEntries } from "./useFileTreeEntries"; + +type Hook = typeof useFileTreeEntries; +type Input = Parameters[0]; + +function setup(withBrowser = false) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const state = mocks.createState(); + mocks.state = state; + const rootQuery: QueryState = { + success: null, + data: { entries: rootEntries, truncated: false }, + error: null, + failure: null, + isPending: false, + refresh: vi.fn(), + }; + state.rootQuery = rootQuery; + const searchQuery: QueryState = { ...rootQuery, data: { entries: [], truncated: false } }; + state.searchQuery = searchQuery; + const { requests } = state; + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + let current: ReturnType; + let renders = 0; + let controlledSelection: string | undefined; + function Probe(input: Input) { + if (++renders > 100 && withBrowser) throw new Error("File tree did not settle"); + const result = useFileTreeEntries(input); + useLayoutEffect(() => { + current = result; + }); + return withBrowser ? ( + + ) : null; + } + const unmount = () => { + act(() => root.unmount()); + container.remove(); + }; + return { + requests, + rootQuery, + searchQuery, + get searchSuccess() { + const result = searchQuery.success ?? state.results.get(searchQuery)?.result; + if (result?._tag !== "Success") throw new Error("Search has not succeeded"); + return result; + }, + get responseTimestamp() { + return state.responseTimestamp; + }, + controlSelection(path: string) { + controlledSelection = path; + }, + updateView(expanded: string[], selected: string) { + renders = 0; + act(() => { + current.setExpandedPaths(() => new Set(expanded)); + current.selectPath(selected); + }); + }, + unmount, + get current() { + return current; + }, + get rows() { + return [...container.querySelectorAll("button")].map((button) => + button.getAttribute("aria-label"), + ); + }, + press(label: string) { + renders = 0; + const button = container.querySelector(`button[aria-label="${label}"]`); + if (!button) throw new Error(`No row labelled ${label}`); + act(() => button.click()); + }, + render(target: Partial = {}) { + renders = 0; + const probe = ; + act(() => root.render(probe)); + }, + load(path = "src", refresh = false) { + renders = 0; + act(() => { + void current.loadDirectory(path, refresh); + }); + }, + async succeed(index: number, entries: ProjectEntry[] = []) { + renders = 0; + await act(async () => { + requests[index]!.resolve({ + _tag: "Success", + value: { entries, truncated: false }, + timestamp: ++state.responseTimestamp, + }); + }); + }, + async fail(index: number, message = "Directory unavailable") { + renders = 0; + await act(async () => { + requests[index]!.resolve({ _tag: "Failure", cause: Cause.fail(new Error(message)) }); + }); + }, + [Symbol.dispose]: unmount, + }; +} + +describe("file tree workspace state", () => { + it("retries failed folders once on reconnect", async () => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.fail(0); + act(() => mocks.state.reconnect()); + expect(screen.requests).toHaveLength(2); + act(() => mocks.state.reconnect()); + expect(screen.requests).toHaveLength(2); + await screen.succeed(1); + act(() => mocks.state.reconnect()); + expect(screen.requests).toHaveLength(2); + }); + + it("publishes immutable loading and loaded snapshots", async () => { + using screen = setup(); + screen.render(); + const initial = screen.current; + screen.load(); + const loading = screen.current; + expect(loading.isPending).toBe(true); + expect([...loading.loadingDirectories]).toEqual(["src"]); + expect([...initial.loadingDirectories]).toEqual([]); + await screen.succeed(0, [{ kind: "file", path: "src/first.ts" }]); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/first.ts"]); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + expect(screen.current.isPending).toBe(false); + expect([...screen.current.loadingDirectories]).toEqual([]); + expect(loading.entries.map((entry) => entry.path)).toEqual(["src"]); + expect([...loading.loadedDirectories]).toEqual([]); + expect([...loading.loadingDirectories]).toEqual(["src"]); + }); + + it.each([{ cwd: "/second" }, { environmentId: EnvironmentId.make("second") }])( + "isolates directory state and restores it on return from %j", + async (target) => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, [{ kind: "file", path: "src/first.ts" }]); + + screen.updateView(["src"], "src/first.ts"); + const firstView = screen.current; + screen.render(target); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBeNull(); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect([...screen.current.loadedDirectories]).toEqual([]); + expect([...screen.current.loadingDirectories]).toEqual([]); + screen.load(); + expect(screen.requests[1]!.query).toEqual({ + environmentId: target.environmentId ?? firstTarget.environmentId, + input: { cwd: target.cwd ?? firstTarget.cwd, directoryPath: "src" }, + }); + await screen.succeed(1, [{ kind: "file", path: "src/second.ts" }]); + + screen.updateView([], "src/second.ts"); + expect([...firstView.expandedPaths]).toEqual(["src"]); + expect(firstView.selectedPath).toBe("src/first.ts"); + screen.render(); + expect([...screen.current.expandedPaths]).toEqual(["src"]); + expect(screen.current.selectedPath).toBe("src/first.ts"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/first.ts"]); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + screen.load(); + expect(screen.requests).toHaveLength(3); + await screen.succeed(2, [{ kind: "file", path: "src/first.ts" }]); + + screen.render(target); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBe("src/second.ts"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/second.ts"]); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + }, + ); + + it("ignores stale successes and failures after leaving and returning to a workspace", async () => { + using screen = setup(); + screen.render(); + screen.load(); + screen.load("src/other"); + screen.render({ cwd: "/second" }); + screen.render(); + screen.load(); + + await screen.succeed(0, [{ kind: "file", path: "src/stale.ts" }]); + await screen.fail(1, "Stale error"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect(screen.current.error).toBeNull(); + expect([...screen.current.loadingDirectories]).toEqual(["src"]); + expect(screen.current.isPending).toBe(true); + + await screen.succeed(2, [{ kind: "file", path: "src/current.ts" }]); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/current.ts"]); + expect(screen.current.error).toBeNull(); + expect(screen.current.isPending).toBe(false); + expect([...screen.current.loadingDirectories]).toEqual([]); + }); + + it("retains recent listings and expansion when leaving a large workspace", async () => { + using screen = setup(); + screen.render(); + const paths = Array.from({ length: 70 }, (_, index) => `src/directory-${index}`); + screen.load(); + await screen.succeed( + 0, + paths.map((path) => ({ kind: "directory", path })), + ); + for (const path of paths) { + screen.load(path); + await screen.succeed(screen.requests.length - 1, [{ kind: "file", path: `${path}/file.ts` }]); + } + screen.updateView(["src", ...paths], `${paths[69]}/file.ts`); + screen.render({ cwd: "/other" }); + screen.render(); + expect(screen.current.loadedDirectories.size).toBe(64); + expect(screen.current.loadedDirectories.has(paths[69]!)).toBe(true); + expect(screen.current.expandedPaths.size).toBe(71); + expect(screen.current.selectedPath).toBe(`${paths[69]}/file.ts`); + for (let index = 1; index <= 8; index++) screen.render({ cwd: `/workspace-${index}` }); + screen.render(); + expect(screen.current.loadedDirectories.size).toBe(0); + expect(screen.current.expandedPaths.size).toBe(0); + expect(screen.current.selectedPath).toBeNull(); + }); + + it("retries a failed folder only when the user expands it again", async () => { + using screen = setup(true); + screen.render(); + expect(screen.rows).toEqual(["src"]); + expect(screen.requests).toHaveLength(0); + + screen.press("src"); + expect(screen.requests).toHaveLength(1); + await screen.fail(0); + expect(screen.current.error).toBe("Directory unavailable"); + // Re-rendering and the browser's passive expansion effect never retry a failed listing. + screen.render(); + screen.render(); + expect(screen.requests).toHaveLength(1); + expect(screen.rows).toEqual(["src"]); + + screen.press("src"); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.requests).toHaveLength(1); + screen.press("src"); + expect([...screen.current.expandedPaths]).toEqual(["src"]); + expect(screen.requests).toHaveLength(2); + await screen.succeed(1, [{ kind: "file", path: "src/file.ts" }]); + expect(screen.current.error).toBeNull(); + expect(screen.rows).toEqual(["src", "src/file.ts"]); + + // A loaded folder serves from cache on collapse and expand. + screen.press("src"); + screen.press("src"); + expect(screen.rows).toEqual(["src", "src/file.ts"]); + expect(screen.requests).toHaveLength(2); + }); + + it("reveals a controlled leaf-only search result after search clears", () => { + using screen = setup(true); + screen.render(); + screen.searchQuery.data = { + entries: [{ kind: "file", path: "src/new/file.ts" }], + truncated: false, + }; + screen.controlSelection("src/new/file.ts"); + screen.render({ searchQuery: "file" }); + expect([...screen.current.expandedPaths]).toEqual(["src", "src/new"]); + screen.render(); + expect(screen.current.entries.map((entry) => entry.path)).toContain("src/new/file.ts"); + }); + + it.each(["collapse", "delete"])("settles controlled selection after %s", async (action) => { + using screen = setup(true); + screen.render(); + screen.updateView([], "src/nested/file.ts"); + await screen.succeed(0, [{ kind: "directory", path: "src/nested" }]); + expect([...screen.current.expandedPaths]).toEqual(["src", "src/nested"]); + if (action === "collapse") { + screen.updateView([], "src/nested/file.ts"); + expect([...screen.current.expandedPaths]).toEqual([]); + screen.render({ cwd: "/second" }); + screen.render(); + expect([...screen.current.expandedPaths]).toEqual([]); + } + screen.rootQuery.data = { entries: [], truncated: false }; + screen.render(); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBeNull(); + if (action === "delete") { + screen.controlSelection("src/nested/file.ts"); + screen.searchQuery.data = { + entries: [ + { kind: "directory", path: "src" }, + { kind: "directory", path: "src/nested" }, + { kind: "file", path: "src/nested/file.ts" }, + ], + truncated: false, + }; + screen.rootQuery.failure = new ProjectListEntriesError({ + cwd: "/first", + failure: "workspace_root_not_found", + }); + screen.rootQuery.error = screen.rootQuery.failure.message; + const requestCount = screen.requests.length; + for (const error of [null, "Search unavailable"]) { + screen.searchQuery.error = error; + screen.render({ searchQuery: "nested" }); + expect(screen.current.entries).toEqual([]); + expect([...screen.current.expandedPaths]).toEqual([]); + expect([...screen.current.loadedDirectories]).toEqual([]); + expect(screen.current.isPending).toBe(false); + expect(screen.requests).toHaveLength(requestCount); + } + } + }); + + it.each(["return", "missing root"])( + "revalidates retained children after %s without clearing for transient errors", + async (scenario) => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, [{ kind: "file", path: "src/old.ts" }]); + screen.updateView(["src"], "src/old.ts"); + screen.render({ cwd: "/second" }); + screen.render(); + screen.rootQuery.error = "Connection unavailable"; + screen.render(); + expect(screen.current.selectedPath).toBe("src/old.ts"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/old.ts"]); + if (scenario === "missing root") { + screen.rootQuery.failure = new ProjectListEntriesError({ + cwd: "/first", + failure: "workspace_root_not_found", + }); + screen.rootQuery.error = screen.rootQuery.failure.message; + expect(screen.rootQuery.error).toBe("Failed to list workspace entries in '/first'."); + screen.render(); + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.expandedPaths]).toEqual([]); + expect([...screen.current.loadedDirectories]).toEqual([]); + } + screen.rootQuery.error = null; + screen.rootQuery.failure = null; + screen.render(); + if (scenario === "missing root") { + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect(screen.current.selectedPath).toBeNull(); + } + screen.load(); + expect(screen.requests).toHaveLength(2); + await screen.succeed(1, []); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect(screen.current.selectedPath).toBeNull(); + }, + ); + + it.each(["new", "src/new"])( + "keeps search discovery %s until its parent listing revalidates it", + async (path) => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, []); + screen.searchQuery.data = { + entries: [{ kind: "file", path: `${path}/file.ts` }], + truncated: false, + }; + screen.render({ searchQuery: "new" }); + const expanded = path.includes("/") ? ["src", path] : [path]; + screen.updateView(expanded, `${path}/file.ts`); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + screen.load(path); + await screen.succeed(1, [ + { kind: "file", path: `${path}/file.ts` }, + { kind: "file", path: `${path}/other.ts` }, + ]); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + + const cachedSearch = screen.searchSuccess; + for (const searchQuery of ["", "other"]) { + screen.searchQuery.data = { entries: [], truncated: false }; + screen.render({ searchQuery }); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/file.ts` }); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/other.ts` }); + } + + screen.render(); + for (const target of [{ cwd: "/second" }, { environmentId: EnvironmentId.make("second") }]) { + screen.render(target); + screen.render(); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/other.ts` }); + } + + screen.searchQuery.data = { + entries: [{ kind: "file", path: `${path}/file.ts` }], + truncated: false, + }; + screen.render({ searchQuery: "new" }); + for (const equalContents of [false, true]) { + // A later parent listing wins over retained discovery evidence. + if (path.includes("/")) { + // Refresh re-lists the root and every reachable folder; the empty parent prunes the child. + const refreshed = screen.requests.length; + act(() => { + void screen.current.refresh(); + }); + expect(screen.requests.slice(refreshed).map((request) => request.query.input)).toEqual([ + { cwd: "/first", directoryPath: "" }, + { cwd: "/first", query: "new", limit: 200 }, + { cwd: "/first", directoryPath: "src" }, + { cwd: "/first", directoryPath: path }, + ]); + // The parent settles last so its listing carries the newest response timestamp. + for (const index of [refreshed, refreshed + 1, refreshed + 3, refreshed + 2]) { + await screen.succeed(index, []); + } + } else { + const cachedData = screen.rootQuery.data; + screen.rootQuery.isPending = true; + screen.render({ searchQuery: "new" }); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + screen.rootQuery.isPending = false; + expect(screen.rootQuery.data).toBe(cachedData); + screen.render({ searchQuery: "new" }); + } + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.expandedPaths]).toEqual(path.includes("/") ? ["src"] : []); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + + // Cover an older cache and the timestamp's ambiguous same-millisecond boundary. + for (const success of [ + cachedSearch, + AsyncResult.success(cachedSearch.value, { timestamp: screen.responseTimestamp }), + ]) { + screen.searchQuery.data = { entries: [], truncated: false }; + screen.render({ searchQuery: "other" }); + screen.searchQuery.success = success; + screen.searchQuery.data = success.value; + screen.render({ searchQuery: "new" }); + screen.updateView(expanded, `${path}/file.ts`); + const requestCount = screen.requests.length; + screen.load(path); + expect(screen.requests).toHaveLength(requestCount + 1); + await screen.fail(requestCount); + screen.searchQuery.success = null; + screen.render(); + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.expandedPaths]).toEqual(path.includes("/") ? ["src"] : []); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + expect( + screen.current.entries.some( + (entry) => entry.path === path || entry.path.startsWith(`${path}/`), + ), + ).toBe(false); + } + + const cachedSearchData = screen.searchQuery.data; + screen.searchQuery.isPending = true; + screen.render({ searchQuery: "new" }); + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + screen.searchQuery.isPending = false; + if (!equalContents) { + screen.searchQuery.data = { + ...cachedSearchData, + entries: [...cachedSearchData.entries, { kind: "file", path: `${path}/added.ts` }], + }; + } + expect(screen.searchQuery.data === cachedSearchData).toBe(equalContents); + screen.render({ searchQuery: "new" }); + screen.updateView(expanded, `${path}/file.ts`); + screen.load(path); + await screen.succeed(screen.requests.length - 1, [ + { kind: "file", path: `${path}/file.ts` }, + { kind: "file", path: `${path}/recreated.ts` }, + ]); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/file.ts` }); + expect(screen.current.entries).toContainEqual({ + kind: "file", + path: `${path}/recreated.ts`, + }); + } + }, + ); + + it("prunes deleted directory contents, expansion and selection after a fresh parent listing", async () => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, [{ kind: "directory", path: "src/nested" }]); + screen.load("src/nested"); + await screen.succeed(1, [{ kind: "file", path: "src/nested/file.ts" }]); + screen.updateView(["src", "src/nested"], "src/nested/file.ts"); + screen.rootQuery.data = { entries: [], truncated: true }; + screen.render(); + expect([...screen.current.loadedDirectories]).toEqual(["src", "src/nested"]); + expect(screen.current.selectedPath).toBe("src/nested/file.ts"); + screen.rootQuery.data = { entries: [], truncated: false }; + screen.render(); + expect(screen.current.entries).toEqual([]); + expect([...screen.current.loadedDirectories]).toEqual([]); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBeNull(); + screen.rootQuery.data = { entries: rootEntries, truncated: false }; + screen.render(); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + }); +}); diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index 3c728d48ad090..62c2e33746a9f 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -1,13 +1,104 @@ +import { useAtomMount, useAtomSubscribe } from "@effect/atom-react"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; +import { ProjectListEntriesError, type EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { useEffect, useReducer, useRef, useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; +import { Atom } from "effect/reactivity"; +import { environmentCatalog } from "../../connection/catalog"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; import { useDebouncedValue } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; +function createDirectories() { + return { + target: {}, + entries: new Map>(), + errors: new Map(), + completeDirectories: new Set(), + listingCompletedAt: new Map(), + discoveries: new Map(), + rootListing: null as { readonly timestamp: number } | null, + validatedDirectories: new Set(), + loading: new Set(), + expandedPaths: new Set(), + selectedPath: null as string | null, + revealedSelection: { path: null as string | null, ancestors: new Set() }, + isRefreshing: false, + }; +} + +function boundDirectories(current: ReturnType, preserveVisible = true) { + const visible = (path: string) => { + const parts = path.split("/"); + return parts.every((_, index) => + current.expandedPaths.has(parts.slice(0, index + 1).join("/")), + ); + }; + const inactive = [...new Set([...current.entries.keys(), ...current.errors.keys()])].filter( + (path) => !preserveVisible || !visible(path), + ); + const ownedPaths = [ + ...current.entries.keys(), + ...current.expandedPaths, + current.selectedPath, + ].filter((path) => path !== null); + const discoveries = new Map( + [...current.discoveries].filter(([path]) => + ownedPaths.some((owned) => owned === path || owned.startsWith(`${path}/`)), + ), + ); + if (inactive.length <= 64) + return discoveries.size === current.discoveries.size ? current : { ...current, discoveries }; + const evicted = new Set(inactive.slice(0, -64)); + // Evict listing data, not the user's expansion and selection. A missing parent reloads on return. + return { + ...current, + discoveries, + entries: new Map([...current.entries].filter(([path]) => !evicted.has(path))), + errors: new Map([...current.errors].filter(([path]) => !evicted.has(path))), + completeDirectories: new Set( + [...current.completeDirectories].filter((path) => !evicted.has(path)), + ), + validatedDirectories: new Set( + [...current.validatedDirectories].filter((path) => !evicted.has(path)), + ), + listingCompletedAt: new Map( + [...current.listingCompletedAt].filter(([path]) => !evicted.has(path)), + ), + }; +} + +function contradictsListing(path: string, parent: string, entries: ReadonlyArray) { + if (parent !== "" && !path.startsWith(`${parent}/`)) return false; + const child = path.slice(parent === "" ? 0 : parent.length + 1).split("/")[0]; + const childPath = parent === "" ? child : `${parent}/${child}`; + const entry = entries.find((item) => item.path === childPath); + return !entry || (path !== childPath && entry.kind !== "directory"); +} + +function createRequests() { + return { + active: true, + refreshVersion: 0, + pending: new Map(), + }; +} + +function getRequests( + requestsByTarget: WeakMap>, + target: object, +) { + let requests = requestsByTarget.get(target); + if (!requests) { + requests = createRequests(); + requestsByTarget.set(target, requests); + } + return requests; +} +const noSessionGeneration = Atom.make(0); + export function useFileTreeEntries(input: { readonly cwd: string | null; readonly environmentId: EnvironmentId | null; @@ -22,6 +113,7 @@ export function useFileTreeEntries(input: { ? projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }) : null, ); + const { refresh: refreshRoot } = root; const search = useEnvironmentQuery( searching && debouncedQuery.length > 0 && cwd !== null && environmentId !== null ? projectEnvironment.searchEntries({ @@ -30,42 +122,98 @@ export function useFileTreeEntries(input: { }) : null, ); - const [, render] = useReducer((value: number) => value + 1, 0); - const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0); - const refreshVersion = useRef(0); - const directories = { - cwd, - environmentId, - entries: new Map>(), - requested: new Set(), - pending: new Map(), - errors: new Map(), - }; - const [refreshingDirectories, setRefreshingDirectories] = useState( - null, - ); - useEffect( - () => () => { - refreshVersion.current++; - for (const controller of directories.pending.values()) controller.abort(); - directories.pending.clear(); - }, - [directories], - ); + const workspaceKey = JSON.stringify([environmentId, cwd]); + const [cache, setCache] = useState(() => ({ + key: workspaceKey, + workspaces: new Map([[workspaceKey, createDirectories()]]), + })); + const savedDirectories = cache.workspaces.get(workspaceKey) ?? createDirectories(); + // Requests belong to a visit, so returning cannot revive an old completion. + const directories = + cache.key === workspaceKey + ? savedDirectories + : { + ...savedDirectories, + target: {}, + validatedDirectories: new Set(), + errors: new Map(), + loading: new Set(), + isRefreshing: false, + }; + if (cache.key !== workspaceKey) { + const workspaces = new Map(cache.workspaces); + const previous = workspaces.get(cache.key); + if (previous) { + workspaces.set( + cache.key, + boundDirectories( + { + ...previous, + errors: new Map(), + loading: new Set(), + isRefreshing: false, + }, + false, + ), + ); + } + workspaces.delete(workspaceKey); + workspaces.set(workspaceKey, directories); + if (workspaces.size > 8) workspaces.delete(workspaces.keys().next().value!); + setCache({ key: workspaceKey, workspaces }); + } + const setDirectories = ( + update: (current: ReturnType) => ReturnType, + ) => + setCache((current) => { + const previous = current.workspaces.get(workspaceKey); + if (!previous) return current; + const next = boundDirectories(update(previous)); + if (next === previous) return current; + return { ...current, workspaces: new Map(current.workspaces).set(workspaceKey, next) }; + }); + const { target } = directories; + const requestsByTarget = useRef(new WeakMap>()); + // Cancel the old target before consumers' passive effects start new directory loads. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the root and aborts the old target's directory requests before consumers start new loads + useLayoutEffect(() => { + const requests = getRequests(requestsByTarget.current, target); + requests.active = true; + refreshRoot(); + return () => { + requests.active = false; + requests.refreshVersion++; + for (const controller of requests.pending.values()) controller.abort(); + requests.pending.clear(); + setDirectories((current) => + current.target === target + ? { ...current, loading: new Set(), isRefreshing: false } + : current, + ); + }; + }, [target, setDirectories, requestsByTarget, refreshRoot]); + const updateDirectories = ( + update: (current: ReturnType) => ReturnType, + ) => setDirectories((current) => (current.target === target ? update(current) : current)); const loadDirectory = (directoryPath: string, refresh = false) => { + const requests = getRequests(requestsByTarget.current, target); if ( + !requests.active || + missingRoot || cwd === null || environmentId === null || - (!refresh && directories.entries.has(directoryPath)) || - directories.pending.has(directoryPath) + (!refresh && directories.validatedDirectories.has(directoryPath)) || + requests.pending.has(directoryPath) ) { return; } const controller = new AbortController(); - directories.requested.add(directoryPath); - directories.pending.set(directoryPath, controller); - directories.errors.delete(directoryPath); - render(); + requests.pending.set(directoryPath, controller); + updateDirectories((current) => { + const errors = new Map(current.errors); + errors.delete(directoryPath); + return { ...current, errors, loading: new Set(current.loading).add(directoryPath) }; + }); const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); appAtomRegistry.refresh(atom); return executeAtomQuery(appAtomRegistry, atom, { @@ -74,31 +222,87 @@ export function useFileTreeEntries(input: { reportDefect: false, }).then((result) => { if (controller.signal.aborted) return; - directories.pending.delete(directoryPath); - if (result._tag === "Success") { - directories.entries.set( - directoryPath, - result.value.entries.filter( - (entry) => - entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, - ), - ); - entriesChanged(); - } else { + requests.pending.delete(directoryPath); + updateDirectories((current) => { + const validatedDirectories = new Set(current.validatedDirectories).add(directoryPath); + const loading = new Set(current.loading); + loading.delete(directoryPath); + if (result._tag === "Success") { + const entries = new Map(current.entries); + entries.delete(directoryPath); + entries.set( + directoryPath, + result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ); + const completeDirectories = new Set(current.completeDirectories); + if (result.value.truncated) completeDirectories.delete(directoryPath); + else completeDirectories.add(directoryPath); + return { + ...current, + entries, + loading, + completeDirectories, + discoveries: result.value.truncated + ? current.discoveries + : new Map( + [...current.discoveries].filter( + ([path]) => !contradictsListing(path, directoryPath, result.value.entries), + ), + ), + validatedDirectories, + listingCompletedAt: new Map(current.listingCompletedAt).set( + directoryPath, + result.timestamp, + ), + }; + } const error = Cause.squash(result.cause); - directories.errors.set( - directoryPath, - error instanceof Error ? error.message : "Files unavailable", - ); - } - render(); + return { + ...current, + loading, + validatedDirectories, + errors: new Map(current.errors).set( + directoryPath, + error instanceof Error ? error.message : "Files unavailable", + ), + }; + }); }); }; - const { refresh: refreshRoot, data: rootData } = root; - const { refresh: refreshSearch, data: searchData } = search; + const missingRoot = + root.failure instanceof ProjectListEntriesError && + root.failure.failure === "workspace_root_not_found"; + const rootData = missingRoot ? null : root.data; + const rootListing = root.successResult ?? directories.rootListing; + if ( + missingRoot && + (directories.entries.size > 0 || + directories.errors.size > 0 || + directories.loading.size > 0 || + directories.expandedPaths.size > 0 || + directories.selectedPath !== null || + directories.discoveries.size > 0) + ) { + setDirectories(() => createDirectories()); + } + const { refresh: refreshSearch, data: searchData, successResult: searchResult } = search; + // A folder read fails at once while its environment is unreachable; read it again once a new + // session is up rather than leaving the error until someone refreshes. + const sessionGeneration = + environmentId === null + ? noSessionGeneration + : environmentCatalog.sessionGenerationAtom(environmentId); + // A listener alone does not compute the atom, so nothing would follow the connection. + useAtomMount(sessionGeneration); + useAtomSubscribe(sessionGeneration, () => { + for (const path of directories.errors.keys()) void loadDirectory(path, true); + }); const snapshot = (() => { - const merged = new Map(); - if (searching) { + const merged = new Map(missingRoot ? [] : directories.discoveries); + if (searching && !missingRoot) { for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry); } const reachableDirectories = new Set(); @@ -111,31 +315,158 @@ export function useFileTreeEntries(input: { } } }; + if (!missingRoot) { + const discoveries = new Map(directories.discoveries); + // Search can return only leaves; their inferred parents still own loaded children. + for (const entry of directories.discoveries.values()) { + const parts = entry.path.split("/"); + for (let index = 1; index < parts.length; index++) { + const path = parts.slice(0, index).join("/"); + if (!discoveries.has(path)) discoveries.set(path, { kind: "directory", path }); + } + } + visit([...discoveries.values()]); + } visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); return { - revision: entriesRevision, entries: [...merged.values()], reachableDirectories, - searchResultPaths: searching - ? new Set((searchData?.entries ?? []).map((entry) => entry.path)) - : null, + searchResultPaths: + searching && !missingRoot + ? new Set((searchData?.entries ?? []).map((entry) => entry.path)) + : null, }; })(); + if (rootData) { + const deleted = (path: string, directory = false) => { + const parts = path.split("/"); + for (let index = 0; index < parts.length; index++) { + const parent = parts.slice(0, index).join("/"); + const listing = index === 0 ? rootData.entries : directories.entries.get(parent); + const child = parts.slice(0, index + 1).join("/"); + const complete = + index === 0 ? !rootData.truncated : directories.completeDirectories.has(parent); + const entry = listing?.find((item) => item.path === child); + if (complete && listing && !entry) { + const listingCompletedAt = + index === 0 && directories.rootListing !== rootListing + ? rootListing?.timestamp + : directories.listingCompletedAt.get(parent); + // Cached queries keep their completion time across query switches. + // Equal-millisecond responses cannot prove a path postdates the listing. + const retained = + !(index === 0 && directories.rootListing !== rootListing) && + [...directories.discoveries.keys()].some( + (path) => path === child || path.startsWith(`${child}/`), + ); + const discovered = + retained || + (searching && + query === debouncedQuery && + searchResult !== null && + searchResult.timestamp > (listingCompletedAt ?? -Infinity) && + searchData?.entries.some( + (item) => item.path === child || item.path.startsWith(`${child}/`), + )); + if (!discovered) return true; + } + if (entry && (directory || index < parts.length - 1) && entry.kind !== "directory") + return true; + } + return false; + }; + const reconciled = (() => { + const current = directories; + const discoveries = new Map(current.discoveries); + if (current.rootListing !== rootListing && !rootData.truncated) { + for (const path of discoveries.keys()) { + if (contradictsListing(path, "", rootData.entries)) discoveries.delete(path); + } + } + if (searching && query === debouncedQuery && searchResult !== null) { + const ownedPaths = [ + ...current.entries.keys(), + ...current.expandedPaths, + current.selectedPath, + ]; + for (const entry of searchData?.entries ?? []) { + if ( + ownedPaths.some((path) => path === entry.path || path?.startsWith(`${entry.path}/`)) && + !deleted(entry.path, entry.kind === "directory") + ) + discoveries.set(entry.path, entry); + } + } + const entries = new Map([...current.entries].filter(([path]) => !deleted(path, true))); + const errors = new Map([...current.errors].filter(([path]) => !deleted(path, true))); + const expandedPaths = new Set( + [...current.expandedPaths].filter((path) => !deleted(path, true)), + ); + const selectedPath = + current.selectedPath !== null && deleted(current.selectedPath) + ? null + : current.selectedPath; + if ( + entries.size === current.entries.size && + errors.size === current.errors.size && + expandedPaths.size === current.expandedPaths.size && + selectedPath === current.selectedPath && + current.rootListing === rootListing && + discoveries.size === current.discoveries.size && + [...discoveries].every(([path, entry]) => current.discoveries.get(path) === entry) + ) + return current; + const completeDirectories = new Set( + [...current.completeDirectories].filter((path) => entries.has(path)), + ); + return { + ...current, + rootListing, + discoveries, + listingCompletedAt: new Map( + [...current.listingCompletedAt].filter(([path]) => path === "" || entries.has(path)), + ).set( + "", + current.rootListing === rootListing + ? (current.listingCompletedAt.get("") ?? -Infinity) + : (rootListing?.timestamp ?? -Infinity), + ), + entries, + errors, + expandedPaths, + selectedPath, + completeDirectories, + validatedDirectories: new Set( + [...current.validatedDirectories].filter((path) => !deleted(path, true)), + ), + }; + })(); + if (reconciled !== directories) setDirectories(() => reconciled); + } + const refresh = () => { + const requests = getRequests(requestsByTarget.current, target); + if (!requests.active) return Promise.resolve(); refreshRoot(); - if (searching) refreshSearch(); + if (searching && !missingRoot) refreshSearch(); const paths = new Set( - [...directories.requested].filter((path) => snapshot.reachableDirectories.has(path)), + [...directories.entries.keys(), ...directories.errors.keys(), ...directories.loading].filter( + (path) => snapshot.reachableDirectories.has(path), + ), ); - for (const controller of directories.pending.values()) controller.abort(); - directories.pending.clear(); - directories.errors.clear(); - const version = ++refreshVersion.current; - setRefreshingDirectories(directories); + for (const controller of requests.pending.values()) controller.abort(); + requests.pending.clear(); + const version = ++requests.refreshVersion; + updateDirectories((current) => ({ + ...current, + errors: new Map(), + loading: new Set(), + isRefreshing: true, + })); const remaining = paths.values(); const worker = async () => { - while (version === refreshVersion.current) { + while (requests.active && version === requests.refreshVersion) { const next = remaining.next(); if (next.done) return; await loadDirectory(next.value, true); @@ -145,7 +476,7 @@ export function useFileTreeEntries(input: { cwd !== null && environmentId !== null ? [ projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }), - ...(searching + ...(searching && !missingRoot ? [ projectEnvironment.searchEntries({ environmentId, @@ -161,29 +492,80 @@ export function useFileTreeEntries(input: { ), ...Array.from({ length: Math.min(4, paths.size) }, () => worker()), ]; - render(); return Promise.all(work).finally(() => { - if (version === refreshVersion.current) setRefreshingDirectories(null); + if (version === requests.refreshVersion) { + updateDirectories((current) => ({ ...current, isRefreshing: false })); + } }); }; + const revealSelection = (path: string | null) => { + if (missingRoot) return; + updateDirectories((current) => { + const previous = current.revealedSelection; + const handled = previous.path === path ? previous.ancestors : new Set(); + const parts = path?.split("/").filter(Boolean) ?? []; + const ancestors = parts + .slice(1) + .map((_, index) => parts.slice(0, index + 1).join("/")) + .filter( + (ancestor) => + !handled.has(ancestor) && + snapshot.entries.some( + (entry) => + (entry.kind === "directory" && entry.path === ancestor) || + (entry.path === path && entry.path.startsWith(`${ancestor}/`)), + ), + ); + if (previous.path === path && ancestors.length === 0) return current; + return { + ...current, + discoveries: new Map([ + ...current.discoveries, + ...snapshot.entries + .filter((entry) => entry.path === path) + .map((entry) => [entry.path, entry] as const), + ]), + selectedPath: path, + revealedSelection: { path, ancestors: new Set([...handled, ...ancestors]) }, + expandedPaths: new Set([...current.expandedPaths, ...ancestors]), + }; + }); + }; + + const setExpandedPaths = (update: (current: ReadonlySet) => ReadonlySet) => + setDirectories((current) => { + const expandedPaths = update(current.expandedPaths); + return expandedPaths === current.expandedPaths + ? current + : { ...current, expandedPaths: new Set(expandedPaths) }; + }); + return { + revealSelection, + expandedPaths: directories.expandedPaths, + setExpandedPaths, + selectedPath: directories.selectedPath, + selectPath: (selectedPath: string) => + updateDirectories((current) => ({ ...current, selectedPath })), entries: snapshot.entries, searchResultPaths: snapshot.searchResultPaths, - isRefreshing: refreshingDirectories === directories, + isRefreshing: directories.isRefreshing, error: root.error ?? (searching ? search.error : null) ?? [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ?? null, isPending: - root.isPending || - directories.pending.size > 0 || - (searching && (query !== debouncedQuery || search.isPending)), - searchTruncated: searching && (search.data?.truncated ?? false), + !missingRoot && + (root.isPending || + directories.loading.size > 0 || + (searching && (query !== debouncedQuery || search.isPending))), + searchTruncated: !missingRoot && searching && (search.data?.truncated ?? false), loadedDirectories: new Set(directories.entries.keys()), - loadingDirectories: new Set(directories.pending.keys()), - loadDirectory, + loadingDirectories: directories.loading, + loadDirectory: (path: string, retry = false) => + loadDirectory(path, retry && directories.errors.has(path)), refresh, }; } diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index f109ac0042a8e..fa52af019cb0a 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -16,13 +16,10 @@ export function useWorkspaceFileAssetUrlState(props: { readonly threadId: ThreadId | null; readonly draftCwd?: string | null; }) { - const absolutePath = useMemo( - () => - props.cwd !== null && props.relativePath !== null - ? resolveWorkspaceFilePath(props.cwd, props.relativePath) - : null, - [props.cwd, props.relativePath], - ); + const absolutePath = + props.cwd !== null && props.relativePath !== null + ? resolveWorkspaceFilePath(props.cwd, props.relativePath) + : null; const relativePath = props.relativePath; const draftCwd = props.draftCwd ?? null; diff --git a/apps/mobile/src/features/files/zipTree.ts b/apps/mobile/src/features/files/zipTree.ts new file mode 100644 index 0000000000000..dcc9552c5c122 --- /dev/null +++ b/apps/mobile/src/features/files/zipTree.ts @@ -0,0 +1,139 @@ +export interface ZipEntry { + readonly path: string; + readonly name: string; + readonly isDirectory: boolean; + readonly sizeBytes: number; + readonly mimeType: string; + readonly bytes: Uint8Array; +} + +export interface ZipTreeNode { + readonly path: string; + readonly label: string; + readonly entry: ZipEntry | null; + readonly children: ReadonlyArray; + readonly fileCount: number; +} + +export interface ZipTreeRow { + readonly node: ZipTreeNode; + readonly depth: number; +} + +interface MutableNode { + path: string; + name: string; + entry: ZipEntry | null; + children: Map; +} + +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +function freeze(node: MutableNode): ZipTreeNode { + let current = node; + let label = node.name; + while (current.path !== "" && current.entry === null && current.children.size === 1) { + const [only] = current.children.values(); + if (!only || only.entry !== null) break; + current = only; + label = `${label}/${only.name}`; + } + const children = [...current.children.values()] + .sort((left, right) => + (left.entry === null) !== (right.entry === null) + ? left.entry === null + ? -1 + : 1 + : collator.compare(left.name, right.name), + ) + .map(freeze); + return { + path: current.path, + label, + entry: current.entry, + children, + fileCount: + current.entry === null ? children.reduce((sum, child) => sum + child.fileCount, 0) : 1, + }; +} + +export function buildZipTree(entries: ReadonlyArray): ReadonlyArray { + const root: MutableNode = { path: "", name: "", entry: null, children: new Map() }; + for (const entry of entries) { + const parts = entry.path.split("/").filter(Boolean); + let current = root; + parts.forEach((part, index) => { + const leaf = index === parts.length - 1 && !entry.isDirectory; + const key = leaf ? part : `${part}/`; + let child = current.children.get(key); + if (!child) { + child = { + path: parts.slice(0, index + 1).join("/"), + name: part, + entry: leaf ? entry : null, + children: new Map(), + }; + current.children.set(key, child); + } + current = child; + }); + } + return freeze(root).children; +} + +function flatten( + output: ZipTreeRow[], + node: ZipTreeNode, + depth: number, + collapsed: ReadonlySet, + query: string, +): boolean { + if (node.entry) { + if (query && !node.entry.path.toLowerCase().includes(query)) return false; + output.push({ node, depth }); + return true; + } + const children: ZipTreeRow[] = []; + let visible = !query; + if (query || !collapsed.has(node.path)) { + for (const child of node.children) { + if (flatten(children, child, depth + 1, collapsed, query)) visible = true; + } + } + if (!visible) return false; + output.push({ node, depth }, ...children); + return true; +} + +export function flattenZipTree( + nodes: ReadonlyArray, + collapsed: ReadonlySet, + query: string, +): ReadonlyArray { + const output: ZipTreeRow[] = []; + const normalized = query.trim().toLowerCase(); + for (const node of nodes) flatten(output, node, 0, collapsed, normalized); + return output; +} + +export function zipFolderLabel(label: string) { + const parts = label.split("/"); + const name = parts.at(-1) ?? label; + if (parts.length > 3) return { prefix: `${parts[0]}/…/${parts.at(-2)}/`, name }; + return { prefix: parts.length > 1 ? `${parts.slice(0, -1).join("/")}/` : "", name }; +} + +export function zipDownloadTimeLeft(input: { + readonly receivedBytes: number; + readonly totalBytes: number; + readonly elapsedMs: number; +}) { + if (input.receivedBytes <= 0 || input.totalBytes <= input.receivedBytes || input.elapsedMs < 1000) + return null; + const seconds = Math.ceil( + ((input.totalBytes - input.receivedBytes) * input.elapsedMs) / input.receivedBytes / 1000, + ); + return seconds < 60 + ? `About ${seconds} ${seconds === 1 ? "second" : "seconds"} left` + : `About ${Math.ceil(seconds / 60)} minutes left`; +} diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index 7c6fa8c6850fd..1a54ed4ad46c3 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, type ComponentProps } from "react"; +import { useRef, useState, type ComponentProps } from "react"; import { useWindowDimensions, View, @@ -14,17 +14,40 @@ import { import { useWorkspaceState } from "../../state/workspace"; import { FabScrollContext } from "./FabScrollContext"; import { updateFabScroll } from "./fab-scroll"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; +import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; +import { WorkspaceTabsContext, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; +import { + useAppearancePreferences, + useAndroidFloatingTabs, +} from "../settings/appearance/AppearancePreferencesProvider"; export function AndroidHomeFabLayout(props: ComponentProps) { + const floatingTabsEnabled = useAndroidFloatingTabs(); const insets = useSafeAreaInsets(); const { appearance } = useAppearancePreferences(); const { fontScale } = useWindowDimensions(); const [layoutWidth, setLayoutWidth] = useState(null); const { state } = useWorkspaceState(); const [expanded, setExpanded] = useState(true); + const { scale, fabSize } = useAndroidControlSizing(); + const tabs = useEnabledNavigationTabs(); + const floating = floatingTabsMetrics( + tabs.length + 1, + scale, + fabSize, + props.paneWidth ?? layoutWidth ?? undefined, + ); + const workspace = useWorkspaceTabs(); + const bottomClearance = + insets.bottom + + FLOATING_TABS.bottom + + FLOATING_TABS.edge + + Math.max(floating.height, floating.fabOffset + fabSize); + const context = floatingTabsEnabled && workspace ? { ...workspace, bottomClearance } : workspace; const scrollState = useRef({ anchor: 0, expanded: true }); - const onScroll = useCallback((event: NativeSyntheticEvent) => { + const onScroll = (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const next = updateFabScroll( scrollState.current, @@ -33,22 +56,33 @@ export function AndroidHomeFabLayout(props: ComponentProps setLayoutWidth(event.nativeEvent.layout.width)}> - {props.children} - {!props.disabled && !props.sidebar && state.hasConnections && layoutWidth !== null ? ( + + {props.children} + + {!props.disabled && + (!props.sidebar || floatingTabsEnabled) && + state.hasConnections && + layoutWidth !== null ? ( ) : null}
diff --git a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx index 5226fce97c2f3..b3a5453f3449f 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx @@ -5,6 +5,7 @@ export function AndroidHomeFabLayout(props: { readonly children: ReactNode; readonly sidebar?: boolean; readonly disabled?: boolean; + readonly paneWidth?: number; }) { return <>{props.children}; } diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx index 8666329f87aba..1d984c2f0fd3c 100644 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ b/apps/mobile/src/features/home/HomeHeader.android.tsx @@ -1,5 +1,4 @@ import type { MenuAction } from "@react-native-menu/menu"; -import { useCallback, useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { ThreadListToolbar } from "./ThreadListToolbar"; import type { HomeHeaderProps } from "./HomeHeader.types"; @@ -13,81 +12,75 @@ function checkedMenuState(checked: boolean) { export function HomeHeader(props: HomeHeaderProps) { const hasCustomListOptions = props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; - const menuActions = useMemo( - () => [ - { - id: "environment", - title: "Environment", - subactions: [ + const menuActions: MenuAction[] = [ + { + id: "environment", + title: "Environment", + subactions: [ + { + id: "environment:all", + title: "All environments", + state: checkedMenuState(props.selectedEnvironmentId === null), + }, + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), + })), + ], + }, + ...(props.projects.length === 0 + ? [] + : ([ { - id: "environment:all", - title: "All environments", - state: checkedMenuState(props.selectedEnvironmentId === null), + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + state: checkedMenuState(props.selectedProjectKey === null), + }, + ...props.projects.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: checkedMenuState(props.selectedProjectKey === project.key), + })), + ], }, - ...props.environments.map((environment) => ({ - id: `environment:${environment.environmentId}`, - title: environment.label, - state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), - })), - ], - }, - ...(props.projects.length === 0 - ? [] - : ([ - { - id: "project", - title: "Project", - subactions: [ - { - id: "project:all", - title: "All projects", - state: checkedMenuState(props.selectedProjectKey === null), - }, - ...props.projects.map((project) => ({ - id: `project:${project.key}`, - title: project.label, - state: checkedMenuState(props.selectedProjectKey === project.key), - })), - ], - }, - ] satisfies MenuAction[])), - ], - [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey], - ); - const handleMenuAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - const id = event.nativeEvent.event; - if (id === "environment:all") { - props.onEnvironmentChange(null); - return; - } + ] satisfies MenuAction[])), + ]; + const handleMenuAction = (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "environment:all") { + props.onEnvironmentChange(null); + return; + } - if (id.startsWith("environment:")) { - const environmentId = id.slice("environment:".length); - const environment = props.environments.find( - (candidate) => candidate.environmentId === environmentId, - ); - if (environment) { - props.onEnvironmentChange(environment.environmentId); - } - return; + if (id.startsWith("environment:")) { + const environmentId = id.slice("environment:".length); + const environment = props.environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (environment) { + props.onEnvironmentChange(environment.environmentId); } + return; + } - if (id === "project:all") { - props.onProjectChange(null); - return; - } + if (id === "project:all") { + props.onProjectChange(null); + return; + } - if (id.startsWith("project:")) { - const projectKey = id.slice("project:".length); - if (props.projects.some((project) => project.key === projectKey)) { - props.onProjectChange(projectKey); - } - return; + if (id.startsWith("project:")) { + const projectKey = id.slice("project:".length); + if (props.projects.some((project) => project.key === projectKey)) { + props.onProjectChange(projectKey); } - }, - [props], - ); + return; + } + }; return ( <> diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 5756a140e779b..fe1401326ab56 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -6,7 +6,7 @@ import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -22,7 +22,6 @@ import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; -import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; @@ -68,37 +67,24 @@ export function HomeRouteScreen() { else if (workspace) workspace.dispatch({ type: "query", query, tab: "threads" }); else setLocalSearchQuery(query); }; - const handleSelectThread = useCallback( - (thread: EnvironmentThreadShell) => - selectDetail({ - name: "Thread", - params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, - }), - [selectDetail], - ); - const selectThreadRoute = useCallback( - (params: ReactNavigation.RootParamList["Thread"]) => selectDetail({ name: "Thread", params }), - [selectDetail], - ); - const handleNewThreadOnBranch = useCallback( - (thread: EnvironmentThreadShell) => { - selectThreadRoute({ - draft: { - requestId: uuidv4(), - environmentId: String(thread.environmentId), - projectId: String(thread.projectId), - branch: thread.branch, - worktreePath: thread.worktreePath, - }, - }); - }, - [selectThreadRoute], - ); - - useEffect(() => { - void checkForAppUpdateOnLaunch(); - startAppUpdateForegroundRecheck(); - }, []); + const handleSelectThread = (thread: EnvironmentThreadShell) => + selectDetail({ + name: "Thread", + params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, + }); + const selectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => + selectDetail({ name: "Thread", params }); + const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { + selectThreadRoute({ + draft: { + requestId: uuidv4(), + environmentId: String(thread.environmentId), + projectId: String(thread.projectId), + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }); + }; const { archiveThread, @@ -117,22 +103,19 @@ export function HomeRouteScreen() { const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(selectThreadRoute); - const environments = useMemo(() => { - const connectionStateByEnvironmentId = new Map( - workspaceEnvironments.map( - (environment) => [environment.environmentId, environment.connectionState] as const, - ), - ); - return Arr.sort( - Object.values(savedConnectionsById).map((connection) => ({ - environmentId: connection.environmentId, - label: connection.environmentLabel, - connectionState: - connectionStateByEnvironmentId.get(connection.environmentId) ?? "available", - })), - Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label), - ); - }, [savedConnectionsById, workspaceEnvironments]); + const connectionStateByEnvironmentId = new Map( + workspaceEnvironments.map( + (environment) => [environment.environmentId, environment.connectionState] as const, + ), + ); + const environments = Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + connectionState: connectionStateByEnvironmentId.get(connection.environmentId) ?? "available", + })), + Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label), + ); const availableEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], @@ -143,16 +126,16 @@ export function HomeRouteScreen() { setSelectedProjectKey, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; - const projectScopeKey = listOptions.selectedProjectKey; - const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { - sidebarProjectGroupingMode: listOptions.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }); - useEffect(() => { - if (resolvedProjectKey !== projectScopeKey) { - setSelectedProjectKey(resolvedProjectKey); - } - }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); + // A legacy per-environment scratch key resolves here on every render; the stored + // value is only rewritten when the user picks a filter. + const resolvedProjectKey = resolveScratchProjectScopeKey( + listOptions.selectedProjectKey, + projects, + { + sidebarProjectGroupingMode: listOptions.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }, + ); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e9a71f2c253fc..1edb92f0d556b 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,7 +12,6 @@ import { import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { useFocusEffect } from "@react-navigation/native"; import { use, useEffect, useRef, useState, type ComponentProps } from "react"; import { Platform, @@ -51,6 +50,7 @@ import { useThreadSearch } from "../../state/queries"; import { requestThreadMessageJump } from "../threads/threadMessageJump"; import { useThreadListSearchMatches } from "../threads/useThreadListSearchMatches"; import { useThreadListV2 } from "../threads/useThreadListV2"; +import { useThreadListNow } from "../threads/use-thread-list-now"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; @@ -316,6 +316,7 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const searchMode = workspace?.state.tabs.threads.searchMode; const restoredOffset = workspace?.state.tabs.threads.scrollOffset; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores the thread list's saved scroll offset through the list ref when search goes back to resting useEffect(() => { if (searchMode !== "resting" || restoredOffset === undefined) return; listRef.current?.scrollToOffset({ @@ -333,6 +334,7 @@ export function HomeScreen(props: HomeScreenProps) { }; // Status-bar, accessibility and programmatic scrolls bypass the gate; activate visible rows after settling. const activationTimerRef = useRef | undefined>(undefined); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the row activation timer on unmount useEffect(() => () => clearTimeout(activationTimerRef.current), []); const handleListScroll = (event: NativeSyntheticEvent) => { onFabScroll?.(event); @@ -423,21 +425,14 @@ export function HomeScreen(props: HomeScreenProps) { toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); - // Keep time in state; the compiler would cache a render-time new Date(). - const [now, setNow] = useState(() => new Date().toISOString()); - const nowMinute = now.slice(0, 16); - const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - useFocusEffect(() => { - setNow(new Date().toISOString()); - const id = setInterval(() => setNow(new Date().toISOString()), 60_000); - return () => clearInterval(id); - }); + const now = useThreadListNow(props.threads); const listEnvironments = useAtomValue(threadListEnvironmentsAtom); const { providersByEnvironmentId, machineByEnvironmentId, settlementEnvironmentIds, snoozeEnvironmentIds, + snoozePresetsByEnvironmentId, pinningEnvironmentIds, autoSettleOptOutEnvironmentIds, pinReorderEnvironmentIds, @@ -445,7 +440,7 @@ export function HomeScreen(props: HomeScreenProps) { titleRegenerationEnvironmentIds, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); - const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const pendingOrder = usePendingThreadOrder(); const { layout: threadListV2Layout, items: threadListV2Items, @@ -473,19 +468,6 @@ export function HomeScreen(props: HomeScreenProps) { settledShelfExpanded, selectedThreadKey, }); - const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; - useEffect(() => { - if (nextSnoozeWakeAt === null) return; - const wakeAtMs = Date.parse(nextSnoozeWakeAt); - if (Number.isNaN(wakeAtMs)) return; - const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); - const id = setTimeout(() => { - setNow(new Date().toISOString()); - bumpSnoozeWakeTick((tick) => tick + 1); - }, delayMs); - return () => clearTimeout(id); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- snoozeWakeTick re-arms clamped timers even when the wake date exceeds 32-bit setTimeout and stays unchanged. - }, [nextSnoozeWakeAt, snoozeWakeTick]); // Called as `props.onSelectThread(...)`, the compiler would cache this handler on the whole // props object, which is new on every render of Home's parent. const { onSelectThread } = props; @@ -502,6 +484,7 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread(thread); }; useThreadJumpShortcuts(threadListV2Items, selectThread); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads the list's visible range after commit to enable swipe gestures on those rows useEffect(() => { if (swipeEnabled) activateVisibleRows(threadListV2Items); }, [activateVisibleRows, swipeEnabled, threadListV2Items]); @@ -620,6 +603,7 @@ export function HomeScreen(props: HomeScreenProps) { settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} + snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} reorderSupported={ diff --git a/apps/mobile/src/features/home/ThreadListToolbar.tsx b/apps/mobile/src/features/home/ThreadListToolbar.tsx index 90decba1d5b1f..9e8c55fd2b5e5 100644 --- a/apps/mobile/src/features/home/ThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/ThreadListToolbar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; +import { useEffect, useRef, useState, type ComponentProps } from "react"; import { useNavigation, useNavigationState } from "@react-navigation/native"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { @@ -42,20 +42,21 @@ export function ThreadListToolbar(props: { const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; - const openSearch = useCallback(() => { + const openSearch = () => { onRequestVisibility?.(); setSearchOpen(true); searchRef.current?.focus(); return true; - }, [onRequestVisibility]); + }; useHardwareKeyboardCommand("focusSearch", openSearch); - const closeSearch = useCallback(() => { + const closeSearch = () => { onSearchQueryChange(""); setSearchOpen(false); Keyboard.dismiss(); - }, [onSearchQueryChange]); + }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to BackHandler while searching to close search on Android back useEffect(() => { if (!searching || !listFocused) return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { diff --git a/apps/mobile/src/features/home/ThreadUndoNotice.tsx b/apps/mobile/src/features/home/ThreadUndoNotice.tsx index ec7c630d29ed2..364873a3eadeb 100644 --- a/apps/mobile/src/features/home/ThreadUndoNotice.tsx +++ b/apps/mobile/src/features/home/ThreadUndoNotice.tsx @@ -1,3 +1,4 @@ +import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; import { useState } from "react"; import { useKeyboardState } from "react-native-keyboard-controller"; import { Platform, View } from "react-native"; @@ -18,6 +19,7 @@ import { } from "./thread-undo-notice"; export function ThreadUndoNotice(props: { readonly visible: boolean }) { + const floatingTabsEnabled = useAndroidFloatingTabs(); const notice = useThreadUndoNotice(); const insets = useSafeAreaInsets(); const columnMetrics = useNativeColumnLayoutMetrics(); @@ -64,7 +66,9 @@ export function ThreadUndoNotice(props: { readonly visible: boolean }) { columnMetrics?.safeArea.bottom ?? 0, (workspace?.bottomClearance ?? 0) - workspacePadding, ) + 8 - : insets.bottom + 12; + : floatingTabsEnabled && keyboardHeight === 0 + ? Math.max(insets.bottom + 12, workspace?.bottomClearance ?? 0) + : insets.bottom + 12; return ( ("waiting"); - - useEffect(() => { - if (!hasStatus) return; - const timers = [ - setTimeout(() => setPhase("fading"), STATUS_SHOW_DELAY_MS), - setTimeout(() => setPhase("shown"), STATUS_SHOW_DELAY_MS + FADE_IN_MS), - ]; - return () => { - for (const timer of timers) clearTimeout(timer); - setPhase("waiting"); - }; - }, [hasStatus]); + // When the current status run began; a gap in the status restarts the delay. + const [statusSince, setStatusSince] = useState(() => (hasStatus ? Date.now() : null)); + if (hasStatus !== (statusSince !== null)) { + // oxlint-disable-next-line react/purity -- Reads the clock only in the render-phase adjustment that runs when the status appears. + setStatusSince(hasStatus ? Date.now() : null); + } + const since = hasStatus ? statusSince : null; + const fadeStarted = useDeadlinePassed(since === null ? null : since + STATUS_SHOW_DELAY_MS); + const fadeEnded = useDeadlinePassed( + since === null ? null : since + STATUS_SHOW_DELAY_MS + FADE_IN_MS, + ); const label = presentation?.label; const showsProgress = presentation?.showsProgress ?? false; - const status = useMemo( - () => - label === undefined || phase === "waiting" - ? null - : { label, showsProgress, fadeIn: phase === "fading" }, - [label, showsProgress, phase], - ); + const status = + label === undefined || since === null || !fadeStarted + ? null + : { label, showsProgress, fadeIn: !fadeEnded }; return {props.children}; } @@ -72,7 +68,7 @@ function StatusFadeIn(props: { }) { const { animate } = props; const [opacity] = useState(() => new Animated.Value(0)); - + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- runs the Animated opacity fade-in and stops it on cleanup useEffect(() => { if (!animate) return; const animation = Animated.timing(opacity, { diff --git a/apps/mobile/src/features/home/home-list-options.test.tsx b/apps/mobile/src/features/home/home-list-options.test.tsx new file mode 100644 index 0000000000000..f25d4ff690851 --- /dev/null +++ b/apps/mobile/src/features/home/home-list-options.test.tsx @@ -0,0 +1,49 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ saved: [] as unknown[] })); + +vi.mock("../../state/preferences", () => ({ updateMobilePreferencesAtom: {} })); +vi.mock("@effect/atom-react", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomSet: () => (value: unknown) => harness.saved.push(value), +})); + +import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options"; + +const ENVIRONMENT = "env-1" as EnvironmentId; +const AVAILABLE = new Set([ENVIRONMENT]); + +it("saves the scope from every update made in one event", async () => { + harness.saved = []; + let controls: ReturnType | undefined; + function Probe() { + const result = useHomeListOptions(AVAILABLE); + useLayoutEffect(() => { + controls = result; + }); + return null; + } + + const root = createRoot(document.createElement("div")); + await act(async () => + root.render( + + + , + ), + ); + act(() => { + controls!.setSelectedEnvironmentId(ENVIRONMENT); + controls!.setSelectedProjectKey("env-1:project-1"); + }); + + expect(harness.saved.at(-1)).toEqual({ + threadListFilters: { environmentId: ENVIRONMENT, projectKey: "env-1:project-1" }, + }); + expect(controls!.options.selectedEnvironmentId).toBe(ENVIRONMENT); + expect(controls!.options.selectedProjectKey).toBe("env-1:project-1"); + act(() => root.unmount()); +}); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 9ee8cadf67602..09c8be2c9a7c1 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -1,17 +1,8 @@ import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts"; -import { useAtomSet } from "@effect/atom-react"; -import { - createContext, - createElement, - useContext, - useEffect, - useRef, - useState, - type PropsWithChildren, - type Dispatch, - type SetStateAction, -} from "react"; +import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; +import { createContext, createElement, useContext, useState, type PropsWithChildren } from "react"; import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { updateMobilePreferencesAtom } from "../../state/preferences"; @@ -40,7 +31,7 @@ function defaultHomeListOptions(filters?: ThreadListFilters): HomeListOptions { interface HomeListOptionsContextValue { readonly options: HomeListOptions; - readonly setOptions: Dispatch>; + readonly updateOptions: (patch: Partial) => void; readonly projectGroupingMode: SidebarProjectGroupingMode; } @@ -54,20 +45,29 @@ export function HomeListOptionsProvider({ readonly projectGroupingMode: SidebarProjectGroupingMode; readonly savedFilters?: ThreadListFilters | undefined; }>) { - const [options, setOptions] = useState(() => defaultHomeListOptions(savedFilters)); + const registry = useContext(RegistryContext); + const [optionsAtom] = useState(() => Atom.make(defaultHomeListOptions(savedFilters))); + const options = useAtomValue(optionsAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const saved = useRef(savedFilters); - const { selectedEnvironmentId: environmentId, selectedProjectKey: projectKey } = options; - useEffect(() => { + // Only the scope persists; the sort order is per session. Updates read the + // registry, so several in one event each build on the last and save it. + const updateOptions = (patch: Partial) => { + const current = registry.get(optionsAtom); + const next = { ...current, ...patch }; + registry.set(optionsAtom, next); if ( - (saved.current?.environmentId ?? null) === environmentId && - (saved.current?.projectKey ?? null) === projectKey - ) - return; - saved.current = { environmentId, projectKey }; - savePreferences({ threadListFilters: saved.current }); - }, [environmentId, projectKey, savePreferences]); - const value = { options, setOptions, projectGroupingMode }; + next.selectedEnvironmentId !== current.selectedEnvironmentId || + next.selectedProjectKey !== current.selectedProjectKey + ) { + savePreferences({ + threadListFilters: { + environmentId: next.selectedEnvironmentId, + projectKey: next.selectedProjectKey, + }, + }); + } + }; + const value = { options, updateOptions, projectGroupingMode }; return createElement(HomeListOptionsContext, { value }, children); } @@ -75,7 +75,9 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet(defaultHomeListOptions); const options = shared?.options ?? localOptions; - const setOptions = shared?.setOptions ?? setLocalOptions; + const updateOptions = + shared?.updateOptions ?? + ((patch: Partial) => setLocalOptions((current) => ({ ...current, ...patch }))); const selectedEnvironmentId = options.selectedEnvironmentId !== null && availableEnvironmentIds.has(options.selectedEnvironmentId) @@ -91,13 +93,13 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { - setOptions((current) => ({ ...current, selectedEnvironmentId: value })); + updateOptions({ selectedEnvironmentId: value }); }; const setSelectedProjectKey = (value: string | null) => { - setOptions((current) => ({ ...current, selectedProjectKey: value })); + updateOptions({ selectedProjectKey: value }); }; const setProjectSortOrder = (value: HomeProjectSortOrder) => { - setOptions((current) => ({ ...current, projectSortOrder: value })); + updateOptions({ projectSortOrder: value }); }; return { options: resolvedOptions, diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts index 98f53a6c3aec0..5413ea1b1b0d4 100644 --- a/apps/mobile/src/features/home/home-route-visibility.ts +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -1,41 +1,59 @@ import { RegistryContext } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; -import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useContext, useRef, useSyncExternalStore } from "react"; import type { Atom } from "effect/reactivity"; -/** Fabric never freezes the route below focus; Home must stop its own work beneath Thread. */ +/** + * Home stays mounted under a pushed Thread, and native-stack never freezes the + * route directly below the focused one on Fabric, so Home has to stop its own + * work. The Home screen's static `listeners` (Stack.tsx) drive this store. + */ +let homeVisible: boolean | null = null; +const visibilityListeners = new Set<() => void>(); + +function setHomeVisible(next: boolean) { + if (next === homeVisible) return; + homeVisible = next; + for (const listener of visibilityListeners) listener(); +} + +function subscribeHomeVisibility(onChange: () => void) { + visibilityListeners.add(onChange); + return () => { + visibilityListeners.delete(onChange); + // The value describes one Home instance. A later Home can mount under a + // Thread with no event of its own (Android activity replacement, a cold + // deep link), so it must fall back to `isFocused()` instead of this one. + if (visibilityListeners.size === 0) homeVisible = null; + }; +} + +export function recordHomeTransitionStart(closing: boolean): void { + if (!closing) setHomeVisible(true); +} + +export function recordHomeTransitionEnd(closing: boolean): void { + if (closing) setHomeVisible(false); +} + +/** Popping a sheet or a deep-linked route above Home has no appear event. */ +export function recordHomeFocus(): void { + setHomeVisible(true); +} + export function useHomeRouteVisible(): boolean { - const navigation = - useNavigation>(); - // A cold deep link mounts Home under its Thread without ever showing it. - const [visible, setVisible] = useState(() => navigation.isFocused()); - useEffect(() => { - const show = () => setVisible(true); - const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { - if (!data.closing) show(); - }); - const removeTransitionEnd = navigation.addListener("transitionEnd", ({ data }) => { - if (data.closing) setVisible(false); - }); - // Popping a sheet or a deep-linked route above Home has no appear event. - const removeFocus = navigation.addListener("focus", show); - return () => { - removeTransitionStart(); - removeTransitionEnd(); - removeFocus(); - }; - }, [navigation]); - return visible; + const navigation = useNavigation(); + const visible = useSyncExternalStore(subscribeHomeVisibility, () => homeVisible); + // Before Home's first navigation event: a cold deep link mounts Home under + // its Thread without ever showing it. + return visible ?? navigation.isFocused(); } export function useAtomValueWhileVisible(atom: Atom.Atom, visible: boolean): A { const registry = useContext(RegistryContext); const held = useRef<{ readonly value: A } | null>(null); - const subscribe = useCallback( - (onChange: () => void) => (visible ? registry.subscribe(atom, onChange) : () => undefined), - [registry, atom, visible], - ); + const subscribe = (onChange: () => void) => + visible ? registry.subscribe(atom, onChange) : () => undefined; return useSyncExternalStore(subscribe, () => { if (visible || held.current === null) held.current = { value: registry.get(atom) }; return held.current.value; diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 0ad078f73b588..a6c48086f2c85 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -50,8 +50,8 @@ const ACTION_ICON_SIZE = 15; const COMPACT_ACTION_CIRCLE_SIZE = 28; const COMPACT_ACTION_ICON_SIZE = 13; -export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; -export const THREAD_SWIPE_SPRING = { +const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; +const THREAD_SWIPE_SPRING = { damping: 26, mass: 0.7, overshootClamping: true, @@ -164,6 +164,7 @@ export function useSwipeableScrollGate(options?: { settleTimerRef.current = null; } }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the scroll settle timer on unmount useEffect(() => clearSettle, [clearSettle]); const onScrollBeginDrag = useCallback( @@ -289,6 +290,7 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { pending.resolve(); }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the row's Reanimated animations and settles a pending dismissal on unmount useLayoutEffect(() => { mountedRef.current = true; return () => { @@ -302,6 +304,7 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]); // Reset recycled rows in place so their child views stay mounted. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resets the swipeable and its shared values when the list recycles this row for new content useLayoutEffect(() => { if (contentRef.current === content) return; contentRef.current = content; @@ -309,6 +312,7 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { restoreRow(); }, [content, finishDismiss, restoreRow]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes the open swipeable and resets its shared values when the row goes dormant useLayoutEffect(() => { if (props.dormant && activeTranslationRef.current && dismissalRef.current === null) { restoreRow(); @@ -339,6 +343,7 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { }, [actionOpacity, collapse, dismissing, finishDismiss, rowWidth], ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers this row's animated dismissal in the module-level thread dismissal registry useLayoutEffect( () => props.dormant @@ -711,7 +716,7 @@ function SwipeActionButton(props: { ); } -export function ThreadSwipeActions(props: { +function ThreadSwipeActions(props: { readonly backgroundColor: ColorValue; readonly compact: boolean; readonly fullSwipeAction?: "delete" | "primary"; diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 9a59367a9543a..ec0f6a14558dd 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -114,7 +114,10 @@ vi.mock("../../state/thread-order", () => ({ }, })); vi.mock("../../state/threads", () => ({ - environmentThreadShells: { threadShellsAtom: "thread-shells" }, + environmentThreadShells: { + threadShellsAtom: "thread-shells", + navigationThreadShellsAtom: "thread-shells", + }, threadEnvironment: Object.fromEntries( [ "archive", diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index ddbbf9ff343c5..cac37f0552802 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -2,7 +2,7 @@ import type { ThreadMoveDestination } from "../threads/threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo"; import { showThreadUndoNotice } from "./thread-undo-notice"; -import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; @@ -33,33 +33,8 @@ import { getThreadListV2OrderedSection } from "../threads/threadListV2"; import { threadCanArchive } from "./threadArchive"; import { resolveThreadTitleRename } from "../threads/thread-title-rename"; -/** Older servers default the settlement capability to false; never send them settle/unsettle. */ -function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadSettlement === true - ); -} - -function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadSnooze === true - ); -} - -function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadPinning === true - ); -} - -function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadPinReorder === true - ); +function environmentConfigLoaded(environmentId: EnvironmentThreadShell["environmentId"]) { + return appAtomRegistry.get(environmentServerConfigsAtom).has(environmentId); } function environmentSupportsAutoSettleOptOut( @@ -71,15 +46,6 @@ function environmentSupportsAutoSettleOptOut( ); } -function environmentSupportsTitleRegeneration( - environmentId: EnvironmentThreadShell["environmentId"], -) { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .threadTitleRegeneration === true - ); -} - type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -142,7 +108,7 @@ function useThreadActionExecutor( const succeeded = await (async () => { if ( (action === "settle" || action === "unsettle") && - !environmentSupportsSettlement(thread.environmentId) + !environmentConfigLoaded(thread.environmentId) ) { Dialog.alert( actionFailureTitle(action), @@ -331,7 +297,7 @@ export function useThreadListActions(): { } snoozeInFlightThreadKeys.current.add(key); const succeeded = await (async () => { - if (!environmentSupportsSnooze(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not snooze thread", "This environment's server does not support snoozing yet. Update the server to use Snooze.", @@ -389,7 +355,7 @@ export function useThreadListActions(): { } snoozeInFlightThreadKeys.current.add(key); const succeeded = await (async () => { - if (!environmentSupportsSnooze(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not wake thread", "This environment's server does not support snoozing yet. Update the server to wake this thread.", @@ -427,7 +393,7 @@ export function useThreadListActions(): { (await executeAction("unsettle", thread)) === true; const pinThread = async (thread: EnvironmentThreadShell, restoreOrderKey?: string) => { if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false; - if (!environmentSupportsPinning(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not pin thread", "This environment's server does not support pinning yet. Update the server to use Pin.", @@ -435,19 +401,13 @@ export function useThreadListActions(): { return false; } selectionHaptic(); - let orderKey: string | undefined; - if (environmentSupportsPinReorder(thread.environmentId)) { - const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); - let firstKey: string | null = null; - for (const shell of shells) { - if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; - if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; - } - orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined; + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + let firstKey: string | null = null; + for (const shell of shells) { + if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; + if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; } - orderKey = environmentSupportsPinReorder(thread.environmentId) - ? (restoreOrderKey ?? orderKey) - : undefined; + const orderKey = restoreOrderKey ?? pinOrderKeyBetween(null, firstKey) ?? undefined; ThreadUndo.invalidate("pin", scopedThreadKey(thread.environmentId, thread.id)); // Pinning clears snooze, so older Settled or Snoozed undos would fight this pin. ThreadUndo.invalidate("settle", scopedThreadKey(thread.environmentId, thread.id)); @@ -475,7 +435,7 @@ export function useThreadListActions(): { }; const unpinThread = async (thread: EnvironmentThreadShell) => { if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false; - if (!environmentSupportsPinning(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not unpin thread", "This environment's server does not support pinning yet. Update the server to use Pin.", @@ -539,7 +499,7 @@ export function useThreadListActions(): { if (thread.titleRegeneration != null || titleRegenerationInFlightThreadKeys.current.has(key)) { return false; } - if (!environmentSupportsTitleRegeneration(thread.environmentId)) { + if (!environmentConfigLoaded(thread.environmentId)) { Dialog.alert( "Could not regenerate title", "This environment's server does not support title regeneration yet. Update the server to regenerate thread titles.", @@ -611,7 +571,7 @@ export function useThreadListActions(): { const moveThread = async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; - const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const shells = appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom); const current = shells.find( (row) => row.id === thread.id && row.environmentId === thread.environmentId, ); @@ -624,20 +584,14 @@ export function useThreadListActions(): { ? "pinned" : "active"; if (section === "settled") { - if (!environmentSupportsSettlement(thread.environmentId)) return false; + if (!environmentConfigLoaded(thread.environmentId)) return false; appAtomRegistry.set(threadDropBusyAtom, true); const settled = await settleThread(thread); appAtomRegistry.set(threadDropBusyAtom, false); return settled; } const configs = appAtomRegistry.get(environmentServerConfigsAtom); - const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { - const capabilities = configs.get(environmentId)?.environment.capabilities; - return section === "pinned" - ? capabilities?.threadPinReorder === true - : capabilities?.threadActiveReorder === true; - }; - if (!supportsReorder(thread.environmentId)) { + if (!configs.has(thread.environmentId)) { Dialog.alert( "Could not move thread", "This environment's server does not support reordering these threads. Update the server to arrange them.", @@ -649,38 +603,20 @@ export function useThreadListActions(): { section, now: new Date().toISOString(), queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), - settlementEnvironmentIds: new Set( - [...configs].flatMap(([id, config]) => - config.environment.capabilities.threadSettlement === true ? [id] : [], - ), - ), - snoozeEnvironmentIds: new Set( - [...configs].flatMap(([id, config]) => - config.environment.capabilities.threadSnooze === true ? [id] : [], - ), - ), + settlementEnvironmentIds: new Set(configs.keys()), + snoozeEnvironmentIds: new Set(configs.keys()), }); const assignments = createThreadMovePlanner({ allThreads: shells, ordered, section, - reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), + reorderableEnvironmentIds: new Set(configs.keys()), })(scopedThreadKey(thread.environmentId, thread.id), direction); if (assignments === null) return false; const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); const crossSection = !ordered.some( (row) => row.id === thread.id && row.environmentId === thread.environmentId, ); - if ( - crossSection && - (((section === "pinned" || thread.pinnedAt != null) && - !environmentSupportsPinning(thread.environmentId)) || - (thread.settledOverride === "settled" && - !environmentSupportsSettlement(thread.environmentId)) || - (effectiveSnoozed(thread, { now: new Date().toISOString() }) && - !environmentSupportsSnooze(thread.environmentId))) - ) - return false; const shellByKey = new Map( shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), ); diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 7e7fea4527b8d..88987819ddf00 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { uuidv4 } from "../../lib/uuid"; import { useNavigation } from "@react-navigation/native"; import { @@ -16,7 +17,7 @@ import { pickedQuery, threadRecentUse, } from "@t3tools/shared/paletteSearch"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { FlatList, KeyboardAvoidingView, @@ -41,15 +42,25 @@ import { deriveBottomControlInsets } from "../../lib/reserved-regions"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; -import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; +import { + useProjects, + useServerConfigs, + useThreadShell, + useThreadShells, +} from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { requestThreadMessageJump } from "../threads/threadMessageJump"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; +import { serverEnvironment } from "../../state/server"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match"; +import { pickTriageEnvironment } from "../triage/triageTarget"; +import { useStartTriage } from "../triage/useStartTriage"; +import { useNewTaskFlow } from "../threads/new-task-flow-provider"; +import { useActiveNewTask } from "../../state/active-new-task"; import { filterCommandPaletteItems, nextPaletteIndex, @@ -216,7 +227,7 @@ export function CommandPalette(props: { const runCommand = props.onCommand; const projects = useProjects(); const threads = useThreadShells(); - const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]); + const activeThreadRef = parseActiveThreadPath(props.pathname); const activeThread = useThreadShell(activeThreadRef); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const environments = useWorkspaceEnvironments(); @@ -256,14 +267,26 @@ export function CommandPalette(props: { query.startsWith(">") ? "" : query, environmentLabels === undefined ? {} : { environmentLabels }, ); - const contentMatchByKey = useMemo( - () => - new Map( - search.matches - .filter((match) => match.source === "user" || match.source === "assistant") - .map((match) => [scopedThreadKey(match.environmentId, match.threadId), match]), - ), - [search.matches], + const serverConfigs = useServerConfigs(); + const startTriage = useStartTriage(); + const newTaskFlow = useNewTaskFlow(); + const draftEnvironmentId = + useActiveNewTask() === null ? null : (newTaskFlow.selectedProject?.environmentId ?? null); + const triageEnvironmentId = pickTriageEnvironment({ + // The route names the machine even before the thread shell loads. + onScreen: activeThreadRef?.environmentId ?? draftEnvironmentId, + connected: searchEnvironmentIds, + supportsTriage: (environmentId) => + serverConfigs.get(environmentId)?.environment.capabilities.triage === true, + }); + const canTriage = + useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) && + triageEnvironmentId !== null && + serverConfigs.get(triageEnvironmentId)?.environment.capabilities.triage === true; + const contentMatchByKey = new Map( + search.matches + .filter((match) => match.source === "user" || match.source === "assistant") + .map((match) => [scopedThreadKey(match.environmentId, match.threadId), match]), ); const items = useMemo(() => { const actions: CommandPaletteItem[] = [ @@ -324,6 +347,17 @@ export function CommandPalette(props: { run: () => openSettings(navigation, "SettingsArchive"), }, ]; + if (canTriage) { + actions.push({ + key: "triage", + kind: "action", + title: "Triage an issue", + // Mobile has no triage hero, so name the machine before it starts. + detail: `On ${savedConnectionsById[triageEnvironmentId]?.environmentLabel ?? "this machine"}`, + searchTerms: ["triage", "bug", "issue", "report", "broken", "debug", "help", "support"], + run: () => void startTriage(triageEnvironmentId), + }); + } const projectByKey = new Map( projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]), ); @@ -355,7 +389,7 @@ export function CommandPalette(props: { ["files", "Go to file", ["open", "files", "browse", "search"]], ["terminal", "Open terminal", ["shell", "console"]], ["review", "Review changes", ["diff", "git", "pull request"]], - ["copyThreadReference", "Copy PR link or thread ID", ["reference", "clipboard"]], + ["copyThreadReference", "Copy PR link or thread", ["reference", "clipboard"]], ] as const; actions.push( ...threadActions.map(([command, title, searchTerms]) => ({ @@ -430,12 +464,15 @@ export function CommandPalette(props: { activeThread, activeThreadRef, environments.length, + canTriage, navigation, projects, runCommand, savedConnectionsById, selectDetail, + startTriage, threads, + triageEnvironmentId, ]); const rankedMessageKeys = useMemo(() => { const subjectByKey = new Map( @@ -507,6 +544,7 @@ export function CommandPalette(props: { // The native list follows the highlighted row. Key presses move it, but so do // live thread updates and late message matches that reorder or drop the // selected row under a kept selection, so no single handler owns the scroll. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the native list to the highlighted row, which key presses and live result updates both move useEffect(() => { if (selectedIndex === 0) { // Centering before the list measures its height scrolls half the first row out of view. @@ -518,7 +556,7 @@ export function CommandPalette(props: { const dismissed = useRef(false); const dismissFallback = useRef | null>(null); - const handleDismissed = useCallback(() => { + const handleDismissed = () => { if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); dismissFallback.current = null; if (dismissed.current) return; @@ -526,7 +564,8 @@ export function CommandPalette(props: { // Present navigation sheets only after UIKit has dismissed this modal. props.onClose(); pendingAction.current?.(); - }, [props]); + }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the dismiss fallback timer on unmount useEffect( () => () => { if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 7b62a6af94cae..135715f064924 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -14,7 +14,7 @@ import { type ReactNode, } from "react"; -import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { copyThreadReference } from "../../lib/copyThreadReference"; import { firstWorkspaceDetailOpen, workspaceHasColumns, @@ -62,7 +62,8 @@ export function HardwareKeyboardCommandProvider({ activeThreadRef === null ? null : resolveThreadReferenceCopyTarget({ - threadId: activeThread?.id ?? activeThreadRef.threadId, + threadRef: activeThreadRef, + title: activeThread?.title ?? "", pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, @@ -89,6 +90,7 @@ export function HardwareKeyboardCommandProvider({ setCopyFeedback(EMPTY_COPY_FEEDBACK); }, COPY_FEEDBACK_DISMISS_MS); }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the copy feedback timer on unmount useEffect( () => () => { if (copyFeedbackTimerRef.current !== null) { @@ -133,16 +135,14 @@ export function HardwareKeyboardCommandProvider({ if (command === "copyThreadReference") { if (copyTarget === null) return; const requestId = ++copyRequestIdRef.current; - void tryCopyTextWithHaptic(copyTarget.value, { - target: copyTarget.clipboardTarget, - }).then((didCopy) => { + void copyThreadReference(copyTarget).then((didCopy) => { if (requestId !== copyRequestIdRef.current) return; showCopyFeedback( didCopy ? { phase: "success", label: copyTarget.successTitle, - description: copyTarget.value, + description: copyTarget.description, } : { phase: "error", diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index cabe2949788ed..3525e17828099 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -27,6 +27,7 @@ export function useHardwareKeyboardCommand( command: HardwareKeyboardCommand | ReadonlyArray, handler: CommandHandler, ): void { + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers the handler in the module-level hardware keyboard command registry useEffect(() => { const commands = typeof command === "string" ? [command] : command; for (const command of commands) { diff --git a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts index 3143d164c1781..8d6f131704423 100644 --- a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts +++ b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts @@ -1,6 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; -import { useCallback } from "react"; import type { ThreadListV2ListItem } from "../threads/threadListV2"; import { @@ -31,13 +30,10 @@ export function useThreadJumpShortcuts( items: ReadonlyArray, onSelectThread: (thread: EnvironmentThreadShell) => void, ) { - const jumpToThread = useCallback( - (command: HardwareKeyboardCommand) => { - const thread = threadJumpTarget(items, command); - if (thread !== null) onSelectThread(thread); - return true; - }, - [items, onSelectThread], - ); + const jumpToThread = (command: HardwareKeyboardCommand) => { + const thread = threadJumpTarget(items, command); + if (thread !== null) onSelectThread(thread); + return true; + }; useHardwareKeyboardCommand(THREAD_JUMP_KEYBINDING_COMMANDS, jumpToThread); } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index e2d758ee9606e..c3b46cf8e24c2 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -1,6 +1,11 @@ +import { + WorkspaceColumnsRendererContext, + WorkspacePresentationHostContext, +} from "./workspace-presentation-context"; +import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { AdaptiveWorkspaceContext, useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; -import { createDeferredNavigation } from "./deferred-navigation"; +import { threadSelectionDeferral } from "./deferred-navigation"; import { uuidv4 } from "../../lib/uuid"; import type { EnvironmentProject, @@ -10,16 +15,7 @@ import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3too import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; -import { - use, - useDeferredValue, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; +import { use, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { @@ -45,6 +41,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; import { useProjects } from "../../state/entities"; +import { useThreadCwd } from "../../state/use-selected-thread-worktree"; import { environmentThreadShells } from "../../state/threads"; import { mobilePreferencesAtom } from "../../state/preferences"; import { @@ -55,9 +52,13 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; +import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; +import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { AndroidWorkspaceNavigation, + FloatingWorkspaceTabs, ANDROID_WORKSPACE_RAIL_WIDTH, } from "../workspace/AndroidWorkspaceNavigation"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; @@ -95,9 +96,13 @@ import { currentWorkspaceSelections, workspaceSelectionCheck, } from "../workspace/workspace-selection-store"; -import { workspaceTabNavigationState } from "../workspace/workspace-tab-navigation"; +import { + workspaceTabColumns, + workspaceTabNavigationState, +} from "../workspace/workspace-tab-navigation"; import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; +import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; export { useAdaptiveWorkspaceLayout }; @@ -124,39 +129,11 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi ); })(); - const focusedRef = useRef(false); - const deactivateRef = useRef<(() => void) | null>(null); - - const syncRegistration = useEffectEvent(() => { - if (!focusedRef.current || wrappedRender === undefined) { - deactivateRef.current?.(); - return; - } - deactivateRef.current = registerWorkspaceInspector(wrappedRender); - }); - - useFocusEffect(() => { - focusedRef.current = true; - syncRegistration(); - return () => { - focusedRef.current = false; - syncRegistration(); - }; - }); - - useEffect(() => { - if (focusedRef.current) { - syncRegistration(); - } - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. - }, [wrappedRender]); - - useEffect( - () => () => { - deactivateRef.current?.(); - deactivateRef.current = null; - }, - [], + // useFocusEffect re-runs when the renderer changes while focused, so the + // pane always shows this render's content, and its cleanup deactivates on + // blur and unmount. + useFocusEffect(() => + wrappedRender === undefined ? undefined : registerWorkspaceInspector(wrappedRender), ); } @@ -164,6 +141,8 @@ export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; readonly workspaceRouteKey: string | undefined; + readonly workspaceRouteName: string | undefined; + readonly workspaceRouteKeys: ReadonlyArray; readonly selectedPullRequestKey?: string | null; }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); @@ -190,14 +169,18 @@ function AdaptiveWorkspaceLayoutContent( readonly children: ReactNode; readonly pathname: string; readonly workspaceRouteKey: string | undefined; + readonly workspaceRouteName: string | undefined; + readonly workspaceRouteKeys: ReadonlyArray; readonly selectedPullRequestKey?: string | null; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; readonly savedThreadListFilters?: ThreadListFilters | undefined; }, ) { + const floatingTabsEnabled = useAndroidFloatingTabs(); const projectGroupingMode = props.projectGroupingMode; const nativeWorkspace = use(NativeWorkspaceModeContext); + const usesPresentationHost = use(WorkspacePresentationHostContext); const windowDimensions = useWindowDimensions(); const nativeMetrics = useNativeLayoutMetrics(); const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; @@ -272,7 +255,11 @@ function AdaptiveWorkspaceLayoutContent( const showPrimarySidebar = !workspacePage && (primarySidebarPinned || (preferredSidebarVisible && !composerKeyboardActive)); - const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView; + const shouldRenderNavigationRail = + Platform.OS === "android" && baseLayout.usesSplitView && !floatingTabsEnabled; + const enabledTabs = useEnabledNavigationTabs(); + const { scale, fabSize } = useAndroidControlSizing(); + const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize); const navigationRailWidth = shouldRenderNavigationRail && (showPrimarySidebar || workspacePage) ? ANDROID_WORKSPACE_RAIL_WIDTH @@ -283,16 +270,31 @@ function AdaptiveWorkspaceLayoutContent( setHinge(next); setWindowHinge(nextWindow); }; - const layout = useMemo( - () => - alignListPaneToHinge({ - layout: baseLayout, - hinge, - viewportWidth, - leadingWidth: navigationRailWidth, - }), - [baseLayout, hinge, navigationRailWidth, viewportWidth], - ); + const layout = useMemo(() => { + const aligned = alignListPaneToHinge({ + layout: baseLayout, + hinge, + viewportWidth, + leadingWidth: navigationRailWidth, + }); + if ( + !floatingTabsEnabled || + !aligned.usesSplitView || + aligned.listPaneGap !== undefined || + aligned.listPaneWidth === null || + aligned.listPaneWidth >= floatingTabs.row || + viewportWidth - floatingTabs.row < 320 + ) + return aligned; + return { ...aligned, listPaneWidth: floatingTabs.row }; + }, [ + baseLayout, + hinge, + navigationRailWidth, + viewportWidth, + floatingTabs.row, + floatingTabsEnabled, + ]); const width = viewportWidth - navigationRailWidth; // The sidebar stays mounted under a workspace page, so its list is ready on the way back. const shouldRenderPrimarySidebar = layout.usesSplitView; @@ -344,16 +346,52 @@ function AdaptiveWorkspaceLayoutContent( const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; - const selectedThreadKey = (() => { + const selectedThreadRef = (() => { if (environmentId === null || threadId === null) { return null; } try { - return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)); + return { + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), + }; } catch { return null; } })(); + const selectedThreadKey = + selectedThreadRef === null + ? null + : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId); + const isFilesRoute = props.workspaceRouteName === "ThreadFiles"; + // Same resolution the Files screen uses: without a cwd it shows "Files unavailable" and + // registers no inspector, so revealing would only hide the sidebar beside that screen. + const filesCwd = useThreadCwd(isFilesRoute && fileInspector.supported ? selectedThreadRef : null); + // Opening a review or the files route reveals its inspector once per route and thread. + // Routes still on the stack remember their reveal, so going back to one keeps the user's choice. + const revealsInspector = + props.workspaceRouteName === "ThreadReview" || (isFilesRoute && filesCwd !== null); + const [revealedInspectors, setRevealedInspectors] = useState>( + () => new Map(), + ); + if ( + revealsInspector && + props.workspaceRouteKey !== undefined && + selectedThreadKey !== null && + revealedInspectors.get(props.workspaceRouteKey) !== selectedThreadKey + ) { + const next = new Map(); + for (const routeKey of props.workspaceRouteKeys) { + const revealedThreadKey = revealedInspectors.get(routeKey); + if (revealedThreadKey !== undefined) { + next.set(routeKey, revealedThreadKey); + } + } + next.set(props.workspaceRouteKey, selectedThreadKey); + setRevealedInspectors(next); + setFocusedAuxiliaryPaneRole("inspector"); + setFileInspectorPreferredVisible(true); + } const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; readonly active: boolean; @@ -478,7 +516,7 @@ function AdaptiveWorkspaceLayoutContent( tab, currentWorkspaceSelections(), { - columns: Platform.OS === "ios" && (options?.columns ?? layout.usesSplitView), + columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), check: workspaceSelectionCheck, overlays, }, @@ -491,10 +529,9 @@ function AdaptiveWorkspaceLayoutContent( }; const handleOpenSettings = () => openWorkspaceTab("settings"); - const deferredThreadSelection = useMemo(() => createDeferredNavigation(), []); /** The one way to show a page in the detail column; see `selectWorkspaceDetail`. */ function handleSelectDetail(detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) { - deferredThreadSelection.run(() => { + threadSelectionDeferral.run(() => { if (Platform.OS === "android") Keyboard.dismiss(); setFileInspectorPreferredVisible(false); if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false); @@ -524,24 +561,27 @@ function AdaptiveWorkspaceLayoutContent( : undefined; const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; - const thread = appAtomRegistry - .get(environmentThreadShells.navigationThreadShellsAtom) - .find( - (candidate) => - candidate.environmentId === first(params?.environmentId) && - candidate.id === first(params?.threadId), - ); - const draft = params?.draft; - const draftEnvironmentId = first(draft?.environmentId); - const draftProjectId = first(draft?.projectId); - const currentProject = thread - ? { environmentId: thread.environmentId, id: thread.projectId } - : projects.find( - (project) => - project.environmentId === draftEnvironmentId && project.id === draftProjectId, - ); + const environmentId = first(params?.environmentId); + const threadId = first(params?.threadId); + // The full index, not the sidebar list: subagent and archived threads are current threads too. + const projectId = + environmentId && threadId + ? appAtomRegistry + .get( + environmentThreadShells.environmentThreadIndexAtom(EnvironmentId.make(environmentId)), + ) + .get(ThreadId.make(threadId))?.projectId + : undefined; + const preferences = appAtomRegistry.get(mobilePreferencesAtom); + const saved = AsyncResult.isSuccess(preferences) ? preferences.value : undefined; handleSelectThreadRoute( - newTaskDestination(layout.usesSplitView, projects, currentProject, uuidv4()), + newTaskDestination({ + projects, + requestId: uuidv4(), + useLastProject: saved?.useLastProjectForNewThreads, + currentProject: environmentId && projectId ? { environmentId, projectId } : undefined, + lastProject: saved?.lastThreadProject, + }), ); }; @@ -586,8 +626,10 @@ function AdaptiveWorkspaceLayoutContent( workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible ? (panes.auxiliaryPaneWidth ?? 0) : 0; + // Android's split content is a rounded card: keep it off the screen edge like the list card. + const contentEndGap = Platform.OS === "android" && layout.usesSplitView ? 4 : 0; const contentTargetWidth = layout.usesSplitView - ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) + ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth - contentEndGap) : null; const [listPaneSizing, setListPaneSizing] = useState({ viewportWidth, @@ -679,6 +721,7 @@ function AdaptiveWorkspaceLayoutContent( ); const renderedRailWidth = useSharedValue(navigationRailWidth); const leavingWorkspacePage = useRef(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated rail and sidebar width shared values toward the layout's targets useEffect(() => { if (nativeWorkspace) return; renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); @@ -723,7 +766,10 @@ function AdaptiveWorkspaceLayoutContent( })); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // Fading a UIKit glass ancestor can leave remounted header effects missing. - opacity: Platform.OS === "ios" ? 1 : Math.min(1, renderedSidebarWidth.value / 80), + opacity: + Platform.OS === "ios" || floatingTabsEnabled + ? 1 + : Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, })); @@ -770,7 +816,6 @@ function AdaptiveWorkspaceLayoutContent( fileInspector, primarySidebarSearchQuery, startNewTask: handleStartNewTask, - deferThreadSelectionUntil: deferredThreadSelection.waitFor, selectDetail: handleSelectDetail, activateAuxiliaryPaneRole, registerWorkspaceInspector, @@ -802,145 +847,186 @@ function AdaptiveWorkspaceLayoutContent( ); } - return ( - ( + - - - - - {shouldRenderNavigationRail ? ( - - ) : null} - {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( - + {shouldRenderNavigationRail ? ( + + ) : null} + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( + + + ( + + )} + > + - - ( - - )} - > - - - - - - - - + + + + + + {/* Keep the pill under the content throughout the sidebar's clipping animation. */} + {floatingTabsEnabled && !workspacePage ? ( + ) : null} - + + ) : null} + + + {/* Android holds the content still, so its floating controls keep its width. */} + + - - {/* Android holds the content still, so its floating controls keep its width. */} - - - {/* Compact Settings keeps the bottom navigation under all of its pages. */} - - {props.children} - - {compactAndroidSettings ? ( - - ) : null} - - - - - - {shouldRenderNavigationRail && navigationRailWidth > 0 ? ( - // The rail reserves its slot above and draws over the panes, so it can widen over - // the list in place. It takes the whole workspace only while it is open. - - + {children} + + + {floatingTabsEnabled && + (compactAndroidSettings || (layout.usesSplitView && workspacePage)) ? ( + - - ) : null} - + ) : compactAndroidSettings ? ( + + ) : null} + + + + + + {shouldRenderNavigationRail && navigationRailWidth > 0 ? ( + // The rail reserves its slot above and draws over the panes, so it can widen over + // the list in place. It takes the whole workspace only while it is open. + + + + ) : null} +
+ ); + + return ( + + + + {usesPresentationHost ? ( + + {props.children} + + ) : ( + renderColumns(props.children) + )} diff --git a/apps/mobile/src/features/layout/adaptive-workspace-context.ts b/apps/mobile/src/features/layout/adaptive-workspace-context.ts index 7cfeb3e76fbf4..a22459e6ecdb4 100644 --- a/apps/mobile/src/features/layout/adaptive-workspace-context.ts +++ b/apps/mobile/src/features/layout/adaptive-workspace-context.ts @@ -20,7 +20,6 @@ interface AdaptiveWorkspaceContextValue { readonly fileInspector: FileInspectorPaneLayout; readonly primarySidebarSearchQuery: string; readonly startNewTask: () => void; - readonly deferThreadSelectionUntil: (operation: Promise) => void; /** Use `useSelectWorkspaceDetail`, which also works outside the layout. */ readonly selectDetail?: (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => void; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; @@ -67,7 +66,6 @@ export const AdaptiveWorkspaceContext = createContext undefined, - deferThreadSelectionUntil: () => undefined, activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx new file mode 100644 index 0000000000000..765b23243798b --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx @@ -0,0 +1,152 @@ +import { + createNavigatorFactory, + StackRouter, + useNavigationBuilder, + type NavigatorTypeBagBase, + type ParamListBase, + type StackActionHelpers, + type StackNavigationState, + type StackRouterOptions, + type StaticConfig, + type TypedNavigator, +} from "@react-navigation/native"; +import { + NativeStackView, + type NativeStackNavigationEventMap, + type NativeStackNavigationOptions, + type NativeStackNavigatorProps, + type NativeStackTypeBag, +} from "@react-navigation/native-stack"; +import { use, type ComponentProps } from "react"; + +import { RoutedFormSheetContext } from "../../components/ui/RoutedFormSheetContext"; + +import { projectStackPresentation } from "../../native/workspace-stack-projection"; +import { + WorkspaceColumnsRendererContext, + WorkspacePresentationHostContext, +} from "./workspace-presentation-context"; + +type ViewProps = ComponentProps; +type Descriptor = ViewProps["descriptors"][string]; +type Route = ViewProps["state"]["routes"][number]; + +function WorkspaceStackView(props: ViewProps) { + const renderColumns = use(WorkspaceColumnsRendererContext); + if (!renderColumns) return ; + + const isOverlay = (route: Route) => { + const descriptor = props.descriptors[route.key] ?? props.describe(route, true); + const presentation = descriptor.options.presentation; + return presentation !== undefined && presentation !== "card"; + }; + const projection = projectStackPresentation(props.state, isOverlay); + const workspaceRoutes = projection.workspace; + const visibleRoute = workspaceRoutes.at(-1); + if (!visibleRoute) return ; + + const visibleDescriptor = props.descriptors[visibleRoute.key]!; + const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; + const baseDescriptor: Descriptor = { + ...visibleDescriptor, + options: { headerShown: false, freezeOnBlur: false }, + render: () => + renderColumns( + !isOverlay(route)), + }} + />, + ), + }; + const routes = [baseRoute, ...projection.overlays]; + return ( + + ); +} + +function WorkspaceStackNavigator({ + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + ...rest +}: NativeStackNavigatorProps) { + const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< + StackNavigationState, + StackRouterOptions, + StackActionHelpers, + NativeStackNavigationOptions, + NativeStackNavigationEventMap + >(StackRouter, { + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + }); + return ( + + + [ + key, + descriptor.options.presentation === "formSheet" + ? { + ...descriptor, + render: () => ( + {descriptor.render()} + ), + } + : descriptor, + ]), + )} + navigation={navigation} + /> + + + ); +} + +type WorkspaceTypeBag< + ParamList extends ParamListBase, + NavigatorID extends string | undefined, +> = Omit, "Navigator"> & { + Navigator: typeof WorkspaceStackNavigator; +}; + +export function createWorkspaceStackNavigator< + const ParamList extends ParamListBase, + const NavigatorID extends string | undefined = string | undefined, + const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag, + const Config extends StaticConfig = StaticConfig, +>(config?: Config): TypedNavigator { + return createNavigatorFactory(WorkspaceStackNavigator)(config); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 0b2efd9de240a..e2697f4e4d238 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -19,16 +19,7 @@ import { type NativeStackNavigatorProps, type NativeStackTypeBag, } from "@react-navigation/native-stack"; -import { - Fragment, - use, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ComponentProps, -} from "react"; +import { Fragment, use, useEffect, useRef, useState, type ComponentProps } from "react"; import { StyleSheet, useWindowDimensions, View } from "react-native"; import { Split, type SplitHostCommands } from "react-native-screens"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -106,17 +97,13 @@ function ColumnScreen(props: { descriptor.options, ); const [presented, setPresented] = useState(false); - const primaryColumn = useMemo( - () => - props.primary - ? { - presented, - selectedThreadKey: props.selectedThreadKey ?? null, - selectedPullRequestKey: props.selectedPullRequestKey ?? null, - } - : null, - [props.primary, props.selectedThreadKey, props.selectedPullRequestKey, presented], - ); + const primaryColumn = props.primary + ? { + presented, + selectedThreadKey: props.selectedThreadKey ?? null, + selectedPullRequestKey: props.selectedPullRequestKey ?? null, + } + : null; const key = descriptor.route.key; return ( { - setScreens((current) => ({ - ...current, - routes: current.routes.filter((route) => route.key !== key), - completed: new Set([...current.completed, key]), - })); - if (native) { - const state = props.navigation.getState(); - const action = nativeWorkspacePopAction(state, key); - if (action) props.navigation.dispatch(action); - } - }, - [props.navigation], - ); + const handleNativeDismiss = (key: string, native: boolean) => { + setScreens((current) => ({ + ...current, + routes: current.routes.filter((route) => route.key !== key), + completed: new Set([...current.completed, key]), + })); + if (native) { + const state = props.navigation.getState(); + const action = nativeWorkspacePopAction(state, key); + if (action) props.navigation.dispatch(action); + } + }; const followCollapsedSplit = ( type: "willAppear" | "didAppear" | "willDisappear", key: string, @@ -303,6 +287,7 @@ function WorkspaceColumns( if (transition.pop) props.navigation.dispatch(transition.pop); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the native split host which column to show while collapsed useEffect(() => { if (layout.usesSplitView || collapsedColumn.current.visible === compactColumn) return; collapsedColumn.current = { ...collapsedColumn.current, visible: compactColumn }; @@ -505,6 +490,14 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean const presentation = props.descriptors[route.key]?.options.presentation; return presentation !== undefined && presentation !== "card"; }); + // The root's willDisappear reaches JS frames into the push, so hide the dock in the commit that + // pushes; the native events still own every later change, including back gestures. + const topDetailKey = projection.detail.at(-1)?.key; + const [pushedDetailKey, setPushedDetailKey] = useState(topDetailKey); + if (pushedDetailKey !== topDetailKey) { + setPushedDetailKey(topDetailKey); + if (topDetailKey !== undefined && primaryVisible) setPrimaryVisible(false); + } // Detail-only histories must stay single-stack: placeholder descriptors cannot own sidebar actions or setOptions. const primaryRoute = projection.primary; if (!primaryRoute) return ; diff --git a/apps/mobile/src/features/layout/deferred-navigation.ts b/apps/mobile/src/features/layout/deferred-navigation.ts index 8da68b10b394c..579e1fed0a1cd 100644 --- a/apps/mobile/src/features/layout/deferred-navigation.ts +++ b/apps/mobile/src/features/layout/deferred-navigation.ts @@ -20,3 +20,9 @@ export function createDeferredNavigation() { }, }; } + +/** + * The app's one thread-selection gate: a new-task branch checkout holds + * split-view thread selection until the draft route can be left. + */ +export const threadSelectionDeferral = createDeferredNavigation(); diff --git a/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts b/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts index 6f7627ca677d0..75367dfe5985c 100644 --- a/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts +++ b/apps/mobile/src/features/layout/useWorkspaceSidebarHeaderItem.ts @@ -1,4 +1,3 @@ -import { useMemo } from "react"; import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; import { withNativeGlassHeaderItem } from "./native-glass-header-items"; @@ -11,29 +10,19 @@ export function useWorkspaceSidebarHeaderItem() { ? "Hide thread sidebar" : "Maximize content" : "Show thread sidebar"; - return useMemo( - () => - withNativeGlassHeaderItem({ - axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, - accessibilityLabel: label, - icon: { - name: - panes.primarySidebarVisible && !layout.primarySidebarOverlaysContent - ? "arrow.up.left.and.arrow.down.right" - : "sidebar.left", - type: "sfSymbol" as const, - }, - identifier: "thread-left-sidebar", - label, - onPress: togglePrimarySidebar, - type: "button" as const, - }), - [ - label, - layout.primarySidebarOverlaysContent, - panes.primarySidebarVisible, - togglePrimarySidebar, - usesNativeWorkspaceColumns, - ], - ); + return withNativeGlassHeaderItem({ + axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined, + accessibilityLabel: label, + icon: { + name: + panes.primarySidebarVisible && !layout.primarySidebarOverlaysContent + ? "arrow.up.left.and.arrow.down.right" + : "sidebar.left", + type: "sfSymbol" as const, + }, + identifier: "thread-left-sidebar", + label, + onPress: togglePrimarySidebar, + type: "button" as const, + }); } diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index 68a57a2298099..4c003008d1d62 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -40,6 +40,7 @@ export function WorkspaceInspectorPane(props: { const reduceMotion = useReducedMotion(); const onClosed = props.onClosed; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated inspector progress and width shared values when visibility or width changes useEffect(() => { inspectorProgress.set( withTiming(inspectorVisible ? 1 : 0, WORKSPACE_PANE_TIMING, (finished) => { diff --git a/apps/mobile/src/features/layout/workspace-presentation-context.tsx b/apps/mobile/src/features/layout/workspace-presentation-context.tsx new file mode 100644 index 0000000000000..ec1544dce8213 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-presentation-context.tsx @@ -0,0 +1,7 @@ +import { createContext, type ReactElement, type ReactNode } from "react"; + +/** Android's native presentation stack surrounds all workspace columns. */ +export const WorkspacePresentationHostContext = createContext(false); +export const WorkspaceColumnsRendererContext = createContext< + ((children: ReactNode) => ReactElement) | null +>(null); diff --git a/apps/mobile/src/features/observability/observe.ts b/apps/mobile/src/features/observability/observe.ts new file mode 100644 index 0000000000000..fbbb5e22b8339 --- /dev/null +++ b/apps/mobile/src/features/observability/observe.ts @@ -0,0 +1,63 @@ +import Constants from "expo-constants"; +import { Observe } from "expo-observe"; + +// Importing expo-observe installs its global JS error handler; reports leave only through the relay. +const relayUrl: unknown = Constants.expoConfig?.extra?.relay?.url; +const appVariant: unknown = Constants.expoConfig?.extra?.appVariant; + +export const crashReportingAvailable = typeof relayUrl === "string" && relayUrl.trim() !== ""; + +interface PromiseRejectionTracker { + readonly enablePromiseRejectionTracker?: (options: { + readonly allRejections: boolean; + readonly onUnhandled: (id: number, rejection: unknown) => void; + }) => void; +} + +// React Native tracks unhandled rejections only in development, for LogBox; Observe has no hook. +if (!__DEV__) { + const hermes: PromiseRejectionTracker | undefined = Reflect.get(globalThis, "HermesInternal"); + hermes?.enablePromiseRejectionTracker?.({ + allRejections: true, + onUnhandled: (_id, rejection) => Observe.reportError(rejection), + }); +} + +let latestConfiguration = 0; + +// While sending is off, a dispatch pass drops what it finds, so one runs before turning it back on. +// Best effort: Observe skips the pass while it is backing off from a failed send. +export async function setCrashReportsEnabled(enabled: boolean) { + const configuration = ++latestConfiguration; + const dispatchingEnabled = crashReportingAvailable && enabled; + if (dispatchingEnabled) { + await Observe.dispatchEvents(); + if (configuration !== latestConfiguration) return; + } + Observe.configure({ + dispatchingEnabled, + ...(typeof appVariant === "string" ? { environment: appVariant } : {}), + }); +} + +if (!crashReportingAvailable) { + void setCrashReportsEnabled(false); +} else { + // Observe keeps its last configuration across launches; configuring it off while the setting + // loads would drop the last session's native crash. Imported lazily so the persistence graph + // never runs ahead of the imports `index.ts` orders first. + const changedSinceLoad = () => latestConfiguration !== 0; + void import("../../persistence/imperative") + .then(({ loadPreferences }) => loadPreferences()) + .then( + (preferences) => + changedSinceLoad() + ? undefined + : setCrashReportsEnabled(preferences.crashReportsEnabled !== false), + // Observe keeps the last choice it was given, which stands in for the unreadable one. + (error: unknown) => console.warn("[observe] could not read the crash report setting", error), + ) + .catch((error: unknown) => { + console.warn("[observe] could not apply the crash report setting", error); + }); +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index fe2cb546a3ca2..b251310ef12f9 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -2,25 +2,59 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeModule from "node:module"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { workspaceDetailRouter } from "../workspace/workspace-detail-selection"; +import type { NavigationState } from "@react-navigation/native"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { isValidElement, type ReactNode } from "react"; +import { act, createElement, isValidElement, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ scopes: new Set(), baseDirectory: "", projects: [] as string[], + existingProjects: [] as { + environmentId: string; + id: string; + title: string; + workspaceRoot: string; + }[], + createResult: null as Promise | null, + createNewResult: null as Promise | null, + startCloneResult: null as Promise | null, + connectionStates: {} as Record, + newProjectsRoot: null as string | null, + waitForProject: vi.fn(), + dispatch: vi.fn(), + alert: vi.fn(), + navigationState: undefined as NavigationState | undefined, + environments: [{ environmentId: "environment", environmentLabel: "Environment" }], })); -vi.mock("react", async (importOriginal) => ({ - ...(await importOriginal()), - useCallback: (callback: unknown) => callback, - useMemo: (factory: () => unknown) => factory(), - useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], - useRef: (current: unknown) => ({ current }), - useEffect: () => {}, +function loadRouters() { + const requireNavigation = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), + ); + return requireNavigation("@react-navigation/routers") as { + StackRouter: typeof import("@react-navigation/native").StackRouter; + StackActions: typeof import("@react-navigation/native").StackActions; + CommonActions: typeof import("@react-navigation/native").CommonActions; + }; +} + +vi.mock("../workspace/useSelectWorkspaceDetail", async () => { + const { selectWorkspaceDetail } = await import("../workspace/workspace-detail-selection"); + return { + useSelectWorkspaceDetail: () => (detail: Parameters[0]) => + state.dispatch(selectWorkspaceDetail(detail)), + }; +}); +vi.mock("../../native/StackHeader", () => ({ + nativeHeaderScrollEdgeEffects: () => undefined, })); vi.mock("../../components/ui/NavigationRow", () => ({ NavigationRow: "NavigationRow" })); vi.mock("../../components/ui/Button", () => ({ Button: "Button" })); @@ -30,15 +64,30 @@ vi.mock("../../components/ui/ThemedSwitch", () => ({ ThemedSwitch: "Switch" })); vi.mock("react-native", () => ({ Platform: { OS: "ios" }, ActivityIndicator: "ActivityIndicator", - Alert: { alert: () => {} }, + Alert: { alert: state.alert }, Pressable: "Pressable", ScrollView: "ScrollView", View: "View", })); vi.mock("@react-navigation/native", () => ({ - useNavigation: () => ({ dispatch: () => {} }), + useNavigation: () => ({ + dispatch: state.dispatch, + getState: () => + state.navigationState ?? { + index: 4, + routes: [ + { key: "home", name: "Home" }, + { key: "draft", name: "Thread", params: { draft: {} } }, + { key: "picker", name: "NewTaskProject" }, + { key: "add", name: "AddProject" }, + { key: "local", name: "AddProjectLocal" }, + ], + }, + }), CommonActions: { reset: (input: unknown) => input }, - StackActions: {}, + StackActions: { + push: (name: string, params: unknown) => ({ type: "PUSH", payload: { name, params } }), + }, })); vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }), @@ -56,13 +105,15 @@ vi.mock("../../state/session", () => ({ readEnvironmentScope: (_environmentId: string, scope: string) => state.scopes.has(scope), })); vi.mock("../../state/entities", () => ({ - useProjects: () => [], + useProjects: () => state.existingProjects, + waitForProject: state.waitForProject, useServerConfigs: () => new Map([ [ "environment", { environment: { platform: { os: "linux" }, capabilities: {} }, + newProjectsRoot: state.newProjectsRoot, settings: { addProjectBaseDirectory: state.baseDirectory }, }, ], @@ -71,12 +122,15 @@ vi.mock("../../state/entities", () => ({ vi.mock("../../state/use-remote-environment-registry", () => ({ useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }), useRemoteConnectionStatus: () => ({ - connectedEnvironments: [{ environmentId: "environment", connectionState: "connected" }], + connectedEnvironments: state.environments.map((environment) => ({ + environmentId: environment.environmentId, + connectionState: state.connectionStates[environment.environmentId] ?? "connected", + })), }), useSavedRemoteConnections: () => ({ - savedConnectionsById: { - connection: { environmentId: "environment", environmentLabel: "Environment" }, - }, + savedConnectionsById: Object.fromEntries( + state.environments.map((environment) => [environment.environmentId, environment]), + ), }), })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); @@ -88,49 +142,100 @@ vi.mock("../../state/presentation", () => ({ vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { - cloneRepository: async ({ input }: { input: { destinationPath: string } }) => { + discovery: (input: unknown) => input, + startProjectClone: async ({ input }: { input: { destinationPath: string } }) => { + if (state.startCloneResult) return state.startCloneResult; NodeFS.mkdirSync(input.destinationPath); - return AsyncResult.success({ cwd: input.destinationPath }); + return AsyncResult.success(undefined); }, }, })); vi.mock("../../state/projects", () => ({ projectEnvironment: { - create: async ({ input }: { input: { workspaceRoot: string } }) => { + createNew: () => state.createNewResult, + create: ({ input }: { input: { workspaceRoot: string } }) => { if (!state.scopes.has(AuthOrchestrationOperateScope)) { - return AsyncResult.failure(Cause.fail(new Error("Project creation denied"))); + return Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("Project creation denied"))), + ); } state.projects.push(input.workspaceRoot); - return AsyncResult.success(undefined); + return state.createResult ?? Promise.resolve(AsyncResult.success(undefined)); }, }, })); -import { AddProjectDestinationScreen } from "./AddProjectScreen"; +vi.mock("../../state/use-composer-drafts", () => ({ + waitForComposerDraftsLoaded: async () => {}, + createNewTaskDraft: () => "prompt-draft", + setComposerDraftText: () => {}, +})); + +import { + AddProjectNewScreen, + AddProjectDestinationScreen, + AddProjectLocalFolderScreen, + AddProjectSourceScreen, +} from "./AddProjectScreen"; +import { AddProjectSourceRoute } from "./AddProjectSourceRoute"; -function findAction(node: ReactNode, label: string): (() => unknown) | null { +function findAction( + node: ReactNode, + label: string, + includeDisabled = false, +): (() => unknown) | null { if (Array.isArray(node)) { for (const child of node) { - const action = findAction(child, label); + const action = findAction(child, label, includeDisabled); if (action) return action; } return null; } - if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) { + if ( + !isValidElement<{ + label?: string; + title?: string; + onPress?: () => unknown; + disabled?: boolean; + children?: ReactNode; + }>(node) + ) { return null; } - if (node.props.label === label) return node.props.onPress ?? null; - return findAction(node.props.children, label); + if ((node.props.label ?? node.props.title) === label) { + return node.props.disabled && !includeDisabled ? null : (node.props.onPress ?? null); + } + return findAction(node.props.children, label, includeDisabled); +} + +const mountedRoots: ReturnType[] = []; +afterEach(() => { + for (const root of mountedRoots.splice(0)) act(() => root.unmount()); +}); +function renderScreen(render: () => ReactNode) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + mountedRoots.push(root); + let screen: ReactNode; + function Observer() { + screen = render(); + return null; + } + act(() => root.render(createElement(Observer))); + return screen; } function cloneAction() { const action = findAction( - AddProjectDestinationScreen({ - environmentId: "environment", - remoteUrl: "https://example.com/repo.git", - repositoryName: "repo", - }), + renderScreen(() => + AddProjectDestinationScreen({ + environmentId: "environment", + remoteUrl: "https://example.com/repo.git", + repositoryName: "repo", + }), + ), "Clone project", + true, ); if (!action) throw new Error("Clone action missing"); return action; @@ -143,6 +248,7 @@ describe("clone project permissions", () => { ); state.scopes = new Set([AuthSourceControlWriteScope]); state.projects = []; + state.createResult = null; }); afterEach(async () => { @@ -169,3 +275,484 @@ describe("clone project permissions", () => { }, ); }); + +function mountProjectFlow( + withDraft: boolean, + withChooser = true, + withPullRequest = false, + screenName = "AddProjectLocal", +) { + const { StackRouter, StackActions, CommonActions } = loadRouters(); + const base = StackRouter({}); + const router = { ...base, ...workspaceDetailRouter(base) }; + const options = { + routeNames: [ + "Home", + "Thread", + "PullRequest", + "PullRequestFile", + "NewTaskProject", + "AddProject", + "AddProjectLocal", + "AddProjectNew", + "AddProjectDestination", + ], + routeParamList: {}, + routeGetIdList: {}, + }; + let current = router.getInitialState(options); + const home = current.routes[0]!; + const apply = (action: Parameters[1]) => { + const next = router.getStateForAction(current, action, options); + if (!next || next.stale !== false) throw new Error("Navigation action rejected"); + current = next; + state.navigationState = current; + }; + if (withPullRequest) { + apply(StackActions.push("PullRequest", { number: 123 })); + apply(StackActions.push("PullRequestFile", { path: "file.ts" })); + } + const underlyingRoutes = current.routes; + if (withDraft) { + apply(StackActions.push("Thread", { draft: { projectId: "previous-project" } })); + } + const draftKey = withDraft ? current.routes[current.index]!.key : null; + for (const name of [...(withChooser ? ["NewTaskProject"] : []), "AddProject", screenName]) { + apply(StackActions.push(name)); + } + state.navigationState = current; + state.dispatch.mockImplementation((action) => + apply( + action.type === "PUSH" || action.type === "SELECT_WORKSPACE_DETAIL" + ? action + : CommonActions.reset(action), + ), + ); + return { + home, + draftKey, + underlyingRoutes, + flowRoutes: current.routes.slice(underlyingRoutes.length), + getState: () => current, + back: () => apply(StackActions.pop()), + setParams: (params: object) => apply(CommonActions.setParams(params)), + push: (name: string) => apply(StackActions.push(name)), + }; +} + +describe("add project source environment", () => { + it("blocks local add on a requested unavailable host until reconnect or manual choice", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function SourceObserver() { + screen = AddProjectSourceScreen({ environmentId: "environment" }); + return null; + } + try { + for (const phase of ["available", "offline", "error"] as const) { + state.connectionStates = { environment: phase }; + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + expect(state.dispatch).not.toHaveBeenCalled(); + } + state.connectionStates = {}; + await act(() => root.render(createElement(SourceObserver))); + const reconnectedLocal = findAction(screen, "Local folder"); + if (!reconnectedLocal) throw new Error("Reconnected local folder action missing"); + reconnectedLocal(); + expect(state.dispatch).toHaveBeenLastCalledWith({ + type: "PUSH", + payload: { name: "AddProjectLocal", params: { environmentId: "environment" } }, + }); + state.connectionStates = { environment: "offline" }; + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + const select = findAction(screen, "Another environment"); + if (!select) throw new Error("Connected environment selection missing"); + await act(() => select()); + const local = findAction(screen, "Local folder"); + if (!local) throw new Error("Manually selected local folder action missing"); + local(); + expect(state.dispatch).toHaveBeenLastCalledWith({ + type: "PUSH", + payload: { name: "AddProjectLocal", params: { environmentId: "another-environment" } }, + }); + } finally { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); + + it("keeps a manual host selection when the requested host finishes connecting", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + const navigation = mountProjectFlow(false, true, false, "AddProject"); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + state.connectionStates = { environment: "connecting" }; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function SourceObserver() { + screen = AddProjectSourceScreen({ environmentId: "environment" }); + return null; + } + try { + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + expect(navigation.getState().routes.at(-1)?.name).toBe("AddProject"); + const select = findAction(screen, "Another environment"); + if (!select) throw new Error("Connected environment selection missing"); + await act(() => select()); + state.connectionStates = {}; + await act(() => root.render(createElement(SourceObserver))); + const local = findAction(screen, "Local folder"); + if (!local) throw new Error("Local folder action missing"); + local(); + expect(navigation.getState().routes.at(-1)).toMatchObject({ + name: "AddProjectLocal", + params: { environmentId: "another-environment" }, + }); + } finally { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); + + it("follows warm environment requests while preserving manual selection between requests", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + const navigation = mountProjectFlow(false, true, false, "AddProject"); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let actions: { local: () => unknown; selectEnvironment: () => unknown } | undefined; + function SourceRouteObserver(props: { environmentId?: string | string[] }) { + const route = AddProjectSourceRoute({ route: { params: props } }); + const screen = AddProjectSourceScreen(route.props); + const local = findAction(screen, "Local folder"); + const selectEnvironment = findAction(screen, "Environment"); + if (!local || !selectEnvironment) throw new Error("Source actions missing"); + actions = { local, selectEnvironment }; + return null; + } + const openLocalFolder = (environmentId: string) => { + state.dispatch.mockClear(); + actions!.local(); + expect(navigation.getState().routes.at(-1)).toMatchObject({ + name: "AddProjectLocal", + params: { environmentId }, + }); + navigation.back(); + }; + try { + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "environment" })), + ); + openLocalFolder("environment"); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "another-environment" })), + ); + openLocalFolder("another-environment"); + await act(() => actions!.selectEnvironment()); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: ["another-environment"] })), + ); + openLocalFolder("environment"); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "environment" })), + ); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "another-environment" })), + ); + openLocalFolder("another-environment"); + } finally { + await act(() => root.unmount()); + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); +}); + +describe("local project navigation", () => { + function setup() { + state.navigationState = undefined; + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.baseDirectory = "/tmp/t3-mobile-audit-fixture"; + state.projects = []; + state.dispatch.mockReset(); + state.alert.mockReset(); + state.waitForProject.mockReset(); + const created = Promise.withResolvers>>(); + const projected = Promise.withResolvers(); + state.createResult = created.promise; + state.waitForProject.mockReturnValue(projected.promise); + const submit = findAction( + renderScreen(() => AddProjectLocalFolderScreen({ environmentId: "environment" })), + "Add project", + ); + if (!submit) throw new Error("Add project action missing"); + return { created, projected, submit }; + } + + it("keeps navigation in place and explains a project projection timeout", async () => { + const { created, projected, submit } = setup(); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + projected.resolve(null); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(state.dispatch).not.toHaveBeenCalled(); + expect(state.alert).toHaveBeenCalledExactlyOnceWith( + "Project added", + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + }); + + it("does not replace a reopened flow when the previous submission finishes", async () => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(false); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + navigation.back(); + navigation.push("AddProjectLocal"); + const remaining = navigation.getState(); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + }); + + it("does not replace a warm-linked flow sharing the original route key", async () => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(false); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + const originalKey = navigation.getState().routes.at(-1)!.key; + navigation.setParams({ environmentId: "other-environment" }); + const remaining = navigation.getState(); + expect(remaining.routes.at(-1)!.key).toBe(originalKey); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + }); + + it("does not redirect a reopened flow when an earlier new project finishes", async () => { + const { projected } = setup(); + state.newProjectsRoot = "/projects"; + const created = Promise.withResolvers(); + const waitingForProject = Promise.withResolvers(); + state.createNewResult = created.promise; + state.waitForProject.mockImplementation(() => { + waitingForProject.resolve(); + return projected.promise; + }); + const navigation = mountProjectFlow(false, true, true, "AddProjectNew"); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function NewProjectObserver() { + screen = AddProjectNewScreen({ environmentId: "environment" }); + return null; + } + function enterName(node: ReactNode): boolean { + if (Array.isArray(node)) return node.some(enterName); + if ( + !isValidElement<{ + placeholder?: string; + onChangeText?: (text: string) => void; + children?: ReactNode; + }>(node) + ) + return false; + if (node.props.placeholder === "Project name") { + node.props.onChangeText!("new-project"); + return true; + } + return enterName(node.props.children); + } + try { + await act(() => root.render(createElement(NewProjectObserver))); + await act(() => { + expect(enterName(screen)).toBe(true); + }); + const submit = findAction(screen, "Create project"); + if (!submit) throw new Error("Create project action missing"); + await act(async () => { + submit(); + created.resolve( + AsyncResult.success({ + projectId: "project", + workspaceRoot: "/projects/new-project", + }), + ); + await waitingForProject.promise; + }); + navigation.back(); + navigation.push("AddProjectNew"); + const remaining = navigation.getState(); + await act(async () => { + projected.resolve({ environmentId: "environment", id: "project" }); + await projected.promise; + }); + + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + } finally { + await act(() => root.unmount()); + state.newProjectsRoot = null; + state.createNewResult = null; + } + }); + + it.each([ + { origin: "Thread", withDraft: true, withChooser: true }, + { origin: "NewTaskProject", withDraft: false, withChooser: true }, + { origin: "AddProject", withDraft: false, withChooser: false }, + ])( + "waits for projection then selects the draft from $origin", + async ({ withDraft, withChooser }) => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(withDraft, withChooser, true); + const before = navigation.getState(); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(navigation.getState()).toBe(before); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + expect(state.dispatch).toHaveBeenCalledTimes(1); + expect(navigation.getState().routes.map((route) => route.name)).toEqual(["Home", "Thread"]); + expect(navigation.getState().routes[1]?.params).toEqual({ + draft: { + environmentId: "environment", + projectId: "project", + title: "t3-mobile-audit-fixture", + }, + }); + navigation.back(); + expect(navigation.getState().routes).toEqual([navigation.home]); + }, + ); +}); + +describe("local folder path", () => { + function findPathInput( + node: ReactNode, + ): { value: string; onChangeText: (value: string) => void } | null { + if (Array.isArray(node)) { + for (const child of node) { + const input = findPathInput(child); + if (input) return input; + } + return null; + } + if ( + !isValidElement<{ + value?: string; + onChangeText?: (value: string) => void; + onSubmit?: () => void; + children?: ReactNode; + }>(node) + ) { + return null; + } + const { value, onChangeText, onSubmit } = node.props; + if (typeof value === "string" && onChangeText && onSubmit) return { value, onChangeText }; + return findPathInput(node.props.children); + } + + function mountLocalFolder() { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.baseDirectory = "/work"; + state.connectionStates = {}; + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + const renderedPaths: string[] = []; + let input: ReturnType = null; + function LocalFolderObserver(props: { environmentId?: string }) { + input = findPathInput(AddProjectLocalFolderScreen(props)); + if (input) renderedPaths.push(input.value); + return null; + } + return { + renderedPaths, + get path() { + return input?.value; + }, + type: (value: string) => act(() => input!.onChangeText(value)), + render: (environmentId?: string) => + act(() => root.render(createElement(LocalFolderObserver, { environmentId }))), + [Symbol.asyncDispose]: async () => { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + }, + }; + } + + it("shows the new host's default path on the first render after the host changes", async () => { + await using screen = mountLocalFolder(); + await screen.render("environment"); + await screen.type("/work/edited"); + screen.renderedPaths.length = 0; + + await screen.render("another-environment"); + + expect(screen.renderedPaths).not.toContain("/work/edited"); + expect(screen.path).toBe("~/"); + }); + + it("keeps each host's typed path when switching between hosts", async () => { + await using screen = mountLocalFolder(); + await screen.render("environment"); + await screen.type("/work/a"); + await screen.render("another-environment"); + await screen.type("~/b"); + + await screen.render("environment"); + expect(screen.path).toBe("/work/a"); + await screen.render("another-environment"); + expect(screen.path).toBe("~/b"); + }); + + it("keeps a typed path when the default host drops out and comes back", async () => { + await using screen = mountLocalFolder(); + await screen.render(); + expect(screen.path).toBe("~/"); + await screen.type("~/code/app"); + + state.connectionStates = { "another-environment": "offline" }; + await screen.render(); + expect(screen.path).toBe("/work/"); + + state.connectionStates = {}; + await screen.render(); + expect(screen.path).toBe("~/code/app"); + }); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index ff772a4007159..84271ddd751a4 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,5 @@ +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import * as Dialog from "../../components/ui/Dialog"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { NavigationRow } from "../../components/ui/NavigationRow"; @@ -56,7 +58,6 @@ import { SourceControlProviderKind, } from "@t3tools/contracts"; import { - CommonActions, StackActions, useNavigation, type NavigationState, @@ -86,6 +87,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { ThemedSwitch } from "../../components/ui/ThemedSwitch"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; +import { suggestAppAccessForRemote } from "./suggestAppAccess"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { useRemoteConnectionStatus, @@ -109,7 +111,6 @@ interface EnvironmentOption { readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; - readonly supportsCloneTracking: boolean; } const environmentOptionOrder = Order.mapInput( @@ -291,23 +292,40 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto const environmentId = environment?.environmentId ?? null; const environmentBaseDirectory = environment?.baseDirectory ?? null; const clonePathCaseSensitive = !isWindowsPlatform(environment?.platform ?? ""); - const [pathInput, commitPathInput] = useState(() => - getCloneDestinationPath( - getAddProjectInitialQuery(environmentBaseDirectory), - pinnedDirectoryName, - ), + const seedPath = getCloneDestinationPath( + getAddProjectInitialQuery(environmentBaseDirectory), + pinnedDirectoryName, ); - const previousEnvironmentIdRef = useRef(environmentId); + // Each host keeps the path last typed or browsed for it, and shows its own default until then, + // so switching hosts and back loses nothing. With no host selected (one dropping out briefly), + // the field keeps the last path typed anywhere. + const [paths, setPaths] = useState<{ + readonly byHost: ReadonlyMap; + readonly last: string; + }>(() => ({ byHost: new Map(), last: seedPath })); + const pathInput = + environmentId === null ? paths.last : (paths.byHost.get(environmentId) ?? seedPath); + const commitPathInput = (path: string) => + setPaths((current) => { + const byHost = new Map(current.byHost); + if (environmentId !== null) { + byHost.set(environmentId, path); + } + return { byHost, last: path }; + }); const environmentRuntime = useRemoteEnvironmentRuntime(environmentId); const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { reportFailure: false, reportDefect: false, }); const [browseNavigation] = useState(createBrowseNavigationCoordinator); - const [isBrowseNavigating, setIsBrowseNavigating] = useState(false); + const [navigatingEnvironmentId, setNavigatingEnvironmentId] = useState< + EnvironmentId | null | undefined + >(undefined); + const isBrowseNavigating = navigatingEnvironmentId === environmentId; const setPathInput = (path: string) => { browseNavigation.invalidate(); - setIsBrowseNavigating(false); + setNavigatingEnvironmentId(undefined); commitPathInput(path); }; const navigateToBrowsePath = async (input: { @@ -326,7 +344,7 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto caseSensitive: clonePathCaseSensitive, }) : getCloneDestinationPath(selectedDirectoryPath, pinnedDirectoryName); - setIsBrowseNavigating(true); + setNavigatingEnvironmentId(environmentId); const committed = await browseNavigation.run( async () => { if ( @@ -343,23 +361,12 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto () => commitPathInput(nextPathInput), ); if (committed) { - setIsBrowseNavigating(false); + setNavigatingEnvironmentId(undefined); } return committed; }; - useEffect(() => { - if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { - previousEnvironmentIdRef.current = environmentId; - setPathInput( - getCloneDestinationPath( - getAddProjectInitialQuery(environmentBaseDirectory), - pinnedDirectoryName, - ), - ); - } - }, [environmentBaseDirectory, environmentId, pinnedDirectoryName, setPathInput]); - + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- invalidates the browse navigation coordinator on unmount so in-flight browses do not commit useEffect( () => () => { browseNavigation.invalidate(); @@ -370,7 +377,9 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; } -function useEnvironmentOptions(): ReadonlyArray { +function useEnvironmentOptions( + selectedEnvironmentId: EnvironmentId | null = null, +): ReadonlyArray { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); const { connectedEnvironments } = useRemoteConnectionStatus(); @@ -392,29 +401,33 @@ function useEnvironmentOptions(): ReadonlyArray { connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, - supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true, }; }); return Arr.sort( - options.filter((environment) => canCreateProjectInEnvironment(environment.connectionState)), + options.filter( + (environment) => + canCreateProjectInEnvironment(environment.connectionState) || + environment.environmentId === selectedEnvironmentId, + ), environmentOptionOrder, ); })(); } -function useSelectedEnvironment(): { +function useSelectedEnvironment(requestedEnvironmentId: EnvironmentId | null): { readonly environmentOptions: ReadonlyArray; readonly selectedEnvironment: EnvironmentOption | null; readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; } { - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); - const environmentOptions = useEnvironmentOptions(); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(requestedEnvironmentId); + const [previousRequest, setPreviousRequest] = useState(requestedEnvironmentId); + if (previousRequest !== requestedEnvironmentId) { + setPreviousRequest(requestedEnvironmentId); + setSelectedEnvironmentId(requestedEnvironmentId); + } + const environmentOptions = useEnvironmentOptions(selectedEnvironmentId); const selectedEnvironment = - environmentOptions.find( - (environment) => - environment.environmentId === selectedEnvironmentId && - canCreateProjectInEnvironment(environment.connectionState), - ) ?? + environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? environmentOptions.find((environment) => canCreateProjectInEnvironment(environment.connectionState), ) ?? @@ -501,10 +514,10 @@ function SourceControlRow(props: { ); } -export function AddProjectSourceScreen() { +export function AddProjectSourceScreen(props: { readonly environmentId?: string | string[] }) { const navigation = useNavigation(); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = - useSelectedEnvironment(); + useSelectedEnvironment(stringParam(props.environmentId) as EnvironmentId | null); const canWriteSourceControl = useEnvironmentScope( selectedEnvironment?.environmentId ?? null, AuthSourceControlWriteScope, @@ -513,9 +526,10 @@ export function AddProjectSourceScreen() { selectedEnvironment?.environmentId ?? null, AuthOrchestrationOperateScope, ); - const canCloneProject = canWriteSourceControl && canCreateProject; + const isConnected = canCreateProjectInEnvironment(selectedEnvironment?.connectionState); + const canCloneProject = isConnected && canWriteSourceControl && canCreateProject; const discoveryState = useEnvironmentQuery( - selectedEnvironment === null + selectedEnvironment === null || !isConnected ? null : sourceControlEnvironment.discovery({ environmentId: selectedEnvironment.environmentId, @@ -528,7 +542,7 @@ export function AddProjectSourceScreen() { {selectedEnvironment === null ? : null} - {environmentOptions.length > 1 ? ( + {environmentOptions.length > 1 || (selectedEnvironment !== null && !isConnected) ? ( <> Environments @@ -579,6 +593,7 @@ export function AddProjectSourceScreen() { } isFirst={selectedEnvironment.newProjectsRoot === null} - disabled={!canCreateProject} + disabled={!isConnected || !canCreateProject} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -650,27 +665,37 @@ export function AddProjectSourceScreen() { ); } -function openNewTaskDraft( - navigation: { - dispatch: (action: ReturnType) => void; - getState: () => NavigationState | undefined; - }, - params: { - environmentId: EnvironmentId; - projectId: ProjectId; - title: string; - cloning?: "1"; - draftId?: string; - }, -) { +function getActiveRoute(navigation: { + getState: () => NavigationState | undefined; +}) { const state = navigation.getState(); - if (!state) return; - const threadIndex = state.routes.findLastIndex((route) => route.name === "Thread"); - const routes = [ - ...state.routes.slice(0, threadIndex < 0 ? state.index : threadIndex), - { key: uuidv4(), name: "Thread", params: { draft: params } }, - ]; - navigation.dispatch(CommonActions.reset({ ...state, index: routes.length - 1, routes })); + return state?.routes[state.index]; +} + +function useOpenNewTaskDraft() { + const navigation = useNavigation>(); + const selectDetail = useSelectWorkspaceDetail(); + return ( + params: { + environmentId: EnvironmentId; + projectId: ProjectId; + title: string; + cloning?: "1"; + draftId?: string; + }, + originRoute: NavigationState["routes"][number] | undefined, + ) => { + const activeRoute = getActiveRoute(navigation); + // A warm link can change the task without replacing its native route key. + if ( + !originRoute || + activeRoute?.key !== originRoute.key || + activeRoute.name !== originRoute.name || + activeRoute.params !== originRoute.params + ) + return; + selectDetail({ name: "Thread", params: { draft: params } }); + }; } async function draftWithPrompt( @@ -685,11 +710,12 @@ async function draftWithPrompt( } function useCreateProject(environment: EnvironmentOption | null) { + const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const projects = useProjects(); - return async (workspaceRoot: string) => { + return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { if ( !environment || !canCreateProjectInEnvironment(environment.connectionState) || @@ -709,11 +735,14 @@ function useCreateProject(environment: EnvironmentOption | null) { }); if (existing) { Dialog.alert("Project already exists", existing.title); - openNewTaskDraft(navigation, { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, - }); + openNewTaskDraft( + { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + }, + originRoute, + ); return; } @@ -730,11 +759,27 @@ function useCreateProject(environment: EnvironmentOption | null) { if (AsyncResult.isFailure(result)) { return result; } - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title: inferProjectTitleFromPath(workspaceRoot), - }); + // A successful command can precede its project in the client store. The + // draft treats a missing project as a reason to return to the chooser. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + Dialog.alert( + "Project added", + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + return result; + } + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + }, + originRoute, + ); return result; }; } @@ -943,6 +988,7 @@ function FolderBrowser(props: { } export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) { + const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const environmentOptions = useEnvironmentOptions().filter( (option) => option.newProjectsRoot !== null, @@ -952,6 +998,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s ); const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId); const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const checkPublishedAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, { + reportFailure: false, + reportDefect: false, + }); const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { reportFailure: false, }); @@ -1017,6 +1067,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s const submittingRef = useRef(false); const submit = async () => { if (!environment || trimmedName.length === 0 || submittingRef.current) return; + const originRoute = getActiveRoute(navigation); submittingRef.current = true; setError(null); setIsSubmitting(true); @@ -1049,6 +1100,12 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s `Could not create the ${publishTarget.definition.label} repository`, errorMessage(Cause.squash(publishResult.cause)), ); + } else { + void suggestAppAccessForRemote( + checkPublishedAppAccess, + environment.environmentId, + publishResult.value.remoteUrl, + ); } }); } @@ -1067,11 +1124,14 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s ); return; } - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title: trimmedName, - }); + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title: trimmedName, + }, + originRoute, + ); }; return ( @@ -1264,11 +1324,13 @@ export function AddProjectDestinationScreen(props: { readonly projectTitle?: string | string[]; readonly prompt?: string | string[]; }) { - const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { + const openNewTaskDraft = useOpenNewTaskDraft(); + const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { reportFailure: false, }); - const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { + const checkAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, { reportFailure: false, + reportDefect: false, }); const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); @@ -1281,7 +1343,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 projectTitle = stringParam(props.projectTitle); @@ -1316,34 +1377,36 @@ export function AddProjectDestinationScreen(props: { return; } + const originRoute = getActiveRoute(navigation); setIsSubmitting(true); - if (environment.supportsCloneTracking) { - const projectId = ProjectId.make(uuidv4()); - const title = projectTitle ?? inferProjectTitleFromPath(resolved.path); - const startResult = await startProjectClone({ - environmentId: environment.environmentId, - input: { - projectId, - title, - createdAt: new Date().toISOString(), - remoteUrl, - destinationPath: resolved.path, - }, - }); - if (AsyncResult.isFailure(startResult)) { - setError(errorMessage(Cause.squash(startResult.cause))); - } else { - // Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls. - const project = await waitForProject( - { environmentId: environment.environmentId, projectId }, - 15_000, + const projectId = ProjectId.make(uuidv4()); + const title = projectTitle ?? inferProjectTitleFromPath(resolved.path); + const startResult = await startProjectClone({ + environmentId: environment.environmentId, + input: { + projectId, + title, + createdAt: new Date().toISOString(), + remoteUrl, + destinationPath: resolved.path, + }, + }); + if (AsyncResult.isFailure(startResult)) { + setError(errorMessage(Cause.squash(startResult.cause))); + } else { + void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl); + // Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", ); - if (project === null) { - setError( - "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", - ); - } else { - openNewTaskDraft(navigation, { + } else { + openNewTaskDraft( + { environmentId: environment.environmentId, projectId, title, @@ -1354,25 +1417,9 @@ export function AddProjectDestinationScreen(props: { () => ({}), ) : {}), - }); - } - } - setIsSubmitting(false); - return; - } - const cloneResult = await cloneRepository({ - environmentId: environment.environmentId, - input: { - remoteUrl, - destinationPath: resolved.path, - }, - }); - if (AsyncResult.isFailure(cloneResult)) { - setError(errorMessage(Cause.squash(cloneResult.cause))); - } else { - const createResult = await createProject(cloneResult.value.cwd); - if (createResult && AsyncResult.isFailure(createResult)) { - setError(errorMessage(Cause.squash(createResult.cause))); + }, + originRoute, + ); } } setIsSubmitting(false); diff --git a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx index 2dc85606be739..d69e9141fdf9f 100644 --- a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx @@ -1,5 +1,12 @@ +import type { StaticScreenProps } from "@react-navigation/native"; import { AddProjectSourceScreen } from "./AddProjectScreen"; -export function AddProjectSourceRoute() { - return ; +type AddProjectSourceRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectSourceRoute({ + route, +}: StaticScreenProps) { + return ; } diff --git a/apps/mobile/src/features/projects/suggestAppAccess.ts b/apps/mobile/src/features/projects/suggestAppAccess.ts new file mode 100644 index 0000000000000..9340d8dd836ad --- /dev/null +++ b/apps/mobile/src/features/projects/suggestAppAccess.ts @@ -0,0 +1,38 @@ +import type { + EnvironmentId, + SourceControlAppAccess, + SourceControlAppAccessInput, +} from "@t3tools/contracts"; +import { appSignInTargetForRemote } from "@t3tools/client-runtime/source-control-clients"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { Alert, Linking } from "react-native"; + +/** + * After a clone, suggests installing the forge app on the repository's owner when the server is + * signed in with it but the app does not reach the repository. + */ +export async function suggestAppAccessForRemote( + check: (value: { + readonly environmentId: EnvironmentId; + readonly input: SourceControlAppAccessInput; + }) => Promise>, + environmentId: EnvironmentId, + remoteUrl: string, +): Promise { + const target = appSignInTargetForRemote(remoteUrl); + if (target === null) return; + const result = await check({ environmentId, input: target.input }); + if (result._tag !== "Success" || result.value.status !== "not-covered") return; + const owner = target.input.repository.split("/")[0] ?? target.input.repository; + Alert.alert( + `Give ${target.appSignIn.appName} access to ${owner}`, + `It isn't installed there, so GitHub requests for ${target.input.repository} use your own login and its lower rate limit.`, + [ + { text: "Not now", style: "cancel" }, + { + text: "Install", + onPress: () => void Linking.openURL(target.appSignIn.installUrl).catch(() => undefined), + }, + ], + ); +} diff --git a/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts b/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts new file mode 100644 index 0000000000000..e59670cc64966 --- /dev/null +++ b/apps/mobile/src/features/prompt-links/newTaskPromptLink.ts @@ -0,0 +1,419 @@ +import * as Dialog from "../../components/ui/Dialog"; +import { + StackActions, + type NavigationState, + type PartialState, + type StackActionType, +} from "@react-navigation/native"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; +import { + findPromptLinkProjects, + findPromptLinkPullRequestThread, + resolvePromptLinkModelSelection, +} from "@t3tools/client-runtime/prompt-links"; +import { + executeAtomQuery, + isAtomCommandInterrupted, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + parseNewThreadLink, + type LinkedPrompt, + type PromptLinkPullRequest, + type PromptLinkRepository, +} from "@t3tools/shared/promptLinks"; +import { Atom, type AsyncResult } from "effect/reactivity"; + +import { findLinkedRoute, findStackRoute, updateLinkedRoute } from "../../lib/appLinking"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentProjects, projectEnvironment } from "../../state/projects"; +import { composerPullRequests } from "../../state/pull-requests"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { environmentThreadShells } from "../../state/threads"; +import { + createNewTaskDraft, + setComposerDraftText, + updateComposerDraftSettings, + waitForComposerDraftsLoaded, +} from "../../state/use-composer-drafts"; +import { workspaceConnections, workspaceStateAtom } from "../../state/workspace"; +import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions"; +import { + latestThreadEnvironmentIds, + promptLinkNotice, + promptLinkRepositoryName, + promptLinkScratchEnvironmentId, + promptLinkSearchFromParams, + stepPendingPromptLink, + withoutPromptLinkParams, +} from "./prompt-link-model"; + +// Found at any depth, both in the parsed link and on the live stack, so the two never disagree on +// whether the link has landed. +const NEW_TASK_ROUTE = "NewTaskProject"; +const NEW_TASK_ROUTES = [NEW_TASK_ROUTE]; + +type LinkedDraftRequest = { + readonly prompt: LinkedPrompt | null; + readonly provider: string | null; + readonly model: string | null; + readonly pullRequest: PromptLinkPullRequest | null; +}; + +// A cold launch from a link has no projects or threads yet. Matching then would offer to clone a +// repository the user already has. +const shellsSettledAtom = Atom.make((get) => { + const workspace = get(workspaceStateAtom); + return !( + workspace.isLoadingConnections || + workspace.hasPendingShellSnapshot || + // A machine still connecting may hold the repository or thread. One that keeps failing to + // reconnect has settled offline, and must not hold every link. + workspace.connectingEnvironments.some( + (environment) => + environment.connectionState === "connecting" || environment.connectionError === null, + ) + ); +}).pipe(Atom.withLabel("mobile:prompt-link-shells-settled")); + +let pendingLink: string | null = null; +let stopWaitingForShells: (() => void) | null = null; + +/** + * `new?prompt=…&repo=…` drops its params as it arrives, so the screen never sees them and each + * opening resolves once. It resolves when its screen is on the navigation state that follows and + * the shells have settled; a newer link replaces one still waiting. + */ +export function takeNewTaskPromptLink( + state: PartialState | undefined, +): PartialState | undefined { + const route = findLinkedRoute(state, NEW_TASK_ROUTES); + const search = promptLinkSearchFromParams( + route?.params as Readonly> | undefined, + ); + if (search === null) return state; + pendingLink = search; + return updateLinkedRoute(state, NEW_TASK_ROUTES, (linked) => ({ + ...linked, + params: withoutPromptLinkParams(linked.params), + })); +} + +/** Wired to the navigation container's state changes. */ +export function resolvePendingNewTaskPromptLink(state: NavigationState | undefined): void { + if (pendingLink === null) return; + const step = stepPendingPromptLink(pendingLink, { + // A screen pushed above New task (such as Add project) drops the link, so it never lands + // over the wrong screen. + screenOnStack: findStackRoute(state, NEW_TASK_ROUTE)?.focused === true, + ready: appAtomRegistry.get(shellsSettledAtom), + }); + if (step.pending === null) dropPendingLink(); + else pendingLink = step.pending; + if (step.resolve !== null) resolveLink(step.resolve); + else if (step.pending !== null) { + stopWaitingForShells ??= appAtomRegistry.subscribe(shellsSettledAtom, (settled) => { + if (settled) resolvePendingNewTaskPromptLink(rootNavigationRef.getRootState()); + }); + } +} + +function dropPendingLink(): void { + pendingLink = null; + stopWaitingForShells?.(); + stopWaitingForShells = null; +} + +/** Dispatches as the new-task screen would, so `replace` swaps that screen out. */ +function dispatchFromNewTask(action: StackActionType): void { + const root = rootNavigationRef.getRootState(); + const screen = findStackRoute(root, NEW_TASK_ROUTE); + const target = screen?.navigatorKey ?? root?.key; + rootNavigationRef.dispatch({ + ...action, + ...(target ? { target } : {}), + ...(screen ? { source: screen.routeKey } : {}), + }); +} + +function runQuery(atom: Atom.Atom>) { + return executeAtomQuery(appAtomRegistry, atom, { + label: atom.label?.[0] ?? "atom query", + reportFailure: false, + }); +} + +function writeDraft( + draftKey: string, + environmentId: EnvironmentId, + request: LinkedDraftRequest, +): boolean { + if (request.prompt) setComposerDraftText(draftKey, request.prompt.text); + if (request.provider === null) return false; + const resolved = resolvePromptLinkModelSelection( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.providers ?? [], + { provider: request.provider, model: request.model }, + ); + if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection }); + return resolved?.exact !== true; +} + +function openThread( + thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId }, + prompt: LinkedDraftRequest["prompt"], +) { + dispatchFromNewTask( + StackActions.replace("Thread", { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }), + ); + if (prompt) { + void applyLinkedPromptToThreadDraft( + scopedThreadKey(thread.environmentId, thread.id), + prompt, + promptLinkNotice({ hiddenCharacters: prompt.hiddenCharacters }), + ); + } +} + +// A pull request's thread can be an older settled thread no shell holds, so ask every +// connected environment with the repository. A lookup on another one would wait forever. +async function findServerPullRequestThread( + projects: ReadonlyArray, + pullRequest: PromptLinkPullRequest, +) { + const connected = new Set( + appAtomRegistry + .get(workspaceConnections.environmentsAtom) + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); + const lookups = projects + .filter((project) => connected.has(project.environmentId)) + .map(async (project) => { + const linked = await runQuery( + composerPullRequests.linkedThreadsUncached({ + environmentId: project.environmentId, + input: { + projectId: project.id, + repository: promptLinkRepositoryName(pullRequest), + number: pullRequest.number, + }, + }), + ); + const thread = + linked._tag === "Success" + ? linked.value.threads.find((candidate) => candidate.archivedAt === null) + : undefined; + return thread && { environmentId: project.environmentId, id: thread.id }; + }); + // The most recent project wins without waiting for the lookups after it. + for (const lookup of lookups) { + const thread = await lookup; + if (thread) return thread; + } + return undefined; +} + +async function openProjectDraft(project: EnvironmentProject, request: LinkedDraftRequest) { + try { + await waitForComposerDraftsLoaded(); + } catch { + Dialog.alert("Could not open link", "Drafts could not be loaded, so the prompt was not added."); + return; + } + const draftKey = createNewTaskDraft({ + environmentId: project.environmentId, + projectId: project.id, + }); + let pullRequestUnavailable: number | null = null; + if (request.pullRequest) { + const detail = await runQuery( + composerPullRequests.detail({ + environmentId: project.environmentId, + input: { + projectId: project.id, + repository: promptLinkRepositoryName(request.pullRequest), + number: request.pullRequest.number, + }, + }), + ); + if (detail._tag === "Success") { + updateComposerDraftSettings(draftKey, { + workspaceSelection: { + mode: "worktree", + branch: detail.value.headBranch, + worktreePath: null, + startFromOrigin: false, + pullRequestReference: detail.value.url, + }, + }); + } else { + pullRequestUnavailable = request.pullRequest.number; + } + } + const modelUnavailable = writeDraft(draftKey, project.environmentId, request); + dispatchFromNewTask( + StackActions.push("Thread", { + draft: { + draftId: draftKey, + title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title, + }, + }), + ); + if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) { + showPromptLinkNotice( + promptLinkNotice({ + hiddenCharacters: request.prompt?.hiddenCharacters ?? 0, + modelUnavailable, + pullRequestUnavailable, + }), + ); + } +} + +async function openScratchDraft(request: LinkedDraftRequest) { + const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); + const environmentId = promptLinkScratchEnvironmentId( + latestThreadEnvironmentIds(appAtomRegistry.get(environmentThreadShells.threadShellsAtom)), + appAtomRegistry + .get(workspaceConnections.environmentsAtom) + .filter( + (environment) => + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, + ) + .map((environment) => environment.environmentId), + ); + if (environmentId === null) { + Dialog.alert( + "Could not start without a project", + "No connected environment offers threads without a project.", + ); + return; + } + const result = await runAtomCommand( + appAtomRegistry, + projectEnvironment.openScratch, + { environmentId, input: {} }, + { reportFailure: false }, + ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Could not start without a project", + error instanceof Error + ? error.message + : "The folder for threads without a project could not be created.", + ); + } + return; + } + await openProjectDraft(result.value, { ...request, pullRequest: null }); +} + +function cloneRepository(repository: PromptLinkRepository, request: LinkedDraftRequest) { + // The clone screen lets the user pick another environment before cloning. + const environmentId = + appAtomRegistry.get(environmentProjects.projectsAtom)[0]?.environmentId ?? + appAtomRegistry.get(workspaceConnections.environmentsAtom)[0]?.environmentId ?? + null; + if (environmentId === null) { + Dialog.alert("Could not clone", "Connect an environment first."); + return; + } + const prompt = + request.pullRequest === null && + appAtomRegistry.get(environmentServerConfigsAtom).has(environmentId) + ? request.prompt + : null; + const name = promptLinkRepositoryName(repository); + dispatchFromNewTask( + StackActions.push("AddProjectDestination", { + environmentId, + remoteUrl: `https://github.com/${name}.git`, + repositoryTitle: name, + repositoryName: repository.name, + ...(prompt ? { prompt: prompt.text } : {}), + }), + ); + if (!request.prompt) return; + showPromptLinkNotice( + [ + promptLinkNotice({ + hiddenCharacters: request.prompt.hiddenCharacters, + modelUnavailable: prompt !== null && request.provider !== null, + }), + prompt + ? "The prompt opens in a new task once the clone starts." + : "Open the link again after the clone to get the prompt.", + ].join("\n\n"), + ); +} + +function resolveLink(linkSearch: string) { + const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const parsed = parseNewThreadLink(linkSearch); + if (!parsed.ok) { + Dialog.alert("Could not open link", parsed.error); + return; + } + const link = parsed.value; + const request: LinkedDraftRequest = + link.kind === "new" + ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null } + : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest }; + + if (link.kind === "pull-request") { + const thread = findPromptLinkPullRequestThread(threads, link.pullRequest); + if (thread) { + openThread(thread, link.prompt); + return; + } + } + + const repository = link.kind === "new" ? link.repository : link.pullRequest; + if (repository === null) { + void openScratchDraft(request); + return; + } + const lastActivity = new Map(); + for (const thread of threads) { + const key = `${thread.environmentId}:${thread.projectId}`; + if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt); + } + const activityOf = (project: EnvironmentProject) => + lastActivity.get(`${project.environmentId}:${project.id}`) ?? ""; + const projects = findPromptLinkProjects( + [...appAtomRegistry.get(environmentProjects.projectsAtom)].sort((left, right) => + activityOf(right).localeCompare(activityOf(left)), + ), + repository, + ); + const project = projects[0]; + if (project) { + const { pullRequest } = request; + void (async () => { + const thread = pullRequest && (await findServerPullRequestThread(projects, pullRequest)); + if (thread) openThread(thread, request.prompt); + else await openProjectDraft(project, request); + })(); + return; + } + Dialog.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [ + { text: "Cancel", style: "cancel" }, + ...(request.prompt + ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }] + : []), + { text: "Clone", onPress: () => cloneRepository(repository, request) }, + ]); +} diff --git a/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts b/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts index 9bf74ef042ddc..2c24e9cf72fa5 100644 --- a/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts +++ b/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts @@ -1,7 +1,11 @@ import { parseNewThreadLink } from "@t3tools/shared/promptLinks"; import { describe, expect, it } from "vite-plus/test"; -import { promptLinkSearchFromParams } from "./prompt-link-model"; +import { + promptLinkSearchFromParams, + stepPendingPromptLink, + withoutPromptLinkParams, +} from "./prompt-link-model"; describe("promptLinkSearchFromParams", () => { it("round-trips decoded route params into the shared parser", () => { @@ -24,3 +28,40 @@ describe("promptLinkSearchFromParams", () => { }); }); }); + +describe("stepPendingPromptLink", () => { + const waiting = "repo=t3tools%2Ft3code"; + + it("drops a link whose screen is missing from the first state after it was parsed", () => { + expect(stepPendingPromptLink(waiting, { screenOnStack: false, ready: true })).toEqual({ + pending: null, + resolve: null, + }); + }); + + it("holds a link on its screen until its target is ready, then resolves it once", () => { + expect(stepPendingPromptLink(waiting, { screenOnStack: true, ready: false })).toEqual({ + pending: waiting, + resolve: null, + }); + expect(stepPendingPromptLink(waiting, { screenOnStack: true, ready: true })).toEqual({ + pending: null, + resolve: waiting, + }); + }); + + it("drops a link whose screen was left before its target was ready", () => { + expect(stepPendingPromptLink(waiting, { screenOnStack: false, ready: false })).toEqual({ + pending: null, + resolve: null, + }); + }); +}); + +describe("withoutPromptLinkParams", () => { + it("keeps the route's other params", () => { + expect(withoutPromptLinkParams({ prompt: "hi", repo: "a/b", incomingShareId: "1" })).toEqual({ + incomingShareId: "1", + }); + }); +}); diff --git a/apps/mobile/src/features/prompt-links/prompt-link-model.ts b/apps/mobile/src/features/prompt-links/prompt-link-model.ts index 5872b8c9f7260..eccb5ab935bca 100644 --- a/apps/mobile/src/features/prompt-links/prompt-link-model.ts +++ b/apps/mobile/src/features/prompt-links/prompt-link-model.ts @@ -1,6 +1,6 @@ import type { PromptLinkRepository } from "@t3tools/shared/promptLinks"; -export const PROMPT_LINK_PARAM_KEYS = ["prompt", "repo", "pr", "provider", "model"] as const; +const PROMPT_LINK_PARAM_KEYS = ["prompt", "repo", "pr", "provider", "model"] as const; type RouteParams = Readonly> | undefined; @@ -17,8 +17,12 @@ export function promptLinkSearchFromParams( return search.size > 0 ? search.toString() : null; } -export function clearedPromptLinkParams(keys: ReadonlyArray = PROMPT_LINK_PARAM_KEYS) { - return Object.fromEntries(keys.map((key) => [key, undefined])); +export function withoutPromptLinkParams(params: object | undefined): Record { + return Object.fromEntries( + Object.entries(params ?? {}).filter( + ([key]) => !(PROMPT_LINK_PARAM_KEYS as ReadonlyArray).includes(key), + ), + ); } export function promptLinkNotice(input: { @@ -73,3 +77,17 @@ export function latestThreadEnvironmentIds( export const promptLinkRepositoryName = (repository: PromptLinkRepository) => `${repository.owner}/${repository.name}`; + +/** + * Advances a parsed link at each navigation state; `resolve` is the link to apply. The first + * state after the parse must hold the link's screen. A link that never lands there, or whose + * screen is left before its target is ready, is dropped so it cannot apply on a later visit. + */ +export function stepPendingPromptLink( + link: Link, + input: { readonly screenOnStack: boolean; readonly ready: boolean }, +): { readonly pending: Link | null; readonly resolve: Link | null } { + if (!input.screenOnStack) return { pending: null, resolve: null }; + if (input.ready) return { pending: null, resolve: link }; + return { pending: link, resolve: null }; +} diff --git a/apps/mobile/src/features/prompt-links/threadPromptLink.test.ts b/apps/mobile/src/features/prompt-links/threadPromptLink.test.ts new file mode 100644 index 0000000000000..3ad3bca31ebf6 --- /dev/null +++ b/apps/mobile/src/features/prompt-links/threadPromptLink.test.ts @@ -0,0 +1,144 @@ +import type { NavigationState, PartialState } from "@react-navigation/native"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const fixture = vi.hoisted(() => ({ + applied: [] as { draftKey: string; text: string }[], + root: undefined as NavigationState | undefined, + detailOpens: 0, +})); + +vi.mock("../../components/ui/Dialog", () => ({ alert: () => {} })); +vi.mock("./prompt-link-actions", () => ({ + applyLinkedPromptToThreadDraft: async (draftKey: string, prompt: { text: string }) => { + fixture.applied.push({ draftKey, text: prompt.text }); + }, +})); +vi.mock("../../lib/rootNavigation", () => ({ + rootNavigationRef: { getRootState: () => fixture.root }, +})); +vi.mock("../../state/atom-registry", async () => { + const { AtomRegistry } = await import("effect/reactivity"); + return { appAtomRegistry: AtomRegistry.make() }; +}); +vi.mock("../../state/threads", async () => { + const { AsyncResult, Atom } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + const shell = Atom.make(null); + const detailSource = Atom.make(AsyncResult.success({ data: Option.none() })); + // Computing the real detail atom opens the thread's server stream. + const detail = Atom.make((get) => { + fixture.detailOpens += 1; + return get(detailSource); + }); + return { + environmentThreadShells: { threadShellAtom: () => shell }, + environmentThreads: { stateAtom: () => detail }, + fixtureShell: shell, + fixtureDetail: detail, + fixtureDetailSource: detailSource, + }; +}); + +const { appAtomRegistry } = await import("../../state/atom-registry"); +const { fixtureDetail, fixtureDetailSource, fixtureShell } = + (await import("../../state/threads")) as unknown as { + fixtureShell: Atom.Writable; + fixtureDetail: Atom.Atom; + fixtureDetailSource: Atom.Writable }>>; + }; +const { resolvePendingThreadPromptLink, takeThreadPromptLink } = await import("./threadPromptLink"); + +function setup() { + fixture.applied.length = 0; + fixture.root = undefined; + appAtomRegistry.reset(); + appAtomRegistry.set(fixtureShell, null); + appAtomRegistry.set(fixtureDetailSource, AsyncResult.success({ data: Option.none() })); + fixture.detailOpens = 0; +} + +const loadedDetail = AsyncResult.success({ data: Option.some({}) }); + +const threadParams = { environmentId: "env-1", threadId: "thread-1" }; + +function linkedState(prompt = "Fix the bug"): PartialState { + return { routes: [{ name: "Thread", params: { ...threadParams, prompt } }] }; +} + +function stack(routes: NavigationState["routes"]): NavigationState { + return { + key: "root", + index: routes.length - 1, + routeNames: routes.map((route) => route.name), + routes, + stale: false, + type: "stack", + }; +} + +const threadRoute = stack([{ key: "thread", name: "Thread", params: threadParams }]); + +describe("takeThreadPromptLink", () => { + it("writes no draft for a thread that never shows", () => { + setup(); + takeThreadPromptLink(linkedState()); + expect(fixture.applied).toEqual([]); + + resolvePendingThreadPromptLink(threadRoute); + // An unknown thread's screen replaces itself with a new-thread draft. + resolvePendingThreadPromptLink( + stack([{ key: "draft", name: "Thread", params: { draft: { draftId: "d" } } }]), + ); + appAtomRegistry.set(fixtureShell, { id: "thread-1" }); + expect(fixture.applied).toEqual([]); + }); + + it("fills the draft once the linked thread shows on its screen", () => { + setup(); + takeThreadPromptLink(linkedState()); + fixture.root = threadRoute; + resolvePendingThreadPromptLink(threadRoute); + expect(fixture.applied).toEqual([]); + + appAtomRegistry.set(fixtureShell, { id: "thread-1" }); + expect(fixture.applied).toEqual([{ draftKey: expect.any(String), text: "Fix the bug" }]); + appAtomRegistry.set(fixtureShell, { id: "thread-1", title: "renamed" }); + expect(fixture.applied).toHaveLength(1); + }); + + it("fills the draft of an archived thread its screen shows from detail", () => { + setup(); + takeThreadPromptLink(linkedState()); + fixture.root = threadRoute; + // Archived threads have no shell; the open screen subscribes to their detail instead. + using _screen = { [Symbol.dispose]: appAtomRegistry.mount(fixtureDetail) }; + resolvePendingThreadPromptLink(threadRoute); + expect(fixture.applied).toEqual([]); + + appAtomRegistry.set(fixtureDetailSource, loadedDetail); + expect(fixture.applied).toEqual([{ draftKey: expect.any(String), text: "Fix the bug" }]); + }); + + it("opens no detail for a thread no screen has loaded", () => { + setup(); + takeThreadPromptLink(linkedState()); + fixture.root = threadRoute; + resolvePendingThreadPromptLink(threadRoute); + appAtomRegistry.set(fixtureDetailSource, loadedDetail); + expect(fixture.detailOpens).toBe(0); + expect(fixture.applied).toEqual([]); + resolvePendingThreadPromptLink(stack([{ key: "home", name: "Home" }])); + }); + + it("drops an earlier link to the thread when a newer one is invalid", () => { + setup(); + takeThreadPromptLink(linkedState()); + takeThreadPromptLink(linkedState("\u200b")); + fixture.root = threadRoute; + resolvePendingThreadPromptLink(threadRoute); + appAtomRegistry.set(fixtureShell, { id: "thread-1" }); + expect(fixture.applied).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/prompt-links/threadPromptLink.ts b/apps/mobile/src/features/prompt-links/threadPromptLink.ts new file mode 100644 index 0000000000000..bb8795fffc70a --- /dev/null +++ b/apps/mobile/src/features/prompt-links/threadPromptLink.ts @@ -0,0 +1,128 @@ +import * as Dialog from "../../components/ui/Dialog"; +import type { NavigationState, PartialState } from "@react-navigation/native"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { parseThreadLinkPrompt, type LinkedPrompt } from "@t3tools/shared/promptLinks"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; + +import { findStackRoute } from "../../lib/appLinking"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentThreadShells, environmentThreads } from "../../state/threads"; +import { applyLinkedPromptToThreadDraft } from "./prompt-link-actions"; +import { + promptLinkNotice, + promptLinkSearchFromParams, + stepPendingPromptLink, +} from "./prompt-link-model"; + +const THREAD_LINK_KEYS = ["prompt"]; + +type PendingThreadLink = { readonly ref: ScopedThreadRef; readonly prompt: LinkedPrompt }; + +function firstString(value: unknown): string | null { + const first = Array.isArray(value) ? value[0] : value; + return typeof first === "string" && first.length > 0 ? first : null; +} + +let pendingLink: PendingThreadLink | null = null; +let stopWaitingForShell: (() => void) | null = null; +let stopWaitingForDetail: (() => void) | null = null; + +/** + * `threads//?prompt=…` fills that thread's composer draft. The param is + * dropped as the link arrives, so the thread screen never sees it and a later visit cannot apply + * it again. The draft is written only once the thread's screen shows a thread that exists: a + * screen for an unknown thread replaces itself with a new-thread draft, which drops the link. + */ +export function takeThreadPromptLink( + state: PartialState | undefined, +): PartialState | undefined { + if (!state) return state; + const index = state.routes.findIndex((route) => route.name === "Thread"); + const route = state.routes[index]; + const params = route?.params as Readonly> | undefined; + const search = promptLinkSearchFromParams(params, THREAD_LINK_KEYS); + if (!route || !params || search === null) return state; + // A newer link replaces a waiting one even when it turns out to be invalid. + dropPendingLink(); + const { prompt: _prompt, ...rest } = params; + const routes = state.routes.slice(); + routes[index] = { ...route, params: rest }; + const nextState = { ...state, routes }; + + const environmentId = firstString(params.environmentId); + const threadId = firstString(params.threadId); + if (environmentId === null || threadId === null) return nextState; + const parsed = parseThreadLinkPrompt(search); + if (!parsed.ok) { + Dialog.alert("Could not open link", parsed.error); + return nextState; + } + if (parsed.value !== null) { + pendingLink = { + ref: { environmentId: EnvironmentId.make(environmentId), threadId: ThreadId.make(threadId) }, + prompt: parsed.value, + }; + } + return nextState; +} + +/** Wired to the navigation container's state changes. */ +export function resolvePendingThreadPromptLink(state: NavigationState | undefined): void { + if (pendingLink === null) return; + const { ref } = pendingLink; + // A subagent thread the environment's list leaves out gets a shell once the screen mounts its + // family. An archived thread never does; the screen shows it from its detail. + const shellAtom = environmentThreadShells.threadShellAtom(ref); + const detailAtom = environmentThreads.stateAtom(ref.environmentId, ref.threadId); + // Read detail only while the screen holds it: opening it here for an unknown thread would keep + // retrying with no screen to close it. + const screenHoldsDetail = appAtomRegistry.getNodes().has(detailAtom); + const step = stepPendingPromptLink(pendingLink, { + screenOnStack: + findStackRoute( + state, + "Thread", + (params) => + firstString(params?.environmentId) === ref.environmentId && + firstString(params?.threadId) === ref.threadId, + ) !== undefined, + ready: + appAtomRegistry.get(shellAtom) !== null || + (screenHoldsDetail && detailLoaded(appAtomRegistry.get(detailAtom))), + }); + if (step.pending === null) dropPendingLink(); + if (step.resolve !== null) { + const { prompt } = step.resolve; + void applyLinkedPromptToThreadDraft( + scopedThreadKey(ref.environmentId, ref.threadId), + prompt, + promptLinkNotice({ hiddenCharacters: prompt.hiddenCharacters }), + ); + } else if (step.pending !== null) { + stopWaitingForShell ??= appAtomRegistry.subscribe(shellAtom, (shell) => { + if (shell !== null) resolvePendingThreadPromptLink(rootNavigationRef.getRootState()); + }); + if (screenHoldsDetail) { + stopWaitingForDetail ??= appAtomRegistry.subscribe(detailAtom, (detail) => { + if (detailLoaded(detail)) resolvePendingThreadPromptLink(rootNavigationRef.getRootState()); + }); + } + } +} + +function detailLoaded( + result: AsyncResult.AsyncResult<{ readonly data: Option.Option }, unknown>, +): boolean { + return Option.isSome(Option.flatMap(AsyncResult.value(result), (state) => state.data)); +} + +function dropPendingLink(): void { + pendingLink = null; + stopWaitingForShell?.(); + stopWaitingForShell = null; + stopWaitingForDetail?.(); + stopWaitingForDetail = null; +} diff --git a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts deleted file mode 100644 index 4b31055ae2c94..0000000000000 --- a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts +++ /dev/null @@ -1,348 +0,0 @@ -import * as Dialog from "../../components/ui/Dialog"; -import { StackActions, useNavigation } from "@react-navigation/native"; -import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; -import { - findPromptLinkProjects, - findPromptLinkPullRequestThread, - resolvePromptLinkModelSelection, -} from "@t3tools/client-runtime/prompt-links"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { - parseNewThreadLink, - type LinkedPrompt, - type PromptLinkPullRequest, - type PromptLinkRepository, -} from "@t3tools/shared/promptLinks"; -import { useEffect, useEffectEvent, useRef } from "react"; - -import { scopedThreadKey } from "../../lib/scopedEntities"; -import { useServerConfigs, useThreadShells } from "../../state/entities"; -import { projectEnvironment } from "../../state/projects"; -import { composerPullRequests } from "../../state/pull-requests"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; -import { - createNewTaskDraft, - setComposerDraftText, - updateComposerDraftSettings, - waitForComposerDraftsLoaded, -} from "../../state/use-composer-drafts"; -import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; -import { useWorkspaceState } from "../../state/workspace"; -import { useNewTaskFlow } from "../threads/new-task-flow-provider"; -import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions"; -import { - clearedPromptLinkParams, - promptLinkNotice, - promptLinkRepositoryName, - latestThreadEnvironmentIds, - promptLinkScratchEnvironmentId, - promptLinkSearchFromParams, -} from "./prompt-link-model"; - -type LinkedDraftRequest = { - readonly prompt: LinkedPrompt | null; - readonly provider: string | null; - readonly model: string | null; - readonly pullRequest: PromptLinkPullRequest | null; -}; - -export function usePromptLinkResolver(params: Readonly> | undefined) { - const navigation = useNavigation(); - const flow = useNewTaskFlow(); - const { state: catalogState } = useWorkspaceState(); - const threads = useThreadShells(); - const serverConfigs = useServerConfigs(); - const { connectedEnvironments } = useRemoteConnectionStatus(); - const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); - const loadPullRequest = useAtomQueryRunner(composerPullRequests.detail, { - reportFailure: false, - }); - const loadLinkedThreads = useAtomQueryRunner(composerPullRequests.linkedThreadsUncached, { - reportFailure: false, - }); - const search = promptLinkSearchFromParams(params); - // Wait for cold-launch shells or existing repositories may be offered for cloning. - const waitingForShells = - catalogState.isLoadingConnections || - catalogState.hasPendingShellSnapshot || - // Connecting machines may hold the target; persistently offline ones must not block every link. - catalogState.connectingEnvironments.some( - (environment) => - environment.connectionState === "connecting" || environment.connectionError === null, - ); - - const writeDraft = ( - draftKey: string, - environmentId: EnvironmentId, - request: LinkedDraftRequest, - ): boolean => { - if (request.prompt) setComposerDraftText(draftKey, request.prompt.text); - if (request.provider === null) return false; - const resolved = resolvePromptLinkModelSelection( - serverConfigs.get(environmentId)?.providers ?? [], - { provider: request.provider, model: request.model }, - ); - if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection }); - return resolved?.exact !== true; - }; - - const openThread = ( - thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId }, - prompt: LinkedDraftRequest["prompt"], - ) => { - (navigation.getParent() ?? navigation).dispatch( - StackActions.replace("Thread", { - environmentId: String(thread.environmentId), - threadId: String(thread.id), - }), - ); - if (prompt) { - void applyLinkedPromptToThreadDraft( - scopedThreadKey(thread.environmentId, thread.id), - prompt, - promptLinkNotice({ hiddenCharacters: prompt.hiddenCharacters }), - ); - } - }; - - // A pull request's thread can be an older settled thread no shell holds, so ask every - // connected environment with the repository. A lookup on another one would wait forever. - const findServerPullRequestThread = async ( - projects: ReadonlyArray, - pullRequest: PromptLinkPullRequest, - ) => { - const connected = new Set( - connectedEnvironments - .filter((environment) => environment.connectionState === "connected") - .map((environment) => environment.environmentId), - ); - const lookups = projects - .filter((project) => connected.has(project.environmentId)) - .map(async (project) => { - const linked = await loadLinkedThreads({ - environmentId: project.environmentId, - input: { - projectId: project.id, - repository: promptLinkRepositoryName(pullRequest), - number: pullRequest.number, - }, - }); - const thread = - linked._tag === "Success" - ? linked.value.threads.find((candidate) => candidate.archivedAt === null) - : undefined; - return thread && { environmentId: project.environmentId, id: thread.id }; - }); - // The most recent project wins without waiting for the lookups after it. - for (const lookup of lookups) { - const thread = await lookup; - if (thread) return thread; - } - return undefined; - }; - - const openProjectDraft = async (project: EnvironmentProject, request: LinkedDraftRequest) => { - try { - await waitForComposerDraftsLoaded(); - } catch { - Dialog.alert( - "Could not open link", - "Drafts could not be loaded, so the prompt was not added.", - ); - return; - } - const draftKey = createNewTaskDraft({ - environmentId: project.environmentId, - projectId: project.id, - }); - let pullRequestUnavailable: number | null = null; - if (request.pullRequest) { - const detail = await loadPullRequest({ - environmentId: project.environmentId, - input: { - projectId: project.id, - repository: promptLinkRepositoryName(request.pullRequest), - number: request.pullRequest.number, - }, - }); - if (detail._tag === "Success") { - updateComposerDraftSettings(draftKey, { - workspaceSelection: { - mode: "worktree", - branch: detail.value.headBranch, - worktreePath: null, - startFromOrigin: false, - pullRequestReference: detail.value.url, - }, - }); - } else { - pullRequestUnavailable = request.pullRequest.number; - } - } - const modelUnavailable = writeDraft(draftKey, project.environmentId, request); - navigation.dispatch( - StackActions.push("Thread", { - draft: { - draftId: draftKey, - title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title, - }, - }), - ); - if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) { - showPromptLinkNotice( - promptLinkNotice({ - hiddenCharacters: request.prompt?.hiddenCharacters ?? 0, - modelUnavailable, - pullRequestUnavailable, - }), - ); - } - }; - - const openScratchDraft = async (request: LinkedDraftRequest) => { - const environmentId = promptLinkScratchEnvironmentId( - latestThreadEnvironmentIds(threads), - connectedEnvironments - .filter( - (environment) => - availableScratchWorkspaceRoot( - environment.connectionState, - serverConfigs.get(environment.environmentId), - ) !== null, - ) - .map((environment) => environment.environmentId), - ); - if (environmentId === null) { - Dialog.alert( - "Could not start without a project", - "No connected environment offers threads without a project.", - ); - return; - } - const result = await openScratch({ environmentId, input: {} }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Dialog.alert( - "Could not start without a project", - error instanceof Error - ? error.message - : "The folder for threads without a project could not be created.", - ); - } - return; - } - await openProjectDraft(result.value, { ...request, pullRequest: null }); - }; - - const cloneRepository = (repository: PromptLinkRepository, request: LinkedDraftRequest) => { - const environmentId = - flow.selectedEnvironmentId ?? connectedEnvironments[0]?.environmentId ?? null; - if (environmentId === null) { - Dialog.alert("Could not clone", "Connect an environment first."); - return; - } - const prompt = - request.pullRequest === null && - serverConfigs.get(environmentId)?.environment.capabilities.projectCloneTracking === true - ? request.prompt - : null; - const name = promptLinkRepositoryName(repository); - navigation.dispatch( - StackActions.push("AddProjectDestination", { - environmentId, - remoteUrl: `https://github.com/${name}.git`, - repositoryTitle: name, - repositoryName: repository.name, - ...(prompt ? { prompt: prompt.text } : {}), - }), - ); - if (!request.prompt) return; - showPromptLinkNotice( - [ - promptLinkNotice({ - hiddenCharacters: request.prompt.hiddenCharacters, - modelUnavailable: prompt !== null && request.provider !== null, - }), - prompt - ? "The prompt opens in a new task once the clone starts." - : "Open the link again after the clone to get the prompt.", - ].join("\n\n"), - ); - }; - - const resolveLink = useEffectEvent((linkSearch: string) => { - const parsed = parseNewThreadLink(linkSearch); - if (!parsed.ok) { - Dialog.alert("Could not open link", parsed.error); - return; - } - const link = parsed.value; - const request: LinkedDraftRequest = - link.kind === "new" - ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null } - : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest }; - - if (link.kind === "pull-request") { - const thread = findPromptLinkPullRequestThread(threads, link.pullRequest); - if (thread) { - openThread(thread, link.prompt); - return; - } - } - - const repository = link.kind === "new" ? link.repository : link.pullRequest; - if (repository === null) { - void openScratchDraft(request); - return; - } - const lastActivity = new Map(); - for (const thread of threads) { - const key = `${thread.environmentId}:${thread.projectId}`; - if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt); - } - const activityOf = (project: EnvironmentProject) => - lastActivity.get(`${project.environmentId}:${project.id}`) ?? ""; - const projects = findPromptLinkProjects( - flow.projectScopes - .flatMap((scope) => scope.projects) - .sort((left, right) => activityOf(right).localeCompare(activityOf(left))), - repository, - ); - const project = projects[0]; - if (project) { - const { pullRequest } = request; - void (async () => { - const thread = pullRequest && (await findServerPullRequestThread(projects, pullRequest)); - if (thread) openThread(thread, request.prompt); - else await openProjectDraft(project, request); - })(); - return; - } - Dialog.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [ - { text: "Cancel", style: "cancel" }, - ...(request.prompt - ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }] - : []), - { text: "Clone", onPress: () => cloneRepository(repository, request) }, - ]); - }); - - const handledSearchRef = useRef(null); - useEffect(() => { - if (search === null) { - handledSearchRef.current = null; - return; - } - if (waitingForShells || handledSearchRef.current === search) return; - handledSearchRef.current = search; - navigation.setParams(clearedPromptLinkParams()); - resolveLink(search); - }, [navigation, search, waitingForShells]); -} diff --git a/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts b/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts deleted file mode 100644 index f01f5c39643a3..0000000000000 --- a/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as Dialog from "../../components/ui/Dialog"; -import { useNavigation } from "@react-navigation/native"; -import { parseThreadLinkPrompt } from "@t3tools/shared/promptLinks"; -import { useEffect, useRef } from "react"; - -import { applyLinkedPromptToThreadDraft } from "./prompt-link-actions"; -import { - clearedPromptLinkParams, - promptLinkNotice, - promptLinkSearchFromParams, -} from "./prompt-link-model"; - -const THREAD_LINK_KEYS = ["prompt"]; - -export function useThreadPromptLink( - params: Readonly> | undefined, - threadDraftKey: string | null, -) { - const navigation = useNavigation(); - const search = promptLinkSearchFromParams(params, THREAD_LINK_KEYS); - const handledSearchRef = useRef(null); - useEffect(() => { - if (search === null) { - handledSearchRef.current = null; - return; - } - if (threadDraftKey === null || handledSearchRef.current === search) return; - handledSearchRef.current = search; - navigation.setParams(clearedPromptLinkParams(THREAD_LINK_KEYS)); - const parsed = parseThreadLinkPrompt(search); - if (!parsed.ok) { - Dialog.alert("Could not open link", parsed.error); - return; - } - if (parsed.value === null) return; - void applyLinkedPromptToThreadDraft( - threadDraftKey, - parsed.value, - promptLinkNotice({ hiddenCharacters: parsed.value.hiddenCharacters }), - ); - }, [navigation, search, threadDraftKey]); -} diff --git a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx index 9443eb0ac5a73..1403ee8e8d4c2 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx @@ -40,18 +40,14 @@ export function PullRequestDescription(props: { attachments: content.attachments.filter((attachment) => !isBotBadgeImage(attachment.uri)), }; }, [props.markdown]); - const gallery = useMemo( - () => - content.attachments - .filter((attachment) => attachment.kind === "image") - .map((attachment) => { - return { - ...pullRequestImageSource(attachment.uri, mediaContext), - name: new URL(attachment.uri).pathname.split("/").pop() || "Image", - }; - }), - [content, mediaContext], - ); + const gallery: FilePreviewItem[] = content.attachments + .filter((attachment) => attachment.kind === "image") + .map((attachment) => { + return { + ...pullRequestImageSource(attachment.uri, mediaContext), + name: new URL(attachment.uri).pathname.split("/").pop() || "Image", + }; + }); const canCollapse = bodyHeight > COLLAPSED_HEIGHT; const collapsed = canCollapse && !expanded; return ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx b/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx index f231479b09842..b03a35e0096ce 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx @@ -1,12 +1,7 @@ import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; -import { - StackActions, - useNavigation, - useRoute, - type StaticScreenProps, -} from "@react-navigation/native"; +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { PullRequestChangeBreakdown } from "@t3tools/contracts"; import { useState } from "react"; import { Platform, View } from "react-native"; @@ -36,6 +31,7 @@ import { } from "./usePullRequestDiff"; import { usePullRequestFileFilter } from "./usePullRequestFileFilter"; import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed"; +import { pullRequestFileJumpScope, requestPullRequestFileJump } from "./pullRequestFileJump"; const BREAKDOWN_LABELS = { code: "Code", @@ -403,7 +399,7 @@ function DirectoryRow(props: { export function PullRequestFilesSheet({ route }: StaticScreenProps) { const params = route.params; const navigation = useNavigation(); - const fileSelectionId = useRoute().key; + const jumpScope = pullRequestFileJumpScope(params); const files = useChangedFiles(params); const { diff, filter, viewed, parsed, loading } = files; const [collapsed, setCollapsed] = useState>(() => new Set()); @@ -463,15 +459,16 @@ export function PullRequestFilesSheet({ route }: StaticScreenProps + onPress={() => { + requestPullRequestFileJump(jumpScope, item.file.path); navigation.dispatch( StackActions.popTo("PullRequestFile", { ...params, path: item.file.path, - fileSelectionId, + focus: undefined, }), - ) - } + ); + }} /> )} diff --git a/apps/mobile/src/features/pull-requests/PullRequestFilterMenuContext.tsx b/apps/mobile/src/features/pull-requests/PullRequestFilterMenuContext.tsx deleted file mode 100644 index 68a423f80aa0d..0000000000000 --- a/apps/mobile/src/features/pull-requests/PullRequestFilterMenuContext.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { MenuAction } from "@react-native-menu/menu"; -import { - createContext, - use, - useLayoutEffect, - useState, - type Dispatch, - type ReactNode, - type SetStateAction, -} from "react"; - -export interface PullRequestFilterMenu { - readonly actions: readonly MenuAction[]; - readonly onSelect: (id: string) => void; - /** Whether any filter, the environment, or a non-default sort is active. */ - readonly customized: boolean; -} - -const MenuContext = createContext(null); -const PublishContext = createContext> | null>( - null, -); - -/** - * The Android PR header opens the full filter menu, but the facets (authors, labels) and the - * controls live in the list below it. The list publishes its menu here; only the header reads - * it, so the list's own renders don't fan out. - */ -export function PullRequestFilterMenuProvider(props: { readonly children: ReactNode }) { - const [menu, setMenu] = useState(null); - return ( - - {props.children} - - ); -} - -export function usePullRequestFilterMenu() { - return use(MenuContext); -} - -export function usePublishPullRequestFilterMenu(menu: PullRequestFilterMenu | null) { - const publish = use(PublishContext); - // A layout effect, so the header's filter button lands in the same frame as the list. There is - // no cleanup: the list lives exactly as long as this provider, and a hidden tab runs its - // cleanups, which would drop the button for a few frames every time the tab came back. - useLayoutEffect(() => { - if (publish && menu) publish(menu); - }, [publish, menu]); -} diff --git a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index d7359680d3f1a..38ac98f3b2a84 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -1,10 +1,12 @@ +import { useAtomValue } from "@effect/atom-react"; import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; -import { useMemo } from "react"; +import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; import { Platform, ScrollView } from "react-native"; import type { MenuProps } from "../../components/ui/Menu.types"; import { ListFilterMenu } from "../workspace/ListFilterMenu"; -import { usePublishPullRequestFilterMenu } from "./PullRequestFilterMenuContext"; -import { useProjects, useThreadShells } from "../../state/entities"; +import { environmentProjects } from "../../state/projects"; +import { environmentThreadShells } from "../../state/threads"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { buildHomeProjectScopes, sortHomeProjectScopes } from "../home/homeThreadList"; import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList"; @@ -39,32 +41,58 @@ function choices(field: string, options: Readonly>, selec })); } +type ProjectOrder = readonly { readonly memberProjectRefs: readonly ScopedProjectRef[] }[]; +const sameProjectOrder = (previous: ProjectOrder, next: ProjectOrder) => + previous.length === next.length && + previous.every((group, index) => { + const members = next[index]!.memberProjectRefs; + return ( + group.memberProjectRefs.length === members.length && + group.memberProjectRefs.every( + (ref, member) => + ref.environmentId === members[member]!.environmentId && + ref.projectId === members[member]!.projectId, + ) + ); + }); + +// Thread shells change on every agent tick, but the sidebar order they decide rarely does. The +// equality check keeps the list screen, which builds these filters, from re-rendering on each tick. +const sidebarProjectOrderFamily = Atom.family((mode: SidebarProjectGroupingMode) => + Atom.make((get) => + sortHomeProjectScopes({ + scopes: buildHomeProjectScopes({ + projects: get(environmentProjects.projectsAtom), + environmentId: null, + projectGroupingMode: mode, + }), + threads: get(environmentThreadShells.threadShellsAtom), + pendingTasks: [], + projectSortOrder: "updated_at", + }).map((scope) => ({ memberProjectRefs: scope.projectRefs })), + ).pipe(Atom.withEquality(sameProjectOrder)), +); + function useProjectsInSidebarOrder( projects: readonly Project[], ) { - const allProjects = useProjects(); - const threads = useThreadShells(); const { sidebarProjectGroupingMode } = useMobileProjectGroupingSettings(); - const scopes = useMemo( - () => - sortHomeProjectScopes({ - scopes: buildHomeProjectScopes({ - projects: allProjects, - environmentId: null, - projectGroupingMode: sidebarProjectGroupingMode, - }), - threads, - pendingTasks: [], - projectSortOrder: "updated_at", - }).map((scope) => ({ memberProjectRefs: scope.projectRefs })), - [allProjects, sidebarProjectGroupingMode, threads], - ); - return useMemo(() => sortProjectsByGroupOrder(projects, scopes), [projects, scopes]); + const scopes = useAtomValue(sidebarProjectOrderFamily(sidebarProjectGroupingMode)); + return sortProjectsByGroupOrder(projects, scopes); } -export function PullRequestListFilters(props: { - /** Android sidebar rows sit further in than full-width rows; the pills follow them. */ - readonly sidebar?: boolean; +export interface PullRequestFilterMenu { + readonly actions: MenuProps["actions"]; + readonly onSelect: (id: string) => void; + /** Whether any filter, the environment, or a non-default sort is active. */ + readonly customized: boolean; +} + +/** + * Builds the filter pills and, on Android, the header's full filter menu. The list screen owns + * the call so both the pills and the header it frames read the same menu. + */ +export function usePullRequestListFilters(props: { readonly environments: readonly { id: string; label: string }[]; readonly environmentId: string | null; readonly onEnvironmentChange: (id: string | null) => void; @@ -280,7 +308,7 @@ export function PullRequestListFilters(props: { ]; const customized = count > 0 || value.sort !== "updated" || props.environmentId !== null; // The Android header's filter button opens every filter. - const headerMenu = + const headerMenu: PullRequestFilterMenu | null = Platform.OS === "android" ? { actions: [ @@ -296,7 +324,35 @@ export function PullRequestListFilters(props: { customized, } : null; - usePublishPullRequestFilterMenu(headerMenu); + return { + headerMenu, + select, + count, + more, + reset, + environmentLabel: + props.environments.length > 1 + ? (props.environments.find((environment) => environment.id === props.environmentId) + ?.label ?? "All environments") + : null, + environmentActions, + state: value.state, + stateActions, + involvement: value.involvement, + involvementActions, + selectedLabelCount: selectedLabels.length, + labelActions, + author, + authorActions, + }; +} + +export function PullRequestListFilters(props: { + /** Android sidebar rows sit further in than full-width rows; the pills follow them. */ + readonly sidebar?: boolean; + readonly filters: ReturnType; +}) { + const { count, more, reset, select, author, selectedLabelCount } = props.filters; return ( )} - {props.environments.length > 1 ? ( + {props.filters.environmentLabel !== null ? ( environment.id === props.environmentId) - ?.label ?? "All environments" - } + label={props.filters.environmentLabel} accessibilityLabel="Choose pull request environment" - actions={environmentActions} + actions={props.filters.environmentActions} onSelect={select} /> ) : null} 0} + label={selectedLabelCount ? `Labels ${selectedLabelCount}` : "Labels"} + active={selectedLabelCount > 0} accessibilityLabel="Filter by label" - actions={labelActions} + actions={props.filters.labelActions} onSelect={select} /> diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index 93615a47a7deb..63946016d591f 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -9,7 +9,7 @@ import { usePullRequestMediaUrl, } from "./PullRequestMedia"; import { videoMimeType } from "@t3tools/shared/video"; -import { createContext, useContext, useCallback, useMemo, useState } from "react"; +import { createContext, useContext, useMemo, useState } from "react"; import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; import { linkReferenceAutolinks } from "./pullRequestAutolinks"; @@ -40,23 +40,17 @@ export function PullRequestMarkdownRepository(props: { }) { const provider = props.changeRequest?.provider; const url = props.changeRequest?.url; - const autolinks = useMemo( - () => - provider === undefined || url === undefined - ? null - : changeRequestReferenceAutolinker({ provider, url }), - [provider, url], - ); + const autolinks = + provider === undefined || url === undefined + ? null + : changeRequestReferenceAutolinker({ provider, url }); const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, projectId: target.input.projectId, }); const cwd = project?.workspaceRoot; - const media = useMemo( - () => (cwd ? { environmentId: target.environmentId, cwd } : null), - [target.environmentId, cwd], - ); + const media = cwd ? { environmentId: target.environmentId, cwd } : null; return ( {props.children} @@ -119,32 +113,23 @@ function PullRequestMarkdownContent(props: { }) { const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); - const markdown = useMemo( - () => linkReferenceAutolinks(props.markdown, autolinks), - [props.markdown, autolinks], - ); + const markdown = linkReferenceAutolinks(props.markdown, autolinks); const openPullRequest = useOpenPullRequest(); - const openLink = useCallback( - (href: string) => { - void openPullRequest(href); - }, - [openPullRequest], - ); - const gallery = useMemo( - () => - markdownImageSources(props.imageGroupMarkdown ?? props.markdown) - .filter( - (uri) => - /^https?:\/\//i.test(uri) && - videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null, - ) - .map((uri) => pullRequestImageSource(uri, mediaContext)), - [props.markdown, props.imageGroupMarkdown, mediaContext], - ); + const openLink = (href: string) => { + void openPullRequest(href); + }; + const gallery: FilePreviewItem[] = markdownImageSources( + props.imageGroupMarkdown ?? props.markdown, + ) + .filter( + (uri) => + /^https?:\/\//i.test(uri) && + videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null, + ) + .map((uri) => pullRequestImageSource(uri, mediaContext)); const [preview, setPreview] = useState(null); - const renderImage = useCallback( - (image) => , - [], + const renderImage: MarkdownImageRenderer = (image) => ( + ); const styles = useMarkdownPreviewStyles(); return ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx index 58590229fbf67..0665f045b8465 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useMemo, useState } from "react"; +import { createContext, useContext, useState } from "react"; import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; @@ -32,13 +32,10 @@ export const PullRequestMediaContext = createContext<{ export function usePullRequestMediaUrl(url: string) { const context = useContext(PullRequestMediaContext); const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl; - const resource = useMemo( - () => - context && fetchUrl - ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } - : null, - [context, fetchUrl], - ); + const resource: AssetResource | null = + context && fetchUrl + ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } + : null; const asset = useAssetUrlState(context?.environmentId ?? null, resource); const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource); return { diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx index b784bdb4eb427..8ba68d2a2bfa9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx @@ -1,4 +1,3 @@ -import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -6,12 +5,10 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; -import { SymbolView } from "../../components/ui/AppSymbol"; -import { Menu } from "../../components/ui/Menu"; import { Button } from "../../components/ui/Button"; -import { IconButton } from "../../components/ui/IconButton"; +import { SplitButton } from "../../components/ui/SplitButton"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { composerPullRequests } from "../../state/pull-requests"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -26,8 +23,8 @@ import { import { pullRequestStackMerge, singlePullRequestMergeAllowed } from "./pullRequestStack"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; -import type { PullRequestStackState } from "./usePullRequestStack"; import { PullRequestGlyph } from "./pullRequestGlyphs"; +import type { PullRequestStackState } from "./usePullRequestStack"; export function PullRequestMergeControls(props: { readonly pr: PullRequestDetail; @@ -169,107 +166,41 @@ export function PullRequestMergeControls(props: { }; return ( - - {Platform.OS === "android" ? ( - -