diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 69c1ea6575a4..315ff5742fd5 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -22,6 +22,7 @@ github:btsouth github:cestercian github:chrisdeeming github:chuks-qua +-github:coygeek Filed over 270 automated issues, about 50 a day at peak, far past what maintainers can triage github:cursoragent github:D3OXY github:dbalders diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e27a8c90fdff..cc6dc80511ac 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -35,7 +35,7 @@ runs: - name: Cache Vite+ id: vp_cache if: steps.vp.outputs.version != '' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 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 }} @@ -56,7 +56,7 @@ runs: # 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@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 env: VP_DOWNLOAD_TIMEOUT: "120" with: @@ -92,7 +92,7 @@ runs: - name: Restore pnpm store id: pnpm_cache if: inputs.install == 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} @@ -106,7 +106,7 @@ runs: - name: Restore lockfile verification id: pnpm_verified if: inputs.install == 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.verified }} key: pnpm-verified-v1-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} @@ -121,7 +121,7 @@ runs: - name: Save lockfile verification if: inputs.install == 'true' && steps.pnpm_verified.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.verified }} key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} @@ -138,7 +138,7 @@ runs: 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 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 95581e2b2278..a9502b26c8c8 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -121,9 +121,54 @@ async function resolveLatestNightlyCommit({ github, context, core }) { return { tag, sha: commit.sha, version }; } +// The two places a nightly install looks for an update: the `update-feed` pointer, and +// releases.atom, which holds the 10 newest git tags of any kind. Older installs take the first +// nightly in the feed. Reports what each names beside the newest published nightly. +async function readNightlyUpdateFeed({ github, context, fetch = globalThis.fetch }) { + const latest = await findLatestNightly({ github, context }); + if (!latest) return {}; + + const base = `https://github.com/${context.repo.owner}/${context.repo.repo}`; + // Only a missing or malformed pointer is stale. Rewriting deletes the old file first, so a + // GitHub outage must not look like one. + // Node waits five minutes for a stalled response, which is this job's whole time limit. + const signal = () => AbortSignal.timeout(30_000); + const pointer = await fetch(`${base}/releases/download/update-feed/nightly.json`, { + signal: signal(), + }); + if (!pointer.ok && pointer.status !== 404) { + throw new Error(`The update-feed pointer answered HTTP ${pointer.status}.`); + } + let pointerTag; + if (pointer.ok) { + // A body that fails to arrive throws here; only one that is not JSON counts as malformed. + const body = await pointer.text(); + try { + pointerTag = JSON.parse(body)?.tag_name; + } catch {} + } + + // An unreadable feed leaves `feedTags` unset, so the pointer is still repaired. An error page + // can answer HTTP 200, so only an Atom document counts as read. + let feedTags; + try { + const feed = await fetch(`${base}/releases.atom`, { signal: signal() }); + const body = feed.ok ? await feed.text() : ""; + if (/]/.test(body)) { + feedTags = Array.from(body.matchAll(/href="[^"]*\/releases\/tag\/([^"]+)"/g), (match) => + decodeURIComponent(match[1]), + ); + } + } catch {} + const feedNightly = feedTags?.find((tag) => /^v?\d+\.\d+\.\d+-nightly\./.test(tag)); + + return { tag: latest.tagName, pointerTag, feedNightly, feedTags }; +} + module.exports = { assertCommitOnDefaultBranch, assertReleaseSource, shouldReleaseNightly, resolveLatestNightlyCommit, + readNightlyUpdateFeed, }; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index 3030080d946c..2f3d385755ca 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -237,3 +237,81 @@ test("stable releases fail when no nightly is published", async () => { const { options } = nightlyCommitFixture({ releases: [nightly(1, { tagName: "v1.0.0" })] }); await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly found/); }); + +const { readNightlyUpdateFeed } = require("./check-nightly-release.cjs"); + +// GitHub percent-encodes tag names in the feed, and any tag in the repository takes a slot. +const atomEntry = (tag) => + ``; +const atomFeed = (tags) => new Response(`${tags.map(atomEntry).join("")}`); +const NEWEST = "v1.0.1-nightly.20260905.123"; +const OLDER = "v1.0.1-nightly.20260905.100"; + +function updateFeedFixture({ feed, pointer }) { + const { options } = fixture({ + releases: [nightly(1), nightly(0, { tagName: "v1.0.2-preview.1" })], + }); + options.fetch = async (url) => + url === "https://github.com/example/app/releases.atom" + ? feed + : url === "https://github.com/example/app/releases/download/update-feed/nightly.json" + ? pointer + : assert.fail(`unexpected fetch ${url}`); + return options; +} + +const missing = () => new Response("Not Found", { status: 404 }); +// HTTP 200, then the connection drops before the body arrives. +const brokenBody = () => + new Response(new ReadableStream({ start: (controller) => controller.error(new Error("reset")) })); +for (const [name, { feed, pointer }, expected] of [ + [ + "a nightly crowded out of the feed and a missing pointer", + { feed: atomFeed(["v1.0.2-preview.1", "mobile-android-build/2.3.0/abc"]), pointer: missing() }, + { pointerTag: undefined, feedNightly: undefined }, + ], + [ + "the first nightly in the feed, which is the one older installs take", + { feed: atomFeed(["update-feed", OLDER, NEWEST]), pointer: Response.json({ tag_name: OLDER }) }, + { pointerTag: OLDER, feedNightly: OLDER }, + ], + [ + "a malformed pointer as stale and an unreadable feed as naming nothing", + { feed: new Response("", { status: 503 }), pointer: Response.json(null) }, + { pointerTag: undefined, feedNightly: undefined, unreadable: true }, + ], + [ + "an HTML page with release links as an unreadable feed", + { + feed: new Response( + `x`, + ), + pointer: Response.json({ tag_name: NEWEST }), + }, + { pointerTag: NEWEST, feedNightly: undefined, unreadable: true }, + ], + [ + "the pointer when the feed's body fails to arrive", + { feed: brokenBody(), pointer: Response.json({ tag_name: NEWEST }) }, + { pointerTag: NEWEST, feedNightly: undefined, unreadable: true }, + ], +]) { + test(`update feed check reports ${name}`, async () => { + const { tag, pointerTag, feedNightly, feedTags } = await readNightlyUpdateFeed( + updateFeedFixture({ feed, pointer }), + ); + const { unreadable = false, ...named } = expected; + assert.deepEqual({ tag, pointerTag, feedNightly }, { tag: NEWEST, ...named }); + assert.equal(feedTags === undefined, unreadable); + }); +} + +for (const [name, pointer, error] of [ + ["answers with an error", () => new Response("", { status: 503 }), /pointer answered HTTP 503/], + ["drops the connection", brokenBody, /reset/], +]) { + test(`update feed check does not call a pointer stale when GitHub ${name}`, async () => { + const options = updateFeedFixture({ feed: atomFeed([NEWEST]), pointer: pointer() }); + await assert.rejects(readNightlyUpdateFeed(options), error); + }); +} diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml index 41efb7b69a5a..5935fb4653c3 100644 --- a/.github/workflows/agent-context-budget.yml +++ b/.github/workflows/agent-context-budget.yml @@ -10,10 +10,7 @@ on: permissions: contents: read -concurrency: - group: agent-context-budget-${{ github.event.pull_request.number }} - cancel-in-progress: true - +# No concurrency group: label events arrive in bursts, and a cancelled run reports as a failed check. jobs: budget: name: Agent context budget @@ -24,7 +21,7 @@ jobs: ALLOW_INCREASE: ${{ contains(github.event.pull_request.labels.*.name, 'agent-context-increase') }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: apps/server/src/provider/agentContextBudget.json sparse-checkout-cone-mode: false diff --git a/.github/workflows/coil-ci.yml b/.github/workflows/coil-ci.yml index 71ba5cef4069..7d3fe1310cae 100644 --- a/.github/workflows/coil-ci.yml +++ b/.github/workflows/coil-ci.yml @@ -103,10 +103,17 @@ jobs: - name: Test run: vp run --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test --testTimeout=120000 --hookTimeout=120000 + # Split in two because one headless Chromium running all ~585 web files on these runners lost its + # page late in the run in 5 of 6 runs on 2026-11-13 (`Browser connection was closed`), with no + # test failing. test_web: - name: Test Web + name: Test Web ${{ matrix.shard }} runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout uses: actions/checkout@v6 @@ -127,7 +134,7 @@ jobs: # falls back to GITHUB_REPOSITORY, which on the fork's runners is radroid/t3code. env: T3CODE_DESKTOP_UPDATE_REPOSITORY: pingdotgg/t3code - run: vp run --filter @t3tools/web test --testTimeout=120000 --hookTimeout=120000 + run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }} --testTimeout=120000 --hookTimeout=120000 # apps/server sets `fileParallelism: false`, so its 524 test files run one at a time. Upstream # shards them six ways by recorded duration (apps/server/src/testUtils/weightedShardSequencer.ts). diff --git a/.github/workflows/coil-sync-resolve.yml b/.github/workflows/coil-sync-resolve.yml index ed9387991804..543ca98fbd0d 100644 --- a/.github/workflows/coil-sync-resolve.yml +++ b/.github/workflows/coil-sync-resolve.yml @@ -146,7 +146,8 @@ jobs: 4. Verify: run `vp run typecheck`, `vp run lint`, then the tests in two runs, web last and alone: `vp run --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test --testTimeout=120000 --hookTimeout=120000` - then `vp run --filter @t3tools/web test --testTimeout=120000 --hookTimeout=120000`. + then `vp run --filter @t3tools/web test --shard 1/2 --testTimeout=120000 --hookTimeout=120000` + and the same with `--shard 2/2`. The timeout flags are required: apps/web pins 15s, which is too tight for this runner, and 120s matches the fork's CI gate without lowering any package's own budget. Web runs alone because a web test's in-file 30s hook budget times out diff --git a/.github/workflows/mobile-dev-client.yml b/.github/workflows/mobile-dev-client.yml index 5f8cb86befb5..e204b16f0162 100644 --- a/.github/workflows/mobile-dev-client.yml +++ b/.github/workflows/mobile-dev-client.yml @@ -83,7 +83,7 @@ jobs: T3_NATIVE_CLIENT_VERBOSE: "1" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.ref || github.sha }} @@ -144,7 +144,7 @@ jobs: # the runs holding the cache login restore. - name: Mount build cache if: github.event_name != 'pull_request' - uses: useblacksmith/stickydisk@v1 + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 with: key: ${{ github.repository }}-android-dev-client-${{ matrix.abi }} path: ~/.cache/t3-android-dev-client @@ -165,7 +165,7 @@ jobs: install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts... - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 17 @@ -232,7 +232,7 @@ jobs: T3_NATIVE_CLIENT_VERBOSE: "1" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.ref || github.sha }} diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml index 45e4c9f2ad42..a48462eec8c5 100644 --- a/.github/workflows/mobile-fingerprint-label.yml +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -33,7 +33,7 @@ jobs: steps: - id: pr name: Resolve the pull request behind the check - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const run = context.payload.workflow_run; @@ -100,7 +100,7 @@ jobs: - name: Sync native change label if: steps.pr.outputs.pr_number != '' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} diff --git a/.github/workflows/pr-limits.yml b/.github/workflows/pr-limits.yml index 75943062735c..d6a2864f94a9 100644 --- a/.github/workflows/pr-limits.yml +++ b/.github/workflows/pr-limits.yml @@ -34,13 +34,13 @@ jobs: # branch and never run PR code. Cone mode also checks out the files beside # .github/scripts, which is where TRIAGE_EXEMPTIONS.td and VOUCHED.td live. - name: Checkout trusted scripts - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: .github/scripts - name: Close PRs over the limit - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: DRY_RUN: ${{ vars.PR_LIMITS_ENFORCE != 'true' }} with: @@ -57,13 +57,13 @@ jobs: cancel-in-progress: false steps: - name: Checkout trusted scripts - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: .github/scripts - name: Request rebases and close what went unanswered - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: DRY_RUN: ${{ vars.PR_LIMITS_ENFORCE != 'true' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }} with: diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 3a70ad5a26a0..9a8e69d69cc2 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -30,7 +30,7 @@ jobs: timeout-minutes: 45 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -40,7 +40,7 @@ jobs: # setup-vp's own cache restores a Linux-shaped store on Windows, which is # slower than no cache (see #7975). Cache pnpm's Windows store directly. - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: false @@ -52,7 +52,7 @@ jobs: run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - name: Cache packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.package_cache_path.outputs.path }} key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }} diff --git a/.gitignore b/.gitignore index 85b7c5b4bf02..1637f475fbbd 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ node_modules/ # pnpm content-addressable store; never belongs in the repo. .pnpm-store/ + +/packages/ssh/.ssh-launcher-test-*/ diff --git a/AGENTS.md b/AGENTS.md index 6feb8399aee7..da3bc4a35b81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro, Pi, Muse Code, fx) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. - **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, and Kiro each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, Kiro, Pi, Muse Code, fx, and ACP Registry each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". - **Agents.** A capability a user can trigger is usually one an agent should reach through MCP tools, and scheduled tasks run the same paths. That only works when it is a service method, not handler code. - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. @@ -83,7 +83,8 @@ The most common defect in this repo is a change that works on the path you teste - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. - Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. - To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). -- Stop what you started, by the PID you tracked. See rule 1. +- Stop what you started, by the PID you tracked. See rule 1. `vp` does not pass SIGTERM on, so signalling the `vp run dev` PID alone leaves Vite and the server running. Stop the background task with your harness's own stop, which ends the whole tree. +- Run dev servers, Metro, and builds from your own shell so they end with your session. Do not detach them with `systemd-run`, `nohup`, `setsid`, or `tmux` unless the user asks for one that outlives the session. Detached servers ran for weeks and filled RAM and swap. The dev runner stops itself above `--memory-limit-gb` (half of RAM, at most 16 GiB) on Linux. - Need a second checkout? `git worktree add` it next to this worktree, not in `/tmp`. Remove it when done. ## Test data @@ -149,7 +150,7 @@ For mobile verification, a missing or outdated native client is an install step, ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. -- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): push to `origin`, open and track PRs with `cafe` and their git.cafe URLs, and keep `gh` for merging. +- `origin` is not always GitHub. Check `git remote get-url origin` before your first forge command and use that forge's CLI and skill. See [Forge mirror](docs/operations/development.md#forge-mirror-optional). - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 8a823cde8db7..de687d41a4e6 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -424,7 +424,7 @@ it.effect.each(["startup", "open-url", "early-second-instance"] as const)( ), Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); - }).pipe(Effect.scoped), + }), ); it.effect("hands web and app links to the renderer and leaves other links alone", () => @@ -481,5 +481,5 @@ it.effect("hands web and app links to the renderer and leaves other links alone" }), ), ); - }).pipe(Effect.scoped), + }), ); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 2dc59c58899e..dd61b7984ec1 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -160,7 +160,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(yield* fs.exists(upgradedLink)).toBe(false); expect((yield* upgraded.install).installedPath).toBe(upgradedLink); } - }).pipe(Effect.scoped), + }), ); it.effect("never replaces or removes a t3 it did not create", () => @@ -202,7 +202,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect((yield* Effect.flip(command.install)).message).toContain("Could not set up"); yield* command.uninstall; expect(yield* fs.readFileString(launcher)).toBe("npm's t3\n"); - }).pipe(Effect.scoped), + }), ); it.effect("finds and removes a link left by a previous T3 home", () => @@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { ); expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); expect(yield* fs.readFileString(path.join(home, "bin", "t3.staging"))).toBe("mine"); - }).pipe(Effect.scoped), + }), ); it.effect("refuses to install behind another t3 that runs first", () => @@ -309,7 +309,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { installedPath: path.join(home, ".local", "bin", "t3"), }); expect(installed.shadowedBy).toBeUndefined(); - }).pipe(Effect.scoped), + }), ); it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( @@ -343,7 +343,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { onPath: true, installedPath: path.join(home, ".local", "bin", "t3"), }); - }).pipe(Effect.scoped), + }), ); it.effect("does not treat its own launcher as another t3 when its folder is first on PATH", () => @@ -372,7 +372,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(yield* command.state).toEqual(removed); expect(yield* fs.exists(installed.installedPath!)).toBe(false); expect(yield* fs.exists(launcher)).toBe(true); - }).pipe(Effect.scoped), + }), ); it.effect("leaves the Windows PATH alone when it cannot be read", () => @@ -411,7 +411,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const reader = yield* commandIn({ home, platform: "win32" }, inherited.spawner); yield* reader.install; expect(inherited.registry).toMatchObject({ path: `${userPath};${launcherDir}`, writes: 1 }); - }).pipe(Effect.scoped), + }), ); it.effect("does not claim or remove a Windows PATH entry the user added", () => @@ -441,6 +441,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* (yield* commandIn({ home: besideHome, platform: "win32" }, beside.spawner)) .installOnFirstLaunch; expect(beside.registry).toMatchObject({ path: "C:\\Tools", writes: 0 }); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index e2f97c7d2ff7..3e8b4967dfde 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -39,7 +39,7 @@ const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = }); const REPORTER = - '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; + '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME appimage=$APPIMAGE"\nprintf "%s\\n" "$@"\nexit 3\n'; const fakeAppImage = (canMount = true, app = REPORTER) => { const root = tempRoot(); @@ -56,7 +56,7 @@ const fakeAppImage = (canMount = true, app = REPORTER) => { `echo $$ >'${root}/mounter.pid'`, canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', // Like the FUSE runtime, any of these unmounts. fd 3 reports to the test when it is open. - `trap 'echo unmounted >&3 2>/dev/null; touch "${root}/unmounted"; exit 0' TERM HUP INT`, + "trap 'echo unmounted >&3 2>/dev/null; exit 0' TERM HUP INT", "trap 'echo mounted >&3 2>/dev/null' WINCH", "while :; do sleep 0.05; done", "", @@ -150,6 +150,14 @@ describe("renderCliShim", () => { ); }); + // The server skips its compile cache only under APPIMAGE; each mount path is new. + it("tells the CLI it runs from the mounted AppImage", () => { + const { appImage, shim } = fakeAppImage(); + const result = run(shim, ["--version"]); + expect(result.status).toBe(3); + expect(result.stdout).toContain(`appimage=${appImage}`); + }); + it("keeps the install's T3 home under sudo, unless one is set", () => { const root = tempRoot(); writeExecutable(NodePath.join(root, "app"), REPORTER); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index eba61f561f52..217f3b327dc9 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -109,6 +109,8 @@ export const renderCliShim = (input: { ' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }', ' mount="$work/squashfs-root"', "fi", + // The runtime sets this when it launches the app; the CLI skips its path-keyed compile cache on it. + 'export APPIMAGE="$app"', run("$mount"), "", ].join("\n"); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 98c75385afc5..5dfde9103f15 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -99,7 +99,7 @@ const withStore = ( prefix: "t3-desktop-connection-catalog-test-", }); return yield* effect.pipe(Effect.provide(layerFor(baseDir, encryptionAvailable))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }).pipe(Effect.provide(NodeServices.layer)); describe("DesktopConnectionCatalogStore", () => { it.effect("persists and reads an encrypted connection catalog", () => @@ -298,6 +298,6 @@ describe("DesktopConnectionCatalogStore", () => { assert.notEqual(error.message, decryptError.message); yield* Ref.set(failDecrypt, false); assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/app/DesktopExitChildren.test.ts b/apps/desktop/src/app/DesktopExitChildren.test.ts index 490c6740f0e0..800a0f5bd4c0 100644 --- a/apps/desktop/src/app/DesktopExitChildren.test.ts +++ b/apps/desktop/src/app/DesktopExitChildren.test.ts @@ -3,7 +3,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import { assert, describe, it } from "@effect/vitest"; -import { stopChildProcessesBeforeExit } from "./DesktopExitChildren.ts"; +import { stopChildProcessesBeforeExit, stopZygotesBeforeExit } from "./DesktopExitChildren.ts"; const isRunning = (pid: number) => { try { @@ -39,4 +39,24 @@ describe.runIf(NodeFS.existsSync("/proc/self/task"))("stopChildProcessesBeforeEx parent.kill("SIGKILL"); } }); + + it("kills zygote children before returning and leaves other helpers running", async () => { + const spawnTitled = async (title: string) => { + const child = NodeChildProcess.spawn("sleep", ["60"], { argv0: title, stdio: "ignore" }); + await new Promise((resolve) => child.once("spawn", resolve)); + return child; + }; + // Chromium rewrites a zygote's argv into one space-separated title. + const zygote = await spawnTitled("/opt/T3 Code/t3code --type=zygote --no-sandbox"); + const utility = await spawnTitled("/opt/T3 Code/t3code --type=utility"); + try { + stopZygotesBeforeExit(process.pid); + + assert.isFalse(isRunning(zygote.pid!)); + assert.isTrue(isRunning(utility.pid!)); + } finally { + zygote.kill("SIGKILL"); + utility.kill("SIGKILL"); + } + }); }); diff --git a/apps/desktop/src/app/DesktopExitChildren.ts b/apps/desktop/src/app/DesktopExitChildren.ts index 7cc64c43833e..9426c1b4ff88 100644 --- a/apps/desktop/src/app/DesktopExitChildren.ts +++ b/apps/desktop/src/app/DesktopExitChildren.ts @@ -29,6 +29,13 @@ const childPids = (pid: number): ReadonlyArray => { ); }; +// A zygote rewrites its argv into one space-separated title. +const isZygote = (pid: number): boolean => + (readProc(`/proc/${pid}/cmdline`) ?? "") + .replaceAll("\0", " ") + .split(" ") + .includes("--type=zygote"); + // A zombie has already released its memory mappings. const isRunning = (pid: number): boolean => { const stat = readProc(`/proc/${pid}/stat`); @@ -68,3 +75,13 @@ export const stopChildProcessesBeforeExit = (pid: number): ReadonlyArray waitUntilStopped(survivors, KILL_WAIT_MS); return survivors; }; + +/** + * Chromium's zygotes exit only after main does, so on an AppImage they outlive the mount even on + * an orderly quit. Kills them while main still holds the image mounted. Linux only (/proc). + */ +export const stopZygotesBeforeExit = (pid: number): void => { + const zygotes = childPids(pid).filter(isZygote); + signal(zygotes, "SIGKILL"); + waitUntilStopped(zygotes, KILL_WAIT_MS); +}; diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 30df96c58ad7..e11e946dd41b 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; @@ -117,6 +118,7 @@ describe("DesktopLifecycle", () => { const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { platform, isDevelopment: false, + appImagePath: Option.none(), } as DesktopEnvironment.DesktopEnvironment["Service"]); const layer = DesktopLifecycle.layer.pipe( diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 56fb25fcfa90..9568e72dde31 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -3,6 +3,7 @@ import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -12,7 +13,7 @@ import type * as Electron from "electron"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import { stopChildProcessesBeforeExit } from "./DesktopExitChildren.ts"; +import { stopChildProcessesBeforeExit, stopZygotesBeforeExit } from "./DesktopExitChildren.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -340,6 +341,13 @@ export const make = DesktopLifecycle.of({ quitFromSignal("SIGTERM", runEffect, quit); }); } + + // Unscoped: the app scope closes before Electron exits, and every exit path must run this. + if (environment.platform === "linux" && Option.isSome(environment.appImagePath)) { + yield* Effect.sync(() => { + process.once("exit", () => stopZygotesBeforeExit(process.pid)); + }); + } }).pipe(Effect.withSpan("desktop.lifecycle.register")), }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 7e685358865c..e9c8336feaf4 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -602,7 +602,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { } assert.isFalse(yield* fileSystem.exists(fixture.cachePath)); assert.equal(iconCacheRuns(run), 0); - }).pipe(Effect.scoped), + }), ); it.effect("replaces same-length stale icons and refreshes an existing cache once", () => @@ -634,7 +634,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { run.commands.filter(({ command }) => command === "gtk-update-icon-cache"), [{ command: "gtk-update-icon-cache", args: ["-f", "-t", fixture.hicolorDir] }], ); - }).pipe(Effect.scoped), + }), ); it.effect("refreshes when this launch changed an icon even if the cache looks newer", () => @@ -655,7 +655,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { assert.isTrue(yield* fileSystem.exists(path.join(fixture.appsDir, "com.t3tools.T3Code.png"))); assert.equal(iconCacheRuns(run), 1); - }).pipe(Effect.scoped), + }), ); it.effect("retries a failed icon cache refresh on the next launch", () => @@ -678,7 +678,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { yield* registerWithRealFileSystem(recovering); yield* registerWithRealFileSystem(recovering); assert.equal(iconCacheRuns(recovering), 1); - }).pipe(Effect.scoped), + }), ); it.effect("still registers the handler when the theme icon install fails", () => @@ -702,6 +702,6 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { missingTool.commands.map(({ command }) => command), ["gtk-update-icon-cache", "update-desktop-database", "xdg-mime"], ); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 3126d12a2e93..8a8389082ce8 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -5,6 +5,11 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, DesktopTelemetryControlMessage, } from "@t3tools/contracts"; +import { + DATABASE_MIGRATION_PROGRESS_MESSAGE, + DATABASE_MIGRATIONS_FINISHED_MESSAGE, + DATABASE_MIGRATIONS_STARTED_MESSAGE, +} from "@t3tools/shared/databaseMigrationLog"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -194,129 +199,125 @@ function makeTestInstance(input: MakeInstanceInput) { describe("DesktopBackendManager", () => { it.effect("notifies readiness waiters on HTTP readiness and stop without clock advancement", () => - Effect.scoped( - Effect.gen(function* () { - const probe = yield* Deferred.make(); - const ready = yield* Deferred.make(); - const exit = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.acquireRelease( - Effect.succeed( - makeProcess({ - exitCode: Deferred.await(exit), - kill: () => - Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), - }), - ), - () => Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)), + Effect.gen(function* () { + const probe = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exit = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.acquireRelease( + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(exit), + kill: () => + Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), + }), ), + () => Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)), ), ), - httpClientLayer: Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Deferred.succeed(probe, undefined).pipe( - Effect.andThen(Deferred.await(ready)), - Effect.as(HttpClientResponse.fromWeb(request, new Response("{}"))), - ), + ), + httpClientLayer: Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Deferred.succeed(probe, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + Effect.as(HttpClientResponse.fromWeb(request, new Response("{}"))), ), ), - }); - yield* instance.start; - yield* Deferred.await(probe); - const waiter = yield* instance.waitForReady(Duration.seconds(15)).pipe(Effect.forkChild); - yield* Deferred.succeed(ready, undefined); - assert.isTrue(yield* Fiber.join(waiter)); - yield* instance.stop(); - assert.isFalse(yield* instance.waitForReady(Duration.seconds(15))); - }), - ), + ), + }); + yield* instance.start; + yield* Deferred.await(probe); + const waiter = yield* instance.waitForReady(Duration.seconds(15)).pipe(Effect.forkChild); + yield* Deferred.succeed(ready, undefined); + assert.isTrue(yield* Fiber.join(waiter)); + yield* instance.stop(); + assert.isFalse(yield* instance.waitForReady(Duration.seconds(15))); + }), ); it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () => - Effect.scoped( - Effect.gen(function* () { - let spawnedCommand: ChildProcess.Command | undefined; - let bootstrapJson = ""; - let telemetryJson = ""; - let readyCount = 0; - const ready = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - spawnedCommand = command; - if (command._tag === "StandardCommand") { - const fd3 = command.options.additionalFds?.fd3; - if (fd3?.type === "input" && fd3.stream) { - bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); - } - const fd4 = command.options.additionalFds?.fd4; - if (fd4?.type === "input" && fd4.stream) { - telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString); - } + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let telemetryJson = ""; + let readyCount = 0; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + } + const fd4 = command.options.additionalFds?.fd4; + if (fd4?.type === "input" && fd4.stream) { + telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString); } + } - return makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); - const instance = yield* makeTestInstance({ - config: { - ...baseConfig, - bootstrap: configWithObservability, - }, - spawnerLayer: layerSpawner, - desktopTelemetryStream: Stream.encodeText( - Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), - ), - onReady: Effect.sync(() => { - readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), - backendOutputLog: { - persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), - }, - }); + const instance = yield* makeTestInstance({ + config: { + ...baseConfig, + bootstrap: configWithObservability, + }, + spawnerLayer: layerSpawner, + desktopTelemetryStream: Stream.encodeText( + Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), + ), + onReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + backendOutputLog: { + persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), + }, + }); - yield* instance.start; - yield* Queue.take(exited); - - assert.equal(readyCount, 1); - assert.isDefined(spawnedCommand); - if (spawnedCommand._tag !== "StandardCommand") { - throw new Error("Expected backend to spawn a standard command."); - } - - assert.equal(spawnedCommand.command, "/electron"); - assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); - assert.equal(spawnedCommand.options.cwd, "/server"); - assert.equal(spawnedCommand.options.extendEnv, true); - assert.equal(spawnedCommand.options.stdout, "pipe"); - assert.equal(spawnedCommand.options.stderr, "pipe"); - assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); - assert.isDefined(spawnedCommand.options.forceKillAfter); - assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input"); - assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); - assert.equal( - Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), - 5_000, - ); - - assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); - assert.equal( - telemetryJson, - '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n', - ); - }), - ), + yield* instance.start; + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.isDefined(spawnedCommand); + if (spawnedCommand._tag !== "StandardCommand") { + throw new Error("Expected backend to spawn a standard command."); + } + + assert.equal(spawnedCommand.command, "/electron"); + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); + assert.equal(spawnedCommand.options.cwd, "/server"); + assert.equal(spawnedCommand.options.extendEnv, true); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); + assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input"); + assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); + assert.equal( + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), + 5_000, + ); + + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); + assert.equal( + telemetryJson, + '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n', + ); + }), ); it.effect("reports a SIGKILL exit without an exit code", () => @@ -344,285 +345,272 @@ describe("DesktopBackendManager", () => { ); it.effect("reports child exit before waiting for trailing output to drain", () => - Effect.scoped( - Effect.gen(function* () { - const exitObserved = yield* Deferred.make(); - const finishOutputDrain = yield* Deferred.make(); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.fromEffect( - Deferred.await(finishOutputDrain).pipe( - Effect.as(new TextEncoder().encode("trailing output\n")), - ), + Effect.gen(function* () { + const exitObserved = yield* Deferred.make(); + const finishOutputDrain = yield* Deferred.make(); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.fromEffect( + Deferred.await(finishOutputDrain).pipe( + Effect.as(new TextEncoder().encode("trailing output\n")), ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - }), - ), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }), ), - ); + ), + ); - const runFiber = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid), - }).pipe( - Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), - Effect.forkChild, - ); - - yield* Deferred.await(exitObserved); - assert.isUndefined(runFiber.pollUnsafe()); - - yield* Deferred.succeed(finishOutputDrain, void 0); - assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1); - }), - ), + const runFiber = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid), + }).pipe(Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), Effect.forkChild); + + yield* Deferred.await(exitObserved); + assert.isUndefined(runFiber.pollUnsafe()); + + yield* Deferred.succeed(finishOutputDrain, void 0); + assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1); + }), ); it.effect("continues routing desktop telemetry control messages after an invalid line", () => - Effect.scoped( - Effect.gen(function* () { - const handled = yield* Deferred.make(); - const controlMessage = encodeDesktopTelemetryControl({ - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - getOutputFd: (fd) => - fd === 5 - ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`)) - : Stream.empty, - exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }), - ), + Effect.gen(function* () { + const handled = yield* Deferred.make(); + const controlMessage = encodeDesktopTelemetryControl({ + version: 1, + type: "setDiagnosticsDemand", + enabled: true, + }); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + getOutputFd: (fd) => + fd === 5 + ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`)) + : Stream.empty, + exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), ), - ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - desktopTelemetryPublisher: { - handleControlForSource: (_sourceId, message) => - message.type === "setDiagnosticsDemand" - ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid) - : Effect.void, - }, - }); + ), + ); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + desktopTelemetryPublisher: { + handleControlForSource: (_sourceId, message) => + message.type === "setDiagnosticsDemand" + ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid) + : Effect.void, + }, + }); - yield* instance.start; - assert.isTrue(yield* Deferred.await(handled)); - }), - ), + yield* instance.start; + assert.isTrue(yield* Deferred.await(handled)); + }), ); it.effect("stops the scoped process with output still pending", () => - Effect.scoped( - Effect.gen(function* () { - let startCount = 0; - let closedCount = 0; - const closed = yield* Deferred.make(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - const startedPids = yield* Queue.unbounded(); - const ready = yield* Deferred.make(); - const backendReadyFlag = yield* Ref.make(false); - let shutdownCount = 0; - let persistedFailureCount = 0; - let discardedSessionCount = 0; - let removedTelemetrySources = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - startCount += 1; - yield* Queue.offer(startedPids, 123); - const close = Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen( - Effect.sync(() => { - closedCount += 1; - }), - ), - Effect.andThen(Deferred.succeed(closed, void 0)), - Effect.asVoid, - ); + Effect.gen(function* () { + let startCount = 0; + let closedCount = 0; + const closed = yield* Deferred.make(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + const ready = yield* Deferred.make(); + const backendReadyFlag = yield* Ref.make(false); + let shutdownCount = 0; + let persistedFailureCount = 0; + let discardedSessionCount = 0; + let removedTelemetrySources = 0; - yield* Scope.addFinalizer(scope, close); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + startCount += 1; + yield* Queue.offer(startedPids, 123); + const close = Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen( + Effect.sync(() => { + closedCount += 1; + }), + ), + Effect.andThen(Deferred.succeed(closed, void 0)), + Effect.asVoid, + ); + + yield* Scope.addFinalizer(scope, close); + + return makeProcess({ + stdout: Stream.never, + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); - return makeProcess({ - stdout: Stream.never, - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - kill: () => close, - }); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + onReady: Ref.set(backendReadyFlag, true).pipe( + Effect.andThen(Deferred.succeed(ready, void 0)), + Effect.asVoid, + ), + onShutdown: Ref.set(backendReadyFlag, false).pipe( + Effect.andThen( + Effect.sync(() => { + shutdownCount += 1; }), ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - onReady: Ref.set(backendReadyFlag, true).pipe( - Effect.andThen(Deferred.succeed(ready, void 0)), - Effect.asVoid, - ), - onShutdown: Ref.set(backendReadyFlag, false).pipe( - Effect.andThen( - Effect.sync(() => { - shutdownCount += 1; - }), - ), - ), - backendOutputLog: { - persistFailure: () => - Effect.sync(() => { - persistedFailureCount += 1; - }), - discardSession: Effect.sync(() => { - discardedSessionCount += 1; + ), + backendOutputLog: { + persistFailure: () => + Effect.sync(() => { + persistedFailureCount += 1; }), - }, - desktopTelemetryPublisher: { - removeControlSource: () => - Effect.sync(() => { - removedTelemetrySources += 1; - }), - }, - }); - assert.isTrue(Option.isNone(yield* instance.currentConfig)); - - yield* instance.start; - assert.equal(yield* Queue.take(startedPids), 123); - yield* Deferred.await(ready); - assert.isTrue(yield* Ref.get(backendReadyFlag)); - assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); - - const runningSnapshot = yield* instance.snapshot; - assert.equal(runningSnapshot.ready, true); - assert.deepEqual(runningSnapshot.activePid, Option.some(123)); - - const stopFiber = yield* instance.stop().pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - assert.isFalse(yield* Ref.get(backendReadyFlag)); - assert.equal(shutdownCount, 1); - yield* Deferred.succeed(finishTeardown, undefined); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - assert.equal(startCount, 1); - assert.equal(closedCount, 1); - assert.equal(persistedFailureCount, 0); - assert.equal(discardedSessionCount, 1); - assert.equal(removedTelemetrySources, 1); - - const stoppedSnapshot = yield* instance.snapshot; - assert.isFalse(yield* Ref.get(backendReadyFlag)); - assert.equal(shutdownCount, 1); - assert.equal(stoppedSnapshot.desiredRunning, false); - assert.equal(stoppedSnapshot.ready, false); - assert.equal(Option.isNone(stoppedSnapshot.activePid), true); - }), - ), + discardSession: Effect.sync(() => { + discardedSessionCount += 1; + }), + }, + desktopTelemetryPublisher: { + removeControlSource: () => + Effect.sync(() => { + removedTelemetrySources += 1; + }), + }, + }); + assert.isTrue(Option.isNone(yield* instance.currentConfig)); + + yield* instance.start; + assert.equal(yield* Queue.take(startedPids), 123); + yield* Deferred.await(ready); + assert.isTrue(yield* Ref.get(backendReadyFlag)); + assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); + + const runningSnapshot = yield* instance.snapshot; + assert.equal(runningSnapshot.ready, true); + assert.deepEqual(runningSnapshot.activePid, Option.some(123)); + + const stopFiber = yield* instance.stop().pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + assert.isFalse(yield* Ref.get(backendReadyFlag)); + assert.equal(shutdownCount, 1); + yield* Deferred.succeed(finishTeardown, undefined); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); + assert.equal(startCount, 1); + assert.equal(closedCount, 1); + assert.equal(persistedFailureCount, 0); + assert.equal(discardedSessionCount, 1); + assert.equal(removedTelemetrySources, 1); + + const stoppedSnapshot = yield* instance.snapshot; + assert.isFalse(yield* Ref.get(backendReadyFlag)); + assert.equal(shutdownCount, 1); + assert.equal(stoppedSnapshot.desiredRunning, false); + assert.equal(stoppedSnapshot.ready, false); + assert.equal(Option.isNone(stoppedSnapshot.activePid), true); + }), ); it.effect("restarts when start is requested during stop teardown", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - let startCount = 0; + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); + let startCount = 0; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - const closed = yield* Deferred.make(); - startCount += 1; - yield* Queue.offer(starts, startCount); - if (startCount === 1) { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen(Deferred.succeed(closed, undefined)), - Effect.asVoid, - ), - ); - } else { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(closed, undefined).pipe(Effect.asVoid), - ); - } - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + startCount += 1; + yield* Queue.offer(starts, startCount); + if (startCount === 1) { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen(Deferred.succeed(closed, undefined)), + Effect.asVoid, + ), + ); + } else { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(closed, undefined).pipe(Effect.asVoid), + ); + } + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - }); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + httpClientLayer: layerHttpClient(() => Effect.never), + }); - yield* instance.start; - assert.equal(yield* Queue.take(starts), 1); + yield* instance.start; + assert.equal(yield* Queue.take(starts), 1); - const stopFiber = yield* instance.stop().pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - yield* instance.start; - assert.equal((yield* instance.snapshot).desiredRunning, true); + const stopFiber = yield* instance.stop().pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + yield* instance.start; + assert.equal((yield* instance.snapshot).desiredRunning, true); - yield* Deferred.succeed(finishTeardown, undefined); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - yield* TestClock.adjust(Duration.millis(500)); + yield* Deferred.succeed(finishTeardown, undefined); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); + yield* TestClock.adjust(Duration.millis(500)); - assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); - const restarted = yield* instance.snapshot; - assert.equal(restarted.desiredRunning, true); - assert.deepEqual(restarted.activePid, Option.some(123)); - }).pipe(Effect.provide(TestClock.layer())), - ), + assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); + const restarted = yield* instance.snapshot; + assert.equal(restarted.desiredRunning, true); + assert.deepEqual(restarted.activePid, Option.some(123)); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect("reports state-directory ownership once without scheduling another backend", () => - Effect.scoped( - Effect.gen(function* () { - let startCount = 0; - const refused = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Effect.succeed( - ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), - ), - }); - }), - ), + Effect.gen(function* () { + let startCount = 0; + const refused = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Effect.succeed( + ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), + ), + }); + }), ), - httpClientLayer: layerHttpClient(() => Effect.never), - onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* Deferred.await(refused); - const snapshot = yield* instance.snapshot; - assert.isFalse(snapshot.desiredRunning); - assert.isFalse(snapshot.restartScheduled); - yield* TestClock.adjust(Duration.minutes(1)); - assert.equal(startCount, 1); - }).pipe(Effect.provide(TestClock.layer())), - ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(refused); + const snapshot = yield* instance.snapshot; + assert.isFalse(snapshot.desiredRunning); + assert.isFalse(snapshot.restartScheduled); + yield* TestClock.adjust(Duration.minutes(1)); + assert.equal(startCount, 1); + }).pipe(Effect.provide(TestClock.layer())), ); // A policy refusal is reported on the first exit, since retrying cannot fix it. @@ -632,145 +620,167 @@ describe("DesktopBackendManager", () => { ])( "reports exit $code with the server output after $exits exits", ({ code, exits, managedPolicy }) => - 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]), + 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)}`, ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), - }), - ), + new Uint8Array([0xc3]), + new Uint8Array([0xa9]), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), + }), ), ), - httpClientLayer: layerHttpClient(() => Effect.never), - backendOutputLog: { - persistFailure: () => { - exitCount += 1; - return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); - }, + ), + 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, exits); - assert.equal(failure.reason, `The server could not start (code=${code}).`); - assert.equal(failure.managedPolicy, managedPolicy); - assert.isTrue(failure.output.endsWith(message)); - assert.isTrue(failure.output.isWellFormed()); - }).pipe(Effect.provide(TestClock.layer())), - ), + }, + 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, exits); + assert.equal(failure.reason, `The server could not start (code=${code}).`); + assert.equal(failure.managedPolicy, managedPolicy); + assert.isTrue(failure.output.endsWith(message)); + assert.isTrue(failure.output.isWellFormed()); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect("waits out a slow startup and reports one that never becomes ready", () => - Effect.scoped( - Effect.gen(function* () { - const reports = yield* Queue.unbounded(); - const startInstance = (status: Ref.Ref, onReady?: Effect.Effect) => - makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed(makeProcess({ exitCode: Effect.never })), + Effect.gen(function* () { + const reports = yield* Queue.unbounded(); + const startInstance = (status: Ref.Ref, onReady?: Effect.Effect, stdout = "") => + makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Effect.never, + stdout: Stream.make(new TextEncoder().encode(stdout)), + }), ), ), - httpClientLayer: layerHttpClient((request) => - Ref.get(status).pipe(Effect.map((code) => responseForRequest(request, code))), - ), - onStartupFailure: (failure) => Queue.offer(reports, failure).pipe(Effect.asVoid), - ...(onReady ? { onReady } : {}), - }).pipe(Effect.tap((instance) => instance.start)); - - const slowStatus = yield* Ref.make(503); - const ready = yield* Deferred.make(); - yield* startInstance(slowStatus, Deferred.succeed(ready, undefined).pipe(Effect.asVoid)); - yield* TestClock.adjust(Duration.seconds(66)); - yield* Ref.set(slowStatus, 200); - yield* TestClock.adjust(Duration.millis(100)); - yield* Deferred.await(ready); - assert.isTrue(Option.isNone(yield* Queue.poll(reports))); - - yield* startInstance(yield* Ref.make(503)); - yield* TestClock.adjust(Duration.seconds(179)); - assert.isTrue(Option.isNone(yield* Queue.poll(reports))); - yield* TestClock.adjust(Duration.seconds(1)); - const failure = yield* Queue.take(reports); - assert.equal(failure.reason, "The server started but did not become ready in time."); - }).pipe(Effect.provide(TestClock.layer())), - ), + ), + httpClientLayer: layerHttpClient((request) => + Ref.get(status).pipe(Effect.map((code) => responseForRequest(request, code))), + ), + onStartupFailure: (failure) => Queue.offer(reports, failure).pipe(Effect.asVoid), + ...(onReady ? { onReady } : {}), + }).pipe(Effect.tap((instance) => instance.start)); + + const slowStatus = yield* Ref.make(503); + const ready = yield* Deferred.make(); + yield* startInstance(slowStatus, Deferred.succeed(ready, undefined).pipe(Effect.asVoid)); + yield* TestClock.adjust(Duration.seconds(66)); + yield* Ref.set(slowStatus, 200); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(ready); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + + const migratingStatus = yield* Ref.make(503); + const migrated = yield* Deferred.make(); + yield* startInstance( + migratingStatus, + Deferred.succeed(migrated, undefined).pipe(Effect.asVoid), + // Enough progress lines that the start line scrolls out of the output tail. + `INFO ${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n` + + Array.from( + { length: 80 }, + (_, index) => + `INFO ${DATABASE_MIGRATION_PROGRESS_MESSAGE} migration=${index}_Migration progress="${index + 1} of 80"\n`, + ).join(""), + ); + yield* TestClock.adjust(Duration.minutes(10)); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + yield* Ref.set(migratingStatus, 200); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(migrated); + + yield* startInstance( + yield* Ref.make(503), + undefined, + `${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n${DATABASE_MIGRATIONS_FINISHED_MESSAGE}\n`, + ); + yield* TestClock.adjust(Duration.seconds(179)); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + yield* TestClock.adjust(Duration.seconds(1)); + const failure = yield* Queue.take(reports); + assert.equal(failure.reason, "The server started but did not become ready in time."); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect("the quit stop keeps waiting for a backend that outlasts its deadline", () => - Effect.scoped( - Effect.gen(function* () { - const spawned = yield* Deferred.make(); - const teardownStarted = yield* Deferred.make(); - const teardownFinished = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - yield* Deferred.succeed(spawned, undefined); - yield* Scope.addFinalizer( - scope, - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Effect.sleep(Duration.seconds(10))), - Effect.andThen(Deferred.succeed(teardownFinished, undefined)), - Effect.asVoid, - ), - ); - return makeProcess({ - exitCode: Deferred.await(teardownFinished).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - }); - }), - ), + Effect.gen(function* () { + const spawned = yield* Deferred.make(); + const teardownStarted = yield* Deferred.make(); + const teardownFinished = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + yield* Deferred.succeed(spawned, undefined); + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Effect.sleep(Duration.seconds(10))), + Effect.andThen(Deferred.succeed(teardownFinished, undefined)), + Effect.asVoid, + ), + ); + return makeProcess({ + exitCode: Deferred.await(teardownFinished).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }); + }), ), - httpClientLayer: layerHttpClient(() => Effect.never), - }); - yield* instance.start; - yield* Deferred.await(spawned); - - // App quit and the updater stop every backend with this deadline. - const quitDone = yield* Deferred.make(); - const quitFiber = yield* instance - .stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }) - .pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild); - - yield* Deferred.await(teardownStarted); - // Past the quit deadline, quit must still wait for the backend to exit. - yield* TestClock.adjust(Duration.seconds(9)); - assert.isFalse(yield* Deferred.isDone(quitDone)); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* Fiber.join(quitFiber); - assert.isTrue(yield* Deferred.isDone(teardownFinished)); - }).pipe(Effect.provide(TestClock.layer())), - ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + }); + yield* instance.start; + yield* Deferred.await(spawned); + + // App quit and the updater stop every backend with this deadline. + const quitDone = yield* Deferred.make(); + const quitFiber = yield* instance + .stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }) + .pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild); + + yield* Deferred.await(teardownStarted); + // Past the quit deadline, quit must still wait for the backend to exit. + yield* TestClock.adjust(Duration.seconds(9)); + assert.isFalse(yield* Deferred.isDone(quitDone)); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* Fiber.join(quitFiber); + assert.isTrue(yield* Deferred.isDone(teardownFinished)); + }).pipe(Effect.provide(TestClock.layer())), ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index d24b2f52f295..89ebc9e862ec 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -25,6 +25,7 @@ import { DesktopTelemetryControlMessage, type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue, } from "@t3tools/contracts"; +import { isRunningDatabaseMigrations } from "@t3tools/shared/databaseMigrationLog"; import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; import * as DesktopObservability from "../app/DesktopObservability.ts"; @@ -204,7 +205,7 @@ interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly outputDrainTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; readonly onExitObserved?: () => Effect.Effect; - readonly onReady?: () => Effect.Effect; + readonly onReady?: (pid: number) => Effect.Effect; readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, @@ -251,8 +252,8 @@ export interface BackendInstanceSpec { readonly id: BackendInstanceId; readonly label: Effect.Effect; readonly configResolve: Effect.Effect; - /** Receives the ready run's resolved URL; reading current config could race a subsequent restart. */ - readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; + /** Receives the ready run's URL and pid; reading current config could race a subsequent restart. */ + readonly onReady?: (httpBaseUrl: URL, pid: number) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; readonly onStateDirOwned?: () => Effect.Effect; @@ -577,7 +578,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( httpBaseUrl: options.httpBaseUrl, timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, }).pipe( - Effect.flatMap(() => options.onReady?.() ?? Effect.void), + Effect.flatMap(() => options.onReady?.(handle.pid) ?? Effect.void), Effect.as(true), Effect.catchTags({ BackendReadinessTimeoutError: (error) => @@ -938,7 +939,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...run, exitObserved: true, })), - onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { + onReady: Effect.fn("desktop.backendInstance.onReady")(function* (pid) { const isCurrentRun = yield* SubscriptionRef.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { @@ -958,7 +959,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } - yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; + yield* spec.onReady?.(config.value.httpBaseUrl, pid) ?? Effect.void; if ( config.value.runningDistro !== undefined && config.value.wslRuntimeId !== undefined @@ -971,6 +972,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }), onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( function* (error) { + if (isRunningDatabaseMigrations(yield* Ref.get(outputTail))) { + yield* logInstanceWarning("backend is still migrating its database; waiting", { + error: error.message, + }); + return; + } yield* logInstanceWarning("backend readiness check failed during bootstrap", { error: error.message, }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index c46e0b3a2607..8e9f4320c08c 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -1,5 +1,5 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { DESKTOP_UPDATE_IN_PROGRESS_TTL_MS } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -110,27 +110,52 @@ const runningConfig = { function layerPool( labelRef: Ref.Ref, - backend?: Effect.Success>, + options: { + readonly config?: DesktopBackendStartConfig; + readonly fileSystem?: Partial; + readonly spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly httpClient?: HttpClient.HttpClient; + readonly onReady?: Effect.Effect; + readonly backend?: Effect.Success>; + } = {}, ): Layer.Layer { return DesktopBackendPool.layer.pipe( Layer.provideMerge( Layer.mergeAll( - FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), + DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: "/test-home", + platform: "linux", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(Path.layer, DesktopConfig.layerTest({ T3CODE_HOME: "/test-home" })), + ), + Layer.orDie, + ), + FileSystem.layerNoop(options.fileSystem ?? { exists: () => Effect.succeed(true) }), Path.layer, - backend?.layer ?? + options.backend?.layer ?? Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), + options.spawner ?? + ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), ), Layer.succeed( HttpClient.HttpClient, - HttpClient.make((request) => - backend === undefined - ? Effect.die("unexpected HTTP request") - : Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), - ), - ), + options.httpClient ?? + HttpClient.make((request) => + options.backend === undefined + ? Effect.die("unexpected HTTP request") + : Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ), + ), ), Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { forInstance: () => @@ -155,26 +180,14 @@ function layerPool( }), DesktopBrowserHost.layer, Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { - resolvePrimary: Effect.die("unexpected primary config resolve"), + resolvePrimary: options.config + ? Effect.succeed(options.config) + : Effect.die("unexpected primary config resolve"), resolvePrimaryLabel: Ref.get(labelRef), resolveWsl: () => Effect.die("unexpected WSL config resolve"), currentBootstrapToken: Effect.die("unexpected bootstrap token read"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), - DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: "/home/test", - platform: "linux", - processArch: "x64", - appVersion: "0.0.0", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), - Layer.orDie, - ), DesktopState.layer, DesktopWslEnvironment.layerTest(), ElectronDialog.layer, @@ -186,7 +199,7 @@ function layerPool( activate: Effect.die("unexpected window activate"), createMainIfBackendReady: Effect.die("unexpected window create"), showConnectingSplash: Effect.void, - handleBackendReady: () => Effect.void, + handleBackendReady: () => options.onReady ?? Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, prepareCaptureReveal: Effect.void, @@ -201,86 +214,268 @@ function layerPool( ); } +const markerPath = "/test-home/runtime/desktop-update-in-progress"; +const runtimePath = "/test-home/userdata/server-runtime.json"; + +const makeUpdateMarkerFixture = () => + Effect.gen(function* () { + const allowReady = yield* Deferred.make(); + const runtimeChecked = yield* Deferred.make(); + const markerRemoved = yield* Deferred.make(); + const readyRuns = yield* Queue.unbounded(); + let nextPid = 123; + const files = new Map([[markerPath, JSON.stringify({ startedAt: 0, expiresAt: 60_000 })]]); + const removed: string[] = []; + const config: DesktopBackendStartConfig = { + executablePath: "/electron", + args: [], + entryPath: "/server/bin.mjs", + cwd: "/server", + env: {}, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3774, + t3Home: "/test-home", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + bootstrapDelivery: "fd3", + extendEnv: true, + httpBaseUrl: new URL("http://127.0.0.1:3774"), + captureOutput: false, + preflightFailure: Option.none(), + }; + const layer = layerPool(Ref.makeUnsafe("Local"), { + config, + fileSystem: { + exists: (path) => Effect.succeed(files.has(path) || path === config.entryPath), + readFileString: (path) => + (path === runtimePath ? Deferred.succeed(runtimeChecked, undefined) : Effect.void).pipe( + Effect.andThen(Effect.sync(() => files.get(path) ?? "")), + ), + remove: (path) => + Effect.sync(() => { + removed.push(path); + files.delete(path); + }).pipe( + Effect.andThen( + path === markerPath ? Deferred.succeed(markerRemoved, undefined) : Effect.void, + ), + Effect.asVoid, + ), + }, + spawner: ChildProcessSpawner.make(() => + Effect.gen(function* () { + const exited = yield* Deferred.make(); + const exit = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( + Effect.asVoid, + ); + // Like the real spawner, closing the run's scope ends the process. + yield* Effect.addFinalizer(() => exit); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(nextPid++), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exited), + isRunning: Effect.succeed(true), + kill: () => exit, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), + ), + httpClient: HttpClient.make((request) => + Deferred.await(allowReady).pipe( + Effect.as(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), + ), + onReady: Queue.offer(readyRuns, undefined).pipe(Effect.asVoid), + }); + const publishRuntime = (pid: number) => + files.set( + runtimePath, + JSON.stringify({ + version: 1, + pid, + port: 3774, + origin: "http://127.0.0.1:3774", + startedAt: "2026-10-27T00:00:00.000Z", + }), + ); + return { + layer, + files, + removed, + allowReady, + readyRuns, + runtimeChecked, + markerRemoved, + publishRuntime, + }; + }); + describe("DesktopBackendPool", () => { - it.effect("unregister during pool quit does not interrupt sibling stops", () => - Effect.scoped( + it.effect( + "keeps the update marker after HTTP readiness until the backend publishes its runtime", + () => Effect.gen(function* () { - const backend = yield* makeFakeBackendProcess(); + const fixture = yield* makeUpdateMarkerFixture(); yield* Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; - const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); - const ready = yield* Deferred.make(); - const siblingStarted = yield* Deferred.make(); - const finishSibling = yield* Deferred.make(); - let siblingCompleted = false; - const instance = yield* pool.register({ - id, - label: Effect.succeed("WSL (Ubuntu)"), - configResolve: Effect.succeed(runningConfig), - onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* backend.started; - yield* Deferred.await(ready); - const quitFiber = yield* Effect.all( - [ - instance.stop({ timeout: Duration.seconds(5) }), - Deferred.succeed(siblingStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishSibling)), - Effect.andThen( - Effect.sync(() => { - siblingCompleted = true; - }), - ), - ), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - yield* Deferred.await(siblingStarted); - assert.equal(yield* Queue.take(backend.signals), "SIGTERM"); - const unregisterFiber = yield* pool.unregister(id).pipe(Effect.forkChild); + const primary = yield* pool.primary; + yield* primary.start; + yield* Deferred.succeed(fixture.allowReady, undefined); + yield* Queue.take(fixture.readyRuns); + assert.isTrue( + fixture.files.has(markerPath), + "a launcher could neither wait nor adopt between readiness and publication", + ); + yield* Deferred.await(fixture.runtimeChecked); yield* TestClock.adjust(Duration.seconds(5)); - assert.equal(yield* Queue.take(backend.signals), "SIGKILL"); - assert.isUndefined(quitFiber.pollUnsafe()); - assert.isUndefined(unregisterFiber.pollUnsafe()); - assert.isTrue(yield* backend.isRunning); - yield* TestClock.adjust(backend.killWait); - yield* Fiber.join(unregisterFiber); - yield* Deferred.succeed(finishSibling, undefined); - yield* Fiber.join(quitFiber); - - assert.isTrue(siblingCompleted); - assert.isFalse(yield* backend.isRunning); - assert.isTrue(Option.isNone((yield* instance.snapshot).activePid)); - assert.isTrue(Option.isNone(yield* pool.get(id))); - }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), backend))); + assert.isTrue(fixture.files.has(markerPath)); + fixture.publishRuntime(456); + yield* TestClock.adjust(Duration.seconds(1)); + assert.isTrue( + fixture.files.has(markerPath), + "another process's runtime is not the replacement", + ); + fixture.publishRuntime(123); + yield* TestClock.adjust(Duration.seconds(1)); + yield* Deferred.await(fixture.markerRemoved); + assert.deepEqual(fixture.removed, [markerPath]); + }).pipe(Effect.provide(fixture.layer)); }), - ), ); - it.effect("unregister stops the registered instance", () => - Effect.scoped( - Effect.gen(function* () { - const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + it.effect("leaves a replacement update marker written while an earlier wait is pending", () => + Effect.gen(function* () { + const fixture = yield* makeUpdateMarkerFixture(); + yield* Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primary = yield* pool.primary; + yield* primary.start; + yield* Deferred.succeed(fixture.allowReady, undefined); + yield* Queue.take(fixture.readyRuns); + yield* Deferred.await(fixture.runtimeChecked); + yield* TestClock.adjust(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS - 1_000)); + const replacement = JSON.stringify({ startedAt: 59_000, expiresAt: 119_000 }); + fixture.files.set(markerPath, replacement); + yield* TestClock.adjust(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS)); + assert.equal(fixture.files.get(markerPath), replacement); + assert.deepEqual(fixture.removed, []); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + + it.effect("a later backend run's readiness supersedes the earlier run's marker wait", () => + Effect.gen(function* () { + const fixture = yield* makeUpdateMarkerFixture(); + yield* Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primary = yield* pool.primary; + yield* primary.start; + yield* Deferred.succeed(fixture.allowReady, undefined); + yield* Queue.take(fixture.readyRuns); + yield* Deferred.await(fixture.runtimeChecked); + yield* TestClock.adjust(Duration.seconds(30)); + yield* primary.stop(); + yield* primary.start; + yield* Queue.take(fixture.readyRuns); + yield* TestClock.adjust(Duration.seconds(31)); + assert.isTrue( + fixture.files.has(markerPath), + "the earlier run's wait must not end the later run's handoff", + ); + assert.deepEqual(fixture.removed, []); + fixture.publishRuntime(124); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(fixture.markerRemoved); + assert.deepEqual(fixture.removed, [markerPath]); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + + it.effect("unregister during pool quit does not interrupt sibling stops", () => + Effect.gen(function* () { + const backend = yield* makeFakeBackendProcess(); + yield* Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + const ready = yield* Deferred.make(); + const siblingStarted = yield* Deferred.make(); + const finishSibling = yield* Deferred.make(); + let siblingCompleted = false; const instance = yield* pool.register({ id, label: Effect.succeed("WSL (Ubuntu)"), - configResolve: Effect.succeed({ - ...runningConfig, - preflightFailure: Option.some({ reason: "WSL starting", fatal: false }), - }), + configResolve: Effect.succeed(runningConfig), + onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid), }); yield* instance.start; - assert.equal((yield* instance.snapshot).restartScheduled, true); + yield* backend.started; + yield* Deferred.await(ready); + const quitFiber = yield* Effect.all( + [ + instance.stop({ timeout: Duration.seconds(5) }), + Deferred.succeed(siblingStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishSibling)), + Effect.andThen( + Effect.sync(() => { + siblingCompleted = true; + }), + ), + ), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(siblingStarted); + assert.equal(yield* Queue.take(backend.signals), "SIGTERM"); + const unregisterFiber = yield* pool.unregister(id).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(5)); + assert.equal(yield* Queue.take(backend.signals), "SIGKILL"); + assert.isUndefined(quitFiber.pollUnsafe()); + assert.isUndefined(unregisterFiber.pollUnsafe()); + assert.isTrue(yield* backend.isRunning); + yield* TestClock.adjust(backend.killWait); + yield* Fiber.join(unregisterFiber); + yield* Deferred.succeed(finishSibling, undefined); + yield* Fiber.join(quitFiber); - yield* pool.unregister(id); + assert.isTrue(siblingCompleted); + assert.isFalse(yield* backend.isRunning); + assert.isTrue(Option.isNone((yield* instance.snapshot).activePid)); + assert.isTrue(Option.isNone(yield* pool.get(id))); + }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), { backend }))); + }), + ); - const stopped = yield* instance.snapshot; - assert.equal(stopped.desiredRunning, false); - assert.equal(stopped.restartScheduled, false); - assert.equal(Option.isNone(yield* pool.get(id)), true); - }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows")))), - ), + it.effect("unregister stops the registered instance", () => + Effect.gen(function* () { + const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instance = yield* pool.register({ + id, + label: Effect.succeed("WSL (Ubuntu)"), + configResolve: Effect.succeed({ + ...runningConfig, + preflightFailure: Option.some({ reason: "WSL starting", fatal: false }), + }), + }); + yield* instance.start; + assert.equal((yield* instance.snapshot).restartScheduled, true); + + yield* pool.unregister(id); + + const stopped = yield* instance.snapshot; + assert.equal(stopped.desiredRunning, false); + assert.equal(stopped.restartScheduled, false); + assert.equal(Option.isNone(yield* pool.get(id)), true); + }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows")))), ); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 82d542b322c8..d69e1ce1427f 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -1,10 +1,17 @@ +import { + DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE, + DESKTOP_UPDATE_IN_PROGRESS_TTL_MS, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FiberHandle from "effect/FiberHandle"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -31,6 +38,10 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; const { logWarning: logBackendPoolWarning } = DesktopObservability.makeComponentLogger("desktop-backend-pool"); +const decodeServerRuntime = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ pid: Schema.Number, port: Schema.Number })), +); + export type BackendInstanceId = DesktopBackendManager.BackendInstanceId; export const BackendInstanceId = DesktopBackendManager.BackendInstanceId; export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID; @@ -106,6 +117,8 @@ type UnregisterAction = export const layer = Layer.effect( DesktopBackendPool, Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const desktopWindow = yield* DesktopWindow.DesktopWindow; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -119,6 +132,41 @@ export const layer = Layer.effect( FileSystem.FileSystem | Path.Path | DesktopEnvironment.DesktopEnvironment >(); + const updateMarkerPath = environment.path.join( + environment.baseDir, + "runtime", + DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE, + ); + const serverRuntimePath = environment.path.join(environment.stateDir, "server-runtime.json"); + // The server publishes its runtime after HTTP readiness. Until then an SSH launcher + // finds nothing to adopt, so dropping the marker earlier lets it start a competing server. + const releaseUpdateMarker = Effect.fn("desktop.backendPool.releaseUpdateMarker")(function* ( + httpBaseUrl: URL, + pid: number, + ) { + const marker = yield* fileSystem.readFileString(updateMarkerPath).pipe(Effect.option); + if (Option.isNone(marker)) { + return; + } + yield* fileSystem.readFileString(serverRuntimePath).pipe( + Effect.flatMap(decodeServerRuntime), + Effect.map((runtime) => runtime.pid === pid && runtime.port === Number(httpBaseUrl.port)), + Effect.orElseSucceed(() => false), + Effect.repeat({ + until: (published) => published, + schedule: Schedule.spaced(Duration.millis(100)), + }), + Effect.timeoutOption(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS)), + ); + // A later update may have replaced the marker; that handoff is not ours to end. + const current = yield* fileSystem.readFileString(updateMarkerPath).pipe(Effect.option); + if (Option.isSome(current) && current.value === marker.value) { + yield* fileSystem.remove(updateMarkerPath, { force: true }).pipe(Effect.ignore); + } + }); + // One wait at a time: each run's shutdown or readiness ends the previous run's wait. + const updateMarkerWait = yield* FiberHandle.make(); + // Persist fatal Windows fallbacks; transient transport failures must preserve the user's WSL preference. const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( function* (failure: DesktopBackendManager.PreflightFailure) { @@ -165,8 +213,9 @@ export const layer = Layer.effect( label: configuration.resolvePrimaryLabel, configResolve: configuration.resolvePrimary, // Absorb window errors so readiness can reset restartAttempt. - onReady: (httpBaseUrl) => - desktopWindow.handleBackendReady(httpBaseUrl).pipe( + onReady: (httpBaseUrl, pid) => + FiberHandle.run(updateMarkerWait, releaseUpdateMarker(httpBaseUrl, pid)).pipe( + Effect.andThen(desktopWindow.handleBackendReady(httpBaseUrl)), // Only once the main window exists, so the recovery window never closes into nothing. Effect.andThen(SubscriptionRef.set(primaryBackendStartup, { _tag: "Ready" })), Effect.catch((error) => @@ -177,7 +226,10 @@ export const layer = Layer.effect( ), onStartupFailure: (failure) => SubscriptionRef.set(primaryBackendStartup, { _tag: "Failing", ...failure }), - onShutdown: () => desktopWindow.handleBackendNotReady, + onShutdown: () => + FiberHandle.clear(updateMarkerWait).pipe( + Effect.andThen(desktopWindow.handleBackendNotReady), + ), onPreflightFailed: handlePrimaryPreflightFailure, onStateDirOwned: () => DesktopHomeOwner.readLiveHomeOwner().pipe( diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 054655917cec..397a75770483 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; @@ -79,4 +79,41 @@ describe("tailscale endpoint provider", () => { ); }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); + + it.effect("offers Tailscale HTTPS only when Serve reaches this desktop's backend", () => + Effect.gen(function* () { + const magicDnsStatus = (servedEnvironmentId: string) => + resolveTailscaleAdvertisedEndpoints({ + port: 3773, + serveEnabled: true, + networkInterfaces: {}, + statusJson: `{"Self":{"DNSName":"desktop.example.ts.net.","TailscaleIPs":[]}}`, + }).pipe( + Effect.map((endpoints) => endpoints.map((endpoint) => endpoint.status)), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + environmentId: + new URL(request.url).hostname === "127.0.0.1" + ? "desktop-backend" + : servedEnvironmentId, + label: "T3 server", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.1.9", + capabilities: { repositoryIdentity: true }, + }), + ), + ), + ), + ), + ); + + assert.deepEqual(yield* magicDnsStatus("desktop-backend"), ["available"]); + assert.deepEqual(yield* magicDnsStatus("another-server"), ["unavailable"]); + }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), + ); }); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 4b75afccdb5b..1b8d9dff4ed4 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -1,5 +1,9 @@ import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; -import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; +import { + ExecutionEnvironmentDescriptor, + type AdvertisedEndpoint, + type AdvertisedEndpointProvider, +} from "@t3tools/contracts"; import { buildTailscaleHttpsBaseUrl, isTailscaleIpv4Address, @@ -11,6 +15,7 @@ import { import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; @@ -71,10 +76,20 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { return endpoints; } +// The backend may run elsewhere (WSL), so ask it for its id instead of reading the state dir. +const probeServesThisBackend = (localHttpUrl: string) => (baseUrl: string) => + HttpClient.get(new URL("/.well-known/t3/environment", localHttpUrl)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), + Effect.flatMap(({ environmentId }) => probeTailscaleHttpsEndpoint({ baseUrl, environmentId })), + Effect.orElseSucceed(() => false), + ); + const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( "resolveTailscaleMagicDnsAdvertisedEndpoint", )(function* (input: { readonly dnsName: string | null; + readonly localHttpUrl: string; readonly serveEnabled: boolean; readonly servePort?: number; readonly probe?: (baseUrl: string) => Effect.Effect; @@ -87,11 +102,7 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( magicDnsName: input.dnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), }); - const probe = - input.probe?.(httpBaseUrl) ?? - probeTailscaleHttpsEndpoint({ - baseUrl: httpBaseUrl, - }); + const probe = (input.probe ?? probeServesThisBackend(input.localHttpUrl))(httpBaseUrl); const isReachable = input.serveEnabled ? yield* probe : false; return Option.some( @@ -140,6 +151,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints({ ...input, status }); const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ dnsName: status?.magicDnsName ?? null, + localHttpUrl: `http://127.0.0.1:${input.port}`, serveEnabled: input.serveEnabled === true, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), ...(input.probe === undefined ? {} : { probe: input.probe }), diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index aec3a2d5b45c..b0cf4823126e 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -63,7 +63,7 @@ describe("ElectronProtocol", () => { assert.equal((yield* request("/%invalid")).status, 400); assert.equal((yield* request("/", { method: "POST" })).status, 405); assert.equal(netFetchMock.mock.calls.length, 0); - }).pipe(Effect.provide(Layer.merge(layerProtocol, NodeServices.layer)), Effect.scoped), + }).pipe(Effect.provide(Layer.merge(layerProtocol, NodeServices.layer))), ); it.effect("proxies the stable renderer origin to the current app server", () => diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9864c19b0d0a..e8618b4f5cc4 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -54,18 +54,30 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); - it.effect("opens only the bare Grok Bot launch link", () => + it.effect("opens only the Grok Bot prompt link", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); + const prompt = "grokbot://app/v1/prompt?text=Add%20the%20MCP%20server"; const electronShell = yield* ElectronShell.ElectronShell; const results = yield* Effect.forEach( - ["grokbot://app", "grokbot://app/v1/prompt?id=bot&text=hi", "grokbot://"], + [ + prompt, + "grokbot://app", + "grokbot://app/v1/prompt?id=bot&text=hi", + "grokbot://app/v1/prompt?text=a&text=b", + "grokbot://app/v1/other?text=hi", + "grokbot://app/v1/prompt?text=hi#x", + "grokbot://:secret@app/v1/prompt?text=hi", + "grokbot://user@app/v1/prompt?text=hi", + "grokbot://other/v1/prompt?text=hi", + "other://app/v1/prompt?text=hi", + ], (url) => electronShell.openExternal(url), ); - assert.deepEqual(results, [true, false, false]); - assert.deepEqual(openExternalMock.mock.calls, [["grokbot://app"]]); + assert.deepEqual(results, [true, ...Array(9).fill(false)]); + assert.deepEqual(openExternalMock.mock.calls, [[prompt]]); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index e342361750f4..d27c51d57c6e 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -21,9 +21,16 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); -// "Use T3 Code in another app" opens these apps (apps/web/src/components/mcp/mcpApps.ts). List +// "Use T3 Code in another app" opens these apps (apps/web/src/components/mcp/mcpApps.ts). Match // each exact link shape the wizard sends, never a whole scheme. -const APP_LAUNCH_URLS = new Set(["grokbot://app"]); +const isAppLaunchUrl = (url: URL) => + url.protocol === "grokbot:" && + url.host === "app" && + url.pathname === "/v1/prompt" && + url.username.length === 0 && + url.password.length === 0 && + url.hash.length === 0 && + [...url.searchParams.keys()].join() === "text"; // Zed embeds [user@]host in the path; reject passwords and ports there. const ZED_SSH_PATHNAME = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/; @@ -50,7 +57,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); return SAFE_WEB_PROTOCOLS.has(url.protocol) || - APP_LAUNCH_URLS.has(url.href) || + isAppLaunchUrl(url) || isRemoteEditorUrl(url) || isLocalEditorFileUrl(url) ? Option.some(url.href) diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4239bfe4c5bc..47aa0ce1f8df 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -42,7 +42,7 @@ describe("ElectronTheme", () => { assert.equal(nativeTheme.themeSource, "light"); portal.report(null); assert.equal(nativeTheme.themeSource, "system"); - }).pipe(Effect.scoped), + }), ); it.effect("keeps an explicit appearance over the desktop color scheme", () => @@ -57,7 +57,7 @@ describe("ElectronTheme", () => { yield* theme.setSource("system"); assert.equal(nativeTheme.themeSource, "dark"); - }).pipe(Effect.scoped), + }), ); it.effect("stops following the desktop when the layer is released", () => @@ -76,6 +76,6 @@ describe("ElectronTheme", () => { }); yield* theme.setSource("system"); assert.equal(nativeTheme.themeSource, "system"); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index 45f63b951f36..1973ab49cefe 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -63,5 +63,5 @@ it.effect("answers only the main window's top frame", () => appSubframe: ["rejected", undefined], previewGuest: ["rejected", undefined], }); - }).pipe(Effect.scoped), + }), ); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5a1d1bdc2fd9..e5a2565aafe8 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -88,6 +88,7 @@ export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webvie export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate"; export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back"; export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward"; +export const PREVIEW_STOP_LOADING_CHANNEL = "desktop:preview-stop-loading"; export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh"; export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index ea291b66b314..cc810fee072a 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -168,6 +168,11 @@ const refresh = tabMethod( "desktop.ipc.preview.refresh", (manager, tabId) => manager.refresh(tabId), ); +const stopLoading = tabMethod( + IpcChannels.PREVIEW_STOP_LOADING_CHANNEL, + "desktop.ipc.preview.stopLoading", + (manager, tabId) => manager.stopLoading(tabId), +); const zoomIn = tabMethod( IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, "desktop.ipc.preview.zoomIn", @@ -472,6 +477,7 @@ export const methods = [ goBack, goForward, refresh, + stopLoading, zoomIn, zoomOut, resetZoom, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c7a530da4fc9..d4f5e27261eb 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -406,6 +406,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }), goForward: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, { tabId }), refresh: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_REFRESH_CHANNEL, { tabId }), + stopLoading: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_STOP_LOADING_CHANNEL, { tabId }), zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }), zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview/AnnotationStroke.test.ts b/apps/desktop/src/preview/AnnotationStroke.test.ts new file mode 100644 index 000000000000..ad8a21c140b4 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationStroke.test.ts @@ -0,0 +1,73 @@ +import type { PreviewAnnotationPoint } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + addStrokePoint, + openStrokePath, + startStroke, + strokeBounds, + STROKE_SEGMENTS_PER_PATH, +} from "./AnnotationStroke.ts"; + +// The Draw tool used to rebuild the whole path and rescan every point on each move. +const fullPath = (points: ReadonlyArray): string => { + let path = `M ${points[0]!.x} ${points[0]!.y}`; + for (let index = 1; index < points.length - 1; index += 1) { + const current = points[index]!; + const next = points[index + 1]!; + path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; + } + const last = points[points.length - 1]!; + return `${path} L ${last.x} ${last.y}`; +}; + +const fullBounds = (points: ReadonlyArray, width: number) => { + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const padding = width + 3; + const left = Math.min(...xs) - padding; + const top = Math.min(...ys) - padding; + const right = Math.max(...xs) + padding; + const bottom = Math.max(...ys) + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +}; + +// Each later path starts with a move to where the previous one ended. +const joinPaths = (paths: ReadonlyArray): string => + paths + .map((path, index) => { + if (index === 0) return path; + const resume = path.match(/^M (\S+ \S+)/)![1]!; + expect(paths[index - 1]!.endsWith(` ${resume}`)).toBe(true); + return path.slice(`M ${resume}`.length); + }) + .join(""); + +describe("stroke geometry", () => { + it("draws the same curve and bounds as a full rescan at every length", () => { + const points = Array.from({ length: STROKE_SEGMENTS_PER_PATH * 2 + 30 }, (_, index) => ({ + x: index * 3 + ((index * 7) % 11), + y: 200 + ((index * 13) % 29) - index, + })); + const geometry = startStroke(points[0]!); + const closedPaths: string[] = []; + for (let index = 1; index < points.length; index += 1) { + const closedPath = addStrokePoint(geometry, points[index]!); + if (closedPath !== null) closedPaths.push(closedPath); + const drawn = points.slice(0, index + 1); + expect(joinPaths([...closedPaths, openStrokePath(geometry)])).toBe(fullPath(drawn)); + expect(strokeBounds(geometry, 4)).toEqual(fullBounds(drawn, 4)); + } + expect(closedPaths).toHaveLength(2); + expect(geometry.points).toEqual(points); + }); + + it("keeps the rewritten path short for a stroke past the engine's argument limit", () => { + const geometry = startStroke({ x: 0, y: 0 }); + for (let index = 1; index < 250_000; index += 1) { + addStrokePoint(geometry, { x: index % 800, y: index % 600 }); + } + expect(strokeBounds(geometry, 4)).toEqual({ x: -7, y: -7, width: 813, height: 613 }); + expect(openStrokePath(geometry).length).toBeLessThan(STROKE_SEGMENTS_PER_PATH * 40); + }); +}); diff --git a/apps/desktop/src/preview/AnnotationStroke.ts b/apps/desktop/src/preview/AnnotationStroke.ts new file mode 100644 index 000000000000..66510fc0b7cf --- /dev/null +++ b/apps/desktop/src/preview/AnnotationStroke.ts @@ -0,0 +1,69 @@ +import type { PreviewAnnotationPoint, PreviewAnnotationRect } from "@t3tools/contracts"; + +// Setting a path's `d` makes Chromium parse the whole string again, so a long +// stroke is drawn as a run of paths and only the last one is rewritten per move. +export const STROKE_SEGMENTS_PER_PATH = 100; + +export interface StrokeGeometry { + readonly points: PreviewAnnotationPoint[]; + minX: number; + minY: number; + maxX: number; + maxY: number; + // The curve through the newest point is still open: where it ends depends on + // the point after it. This holds only the settled segments of the last path. + openPath: string; + openSegments: number; +} + +export function startStroke(point: PreviewAnnotationPoint): StrokeGeometry { + return { + points: [point], + minX: point.x, + minY: point.y, + maxX: point.x, + maxY: point.y, + openPath: `M ${point.x} ${point.y}`, + openSegments: 0, + }; +} + +// Returns the final `d` of the path this point filled, if it filled one. +export function addStrokePoint( + geometry: StrokeGeometry, + point: PreviewAnnotationPoint, +): string | null { + const previous = geometry.points[geometry.points.length - 1]!; + let closedPath: string | null = null; + if (geometry.points.length > 1) { + const midX = (previous.x + point.x) / 2; + const midY = (previous.y + point.y) / 2; + geometry.openPath += ` Q ${previous.x} ${previous.y} ${midX} ${midY}`; + geometry.openSegments += 1; + if (geometry.openSegments === STROKE_SEGMENTS_PER_PATH) { + closedPath = geometry.openPath; + geometry.openPath = `M ${midX} ${midY}`; + geometry.openSegments = 0; + } + } + geometry.points.push(point); + geometry.minX = Math.min(geometry.minX, point.x); + geometry.minY = Math.min(geometry.minY, point.y); + geometry.maxX = Math.max(geometry.maxX, point.x); + geometry.maxY = Math.max(geometry.maxY, point.y); + return closedPath; +} + +export function openStrokePath(geometry: StrokeGeometry): string { + const last = geometry.points[geometry.points.length - 1]!; + return `${geometry.openPath} L ${last.x} ${last.y}`; +} + +export function strokeBounds(geometry: StrokeGeometry, width: number): PreviewAnnotationRect { + const padding = width + 3; + const left = geometry.minX - padding; + const top = geometry.minY - padding; + const right = geometry.maxX + padding; + const bottom = geometry.maxY + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +} diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 6309c4ea993f..484ba6f9358f 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -89,7 +89,7 @@ describe("BrowserImport.importCookies", () => { assert.instanceOf(error, BrowserImport.BrowserImportFailedError); assert.equal(error.reason, "unknownSourceProfile"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect.skipIf(!symlinksSupported)( @@ -114,7 +114,7 @@ describe("BrowserImport.importCookies", () => { assert.instanceOf(error, BrowserImport.BrowserImportFailedError); assert.equal(error.reason, "browserRunning"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index e5aa05f268e4..92ab35320dab 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -134,7 +134,7 @@ describe("readChromiumCookieDatabase", () => { const partitioned = yield* readChromiumCookieDatabase(filename, keys, "linux"); expect(partitioned.cookies).toEqual([]); expect(partitioned.undecryptable).toBe(1); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => @@ -186,7 +186,7 @@ describe("readChromiumCookieDatabase", () => { { name: "encrypted", value: "stored encrypted" }, { name: "empty", value: "" }, ]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("enforces domain binding only for schema 24 and newer", () => @@ -227,7 +227,7 @@ describe("readChromiumCookieDatabase", () => { ]); expect(result.undecryptable).toBe(2); expect(result.undecryptableHosts).toEqual(["wrong.example", "short.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("decrypts mixed v10 and v11 cookies with their respective keys", () => @@ -273,7 +273,7 @@ describe("readChromiumCookieDatabase", () => { ]); expect(v10Only.undecryptable).toBe(1); expect(v10Only.undecryptableHosts).toEqual(["v11.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("recovers records written with the empty-passphrase key", () => @@ -320,7 +320,7 @@ describe("readChromiumCookieDatabase", () => { const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux"); expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]); expect(noV11.undecryptableHosts).toEqual(["ev11.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () => @@ -355,7 +355,7 @@ describe("readChromiumCookieDatabase", () => { expect(mac.undecryptable).toBe(0); expect(linux.cookies[0]?.value).toBe("legacy cleartext"); expect(linux.undecryptable).toBe(0); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("skips partitioned cookies without breaking pre-CHIPS schemas", () => @@ -409,6 +409,6 @@ describe("readChromiumCookieDatabase", () => { expect(chips.cookies.map(({ name }) => name)).toEqual(["plain"]); expect(chips.undecryptable).toBe(1); expect(chips.undecryptableHosts).toEqual(["partitioned.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts index 402397d3fde6..ca0a86cedacd 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -11,7 +11,7 @@ import { snapshotCookieDatabase } from "./CookieDatabase.ts"; const runNode = ( effect: Effect.Effect, -) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); +) => effect.pipe(Effect.provide(NodeServices.layer)); describe("snapshotCookieDatabase", () => { it.effect("includes committed WAL data in one consistent database", () => diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index be41318fa01e..33e32ec030be 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -78,7 +78,7 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( }); const run = (effect: Effect.Effect) => - effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + effect.pipe(Effect.provide(NodeServices.layer)); describe("readFirefoxCookies", () => { it.effect("converts millisecond expiries from schema 16 and newer", () => diff --git a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts index 8358088bc134..8ef376e4887b 100644 --- a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts +++ b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts @@ -64,6 +64,6 @@ it.layer(NodeServices.layer)("Linux browser secret path", (it) => { assert.isUndefined(yield* resolve(false, "win32")); yield* fileSystem.remove(staged); assert.isUndefined(yield* resolve(false)); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 7e92334c957d..f82f2319baeb 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -240,7 +240,7 @@ describe("safariAccessDenied", () => { yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); assert.isFalse(yield* safariAccessDenied(jar)); assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts index 3b33f872d124..dc225b04bac7 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts @@ -39,7 +39,7 @@ it.effect("detects Safari access becoming available without reading or importing assert.isTrue(yield* Effect.promise(check)); yield* fs.remove(jar); assert.isFalse(yield* Effect.promise(check)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("recognizes access when cookies exist only in a named Safari profile", () => @@ -55,5 +55,5 @@ it.effect("recognizes access when cookies exist only in a named Safari profile", yield* fs.makeDirectory(directory, { recursive: true }); yield* fs.writeFileString(`${directory}/Cookies.binarycookies`, "not parsed"); assert.isTrue(yield* Effect.promise(check)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 720a57e64a9d..9b2c3cb26c8f 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -51,7 +51,7 @@ const run = ( E, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner >, -) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); +) => effect.pipe(Effect.provide(NodeServices.layer)); const writeCookieDatabase = (file: string, count: number) => Effect.sync(() => { diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 6e657ba5fc4d..ccc2b868720b 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -16,6 +16,7 @@ const makeTarget = ( title: () => "Fixture", userAgent: () => "Electron", setDownloadDirectory, + receivesScreencast: () => true, }); const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 94a78f47db68..658e1cf70623 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -9,6 +9,8 @@ export interface CdpRelayTarget { owner?: string, ) => Promise; readonly sessionDetached?: (owner: string) => void; + /** Whether a page session, by owner as `send` names it, gets the page's screencast frames. */ + readonly receivesScreencast: (owner: string | undefined) => boolean; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; @@ -260,8 +262,15 @@ export function createCdpRelayConnection( send({ method, params, sessionId }); return; } - for (const [id, kind] of sessions) - if (kind === "page") send({ method, params, sessionId: id }); + for (const [id, kind] of sessions) { + if (kind !== "page") continue; + if ( + method === "Page.screencastFrame" && + !target.receivesScreencast(id === PAGE_SESSION_ID ? undefined : id) + ) + continue; + send({ method, params, sessionId: id }); + } }, }; } diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 94809f0d10cf..393e4cccccf9 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -16,6 +16,15 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeCdpReply = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Struct({ id: Schema.Number })), ); +const decodeCdpMessage = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.optional(Schema.Number), + method: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + }), + ), +); const key = { threadId: "thread-1", tabId: "tab-1" }; /** Sends a page command whose id the fake debugger records as it receives it. */ const command = ( @@ -43,6 +52,8 @@ const makeDebuggee = ( const pending: Array<() => void> = []; const focusChanges: Array<() => void> = []; const sent: Array = []; + const methods: Array = []; + const rejects: Array<() => void> = []; const sendWaiters: Array<() => void> = []; const focusEmulation: Array = []; const debuggee = Object.assign(emitter, { @@ -67,6 +78,8 @@ const makeDebuggee = ( return; } sent.push(params?.id); + methods.push(method); + rejects.push(() => reject(new Error("Refused"))); pending.push(() => resolve({ method })); sendWaiters.shift()?.(); }), @@ -94,6 +107,10 @@ const makeDebuggee = ( webContents.emit("did-fail-load", {}, code, "Failed", "http://localhost/", true), /** Ids of the commands the debugger received, in order. */ sent, + /** Methods of the same commands, in order. */ + methods, + /** Refuses the pending command the debugger received with this id. */ + refuse: (id: number) => rejects[sent.indexOf(id)]?.(), focusEmulation, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), @@ -116,16 +133,24 @@ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], coun /** * Ids of the replies the host sends, in order. A screenshot's reply leaves after its turn * has passed on, so by then the next waiting screenshot, if any, has been sent. Subscribe - * before sending commands: a new subscriber releases the tab's relay. + * before sending commands: a new subscriber releases the tab's relay. Screencast frames + * go to `frames` by the session they were sent to. */ -const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => +const watchReplies = ( + host: DesktopBrowserHost.DesktopBrowserHost["Service"], + frames: Array = [], +) => Effect.gen(function* () { const replies = yield* Queue.unbounded(); yield* host.events.pipe( Stream.map((line) => decodeEvent(new TextDecoder().decode(line))), - Stream.runForEach((event) => - event.type === "cdp" ? Queue.offer(replies, decodeCdpReply(event.message).id) : Effect.void, - ), + Stream.runForEach((event) => { + if (event.type !== "cdp") return Effect.void; + const { id, method, sessionId } = decodeCdpMessage(event.message); + if (id !== undefined) return Queue.offer(replies, id); + if (method === "Page.screencastFrame") frames.push(sessionId); + return Effect.void; + }), Effect.forkScoped, ); yield* Effect.yieldNow; @@ -283,7 +308,7 @@ describe("DesktopBrowserHost", () => { expect(reply).toMatchObject({ type: "cdp" }); expect(decodeCdpReply((reply as { message: string }).message).id).toBe(2); - }).pipe(Effect.scoped), + }), ); it.effect("sends a page one screenshot at a time", () => @@ -302,7 +327,7 @@ describe("DesktopBrowserHost", () => { debuggee.releaseFirst(); expect(yield* Queue.take(replies)).toBe(1); expect(debuggee.sent).toEqual([1, 3, 2]); - }).pipe(Effect.scoped), + }), ); it.effect("stops waiting on a screenshot the page navigated away from", () => @@ -327,7 +352,7 @@ describe("DesktopBrowserHost", () => { debuggee.releaseFirst(); expect(yield* Queue.take(replies)).toBe(2); expect(debuggee.sent).toEqual([1, 2, 3]); - }).pipe(Effect.scoped), + }), ); it.effect("drops screenshots a released relay left waiting", () => @@ -399,7 +424,7 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(1); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle once Chromium can no longer answer its pending command", () => @@ -587,7 +612,7 @@ describe("DesktopBrowserHost", () => { yield* turn(false); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false, true]); - }).pipe(Effect.scoped), + }), ); it.effect("paints for a screenshot queued behind one its viewer abandoned mid-capture", () => @@ -625,7 +650,7 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(4); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("stops painting for a queued screenshot once its session detaches", () => @@ -664,7 +689,7 @@ describe("DesktopBrowserHost", () => { expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("never sends a screenshot whose session detached while focus was changing", () => @@ -688,7 +713,7 @@ describe("DesktopBrowserHost", () => { debuggee.settleFocus(); yield* settle; expect(debuggee.sent).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect("holds no focus for a screenshot still queued when the agent's turn ends", () => @@ -718,7 +743,7 @@ describe("DesktopBrowserHost", () => { debuggee.release(); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => @@ -739,7 +764,7 @@ describe("DesktopBrowserHost", () => { expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle after a person's input through a viewer that then closed", () => @@ -768,7 +793,7 @@ describe("DesktopBrowserHost", () => { yield* settle; yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("keeps a page's screencast when a child session's screencast is refused", () => @@ -790,7 +815,7 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(2); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true]); - }).pipe(Effect.scoped), + }), ); it.effect("ignores a screencast that arrives on a session after it detached", () => @@ -816,7 +841,7 @@ describe("DesktopBrowserHost", () => { yield* settle; yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle once the session waiting on its command detaches", () => @@ -843,7 +868,7 @@ describe("DesktopBrowserHost", () => { }); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("never sends a screenshot queued by a session that has since detached", () => @@ -874,7 +899,7 @@ describe("DesktopBrowserHost", () => { yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("keeps a streamed guest focused until its screencast ends", () => @@ -935,7 +960,7 @@ describe("DesktopBrowserHost", () => { }); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("paints a hidden guest only while the screenshot it is taking is pending", () => @@ -961,7 +986,7 @@ describe("DesktopBrowserHost", () => { debuggee.release(); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("stops painting for a screenshot Chromium never answers once its document goes", () => @@ -1006,7 +1031,7 @@ describe("DesktopBrowserHost", () => { host.detach(key); yield* settle; expect(painting).toEqual([true, false, true, false, true, false, true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("paints a streamed guest until its last screencast ends", () => @@ -1037,7 +1062,176 @@ describe("DesktopBrowserHost", () => { }); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), + ); + + it.effect("keeps a guest's one screencast running until its last streaming session stops", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const frames: Array = []; + const replies = yield* watchReplies(host, frames); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const nativeStops = () => + debuggee.methods.filter((method) => method === "Page.stopScreencast").length; + + // A viewer and a recording share Chromium's single screencast for the guest. + yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } }); + expect(yield* Queue.take(replies)).toBe(0); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + debuggee.release(); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); + yield* settle; + expect(frames, "sent a stopped or idle session the screencast's frames").toEqual([ + "t3-preview-page-2", + ]); + + yield* raw({ id: 6, method: "Page.stopScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + expect(nativeStops()).toBe(1); + }), + ); + + it.effect("keeps a session's screencast when another of its starts is refused", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const frames: Array = []; + const replies = yield* watchReplies(host, frames); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } }); + expect(yield* Queue.take(replies)).toBe(0); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ + id: 2, + method: "Page.startScreencast", + params: { id: 2 }, + sessionId: "t3-preview-page-1", + }); + yield* raw({ + id: 3, + method: "Page.startScreencast", + params: { id: 3 }, + sessionId: "t3-preview-page-1", + }); + yield* settle; + debuggee.refuse(2); + debuggee.release(); + expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([2, 3]); + yield* settle; + expect(debuggee.methods).not.toContain("Page.stopScreencast"); + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); + yield* settle; + expect(frames).toContain("t3-preview-page-1"); + + // A refused replacement leaves the session's running stream, shared with another session. + yield* raw({ id: 4, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(4); + yield* raw({ + id: 5, + method: "Page.startScreencast", + params: { id: 5 }, + sessionId: "t3-preview-page-2", + }); + yield* raw({ + id: 6, + method: "Page.startScreencast", + params: { id: 6 }, + sessionId: "t3-preview-page-1", + }); + yield* settle; + debuggee.refuse(6); + debuggee.release(); + expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([5, 6]); + yield* settle; + frames.length = 0; + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 2 }); + yield* settle; + expect(frames).toEqual(["t3-preview-page-1", "t3-preview-page-2"]); + }), + ); + + it.effect("stops a guest's screencast once its last streaming session detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const nativeStops = () => + debuggee.methods.filter((method) => method === "Page.stopScreencast").length; + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + debuggee.release(); + // Streamers go without stopping, such as viewers that closed. + yield* raw({ + id: 5, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + const answered = []; + for (let reply = 0; reply < 4; reply++) answered.push(yield* Queue.take(replies)); + expect(answered.toSorted()).toEqual([3, 4, 5, 6]); + yield* settle; + expect(nativeStops()).toBe(1); + }), + ); + + it.effect("stops a screencast whose start lands after its session detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + // The start waits on focus emulation while the viewer closes. + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + debuggee.settleFocus(); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(debuggee.methods.at(-1), "left the late screencast running").toBe( + "Page.stopScreencast", + ); + }), ); it.effect("saves a server tab's download under its CDP guid where the server asked", () => @@ -1072,7 +1266,7 @@ describe("DesktopBrowserHost", () => { expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(true); expect(paths).toEqual(["/srv/downloads/guid-1"]); expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false); - }).pipe(Effect.scoped), + }), ); it.effect("tells a download the person clicked from one the agent's input started", () => @@ -1170,6 +1364,6 @@ describe("DesktopBrowserHost", () => { expect(JSON.parse((next as { message: string }).message)).toMatchObject({ method: "Page.loadEventFired", }); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index a53fc0e3e6ec..e5f43634d204 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -75,8 +75,8 @@ interface AttachedTab { */ agentUsed: boolean; focusReleasing: boolean; - /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ - readonly screencasts: Set; + /** The sessions sharing the guest's one Chromium screencast; it stops with the last of them. */ + readonly screencasts: Map; readonly onMessage: ( event: Electron.Event, method: string, @@ -289,9 +289,15 @@ export const make = Effect.gen(function* () { dropFocusHolds(tab); }; + /** An owner that leaves without stopping would otherwise keep the screencast running unseen. */ + const leaveScreencast = (tab: AttachedTab, owner: string | undefined) => { + if (!tab.screencasts.delete(owner) || tab.screencasts.size > 0) return; + void tab.debuggee.debugger.sendCommand("Page.stopScreencast").catch(() => {}); + }; + /** A detached session, such as a closed viewer, waits on nothing it sent. */ const dropSessionHolds = (tab: AttachedTab, owner: string) => { - tab.screencasts.delete(owner); + leaveScreencast(tab, owner); for (const [pending, holder] of tab.focusPending) { if (holder === owner) tab.focusPending.delete(pending); } @@ -330,13 +336,19 @@ export const make = Effect.gen(function* () { { send: (method, params, sessionId, owner) => { // A child session's screencast is not the page's, such as a late one on a detached session. - if (sessionId === undefined && method === "Page.startScreencast") { - tab.screencasts.add(owner); + const stream = + sessionId === undefined && method === "Page.startScreencast" + ? (tab.screencasts.get(owner) ?? { started: false, pending: 0 }) + : undefined; + if (stream) { + stream.pending += 1; + tab.screencasts.set(owner, stream); if (owner !== undefined) streamingOwners.add(owner); syncPainting(tab); } else if (sessionId === undefined && method === "Page.stopScreencast") { tab.screencasts.delete(owner); syncPainting(tab); + if (tab.screencasts.size > 0) return Promise.resolve({}); } // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. if ( @@ -365,19 +377,42 @@ export const make = Effect.gen(function* () { held ? holdFocus(tab, owner, () => forward(current)) : forward(current), ) : method === "Page.startScreencast" - ? holdFocus(tab, owner, forward).catch((cause: unknown) => { - // A viewer can outlive a refused stream; nothing is streaming for it. - if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); - releaseFocusWhenIdle(tab); - syncPainting(tab); - throw cause; - }) + ? holdFocus(tab, owner, forward).then( + (result) => { + if (stream) { + stream.pending -= 1; + stream.started = true; + // A detach's stop can overtake a start still waiting on focus; nobody owns it now. + if (tab.screencasts.get(owner) !== stream && tab.screencasts.size === 0) { + void debuggee.sendCommand("Page.stopScreencast").catch(() => {}); + } + } + return result; + }, + (cause: unknown) => { + if (stream) stream.pending -= 1; + // A viewer can outlive a refused start; its running or pending stream still holds it. + if ( + stream && + !stream.started && + stream.pending === 0 && + tab.relay === relay && + tab.screencasts.get(owner) === stream + ) { + leaveScreencast(tab, owner); + } + releaseFocusWhenIdle(tab); + syncPainting(tab); + throw cause; + }, + ) : holdFocus(tab, owner, forward); }, sessionDetached: (owner) => { detachedOwners.add(owner); dropSessionHolds(tab, owner); }, + receivesScreencast: (owner) => tab.screencasts.has(owner), targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -463,7 +498,7 @@ export const make = Effect.gen(function* () { painting: false, agentUsed: false, focusReleasing: false, - screencasts: new Set(), + screencasts: new Map(), onMessage: (_event, method, params, sessionId) => { if (method === "Page.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index a0889940a668..48191ce2c4b9 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -44,6 +44,30 @@ describe("preview guest input", () => { expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything()); }); + it("stops the guest navigation without stopping its embedder", async () => { + let guestLoading = true; + let hostLoading = true; + const guest = { + stop: () => { + guestLoading = false; + }, + }; + const debuggee = { + sendCommand: async () => { + hostLoading = false; + return {}; + }, + }; + await sendPageCommand( + guest as unknown as Electron.WebContents, + debuggee as unknown as Electron.Debugger, + "Page.stopLoading", + {}, + ); + expect(guestLoading).toBe(false); + expect(hostLoading).toBe(true); + }); + it.each([ { sessionId: undefined, diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 2af13af8d7da..4d794beea62b 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -185,6 +185,10 @@ export const sendPageCommand = async ( else webContents.reload(); return {}; } + if (method === "Page.stopLoading") { + webContents.stop(); + return {}; + } if (!FOCUS_ROUTED_INPUT.has(method) || webContents.focusedFrame) { return debuggee.sendCommand(method, params); } diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 09d4eff6d201..0a9f4cb85c00 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -6,6 +6,9 @@ export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; +// Sent to the hosting element (ipc-message). The web app mirrors this +// literal in HostedBrowserWebview because it cannot import from the desktop app. +export const POINTER_DOWN_HOST_CHANNEL = "preview:pointer-down"; export const PAGE_UNHANDLED_SHORTCUT_CHANNEL = "preview:page-unhandled-shortcut"; export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6db04b389ab6..b344b1ac93b2 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -671,7 +671,7 @@ describe("PreviewManager", () => { if (existing) { expect(yield* fs.readFileString(`${downloads}/report.txt`)).toBe("existing download"); } - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); effectIt.effect.each([ @@ -820,7 +820,7 @@ describe("PreviewManager", () => { ["linux", false], ["win32", false], ] as const)( - "lets the page and its iframes claim the find shortcut before the host opens find on %s", + "lets the page and its iframes claim the find and zoom shortcuts before the host acts on %s", ([platform, usesMeta]) => withManager( (manager) => @@ -838,8 +838,23 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_page_find", 42); yield* manager.setForwardedShortcuts([ { command: "preview.find", shortcut: parseKeybindingShortcut("mod+f")! }, + { command: "preview.zoomIn", shortcut: parseKeybindingShortcut("mod+=")! }, ]); const beforeInput = preview.listeners.get("before-input-event")!; + const keepsZoom = vi.fn(); + beforeInput( + { preventDefault: keepsZoom } as never, + { + type: "keyDown", + key: "=", + code: "Equal", + meta: usesMeta, + control: !usesMeta, + shift: false, + alt: false, + } as never, + ); + expect(keepsZoom).not.toHaveBeenCalled(); const findKey = { type: "keyDown", key: "f", @@ -2056,7 +2071,7 @@ describe("PreviewManager", () => { expect(yield* nextType).toBe("attached"); yield* manager.closeTab("tab_1"); expect(yield* nextType).toBe("detached"); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), + }).pipe(Effect.provide(managerLayer())), ); effectIt.effect( @@ -2115,7 +2130,7 @@ describe("PreviewManager", () => { expect(yield* Queue.take(capturing)).toBe(true); screenshot.resolve({ data: "" }); expect(yield* Queue.take(capturing)).toBe(false); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), + }).pipe(Effect.provide(managerLayer())), ); effectIt.effect("reports early and subsequent load failures for the current guest", () => @@ -2206,7 +2221,7 @@ describe("PreviewManager", () => { expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "detached", ...serverTab }); expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "attached", ...serverTab }); expect(statuses.at(-1)?.kind).toBe("Success"); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), + }).pipe(Effect.provide(managerLayer())), ); effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 189885b9b217..0fc48165ffcc 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -444,6 +444,9 @@ type FindResultListener = (event: DesktopPreviewFindResultEvent) => Effect.Effec const PAGE_FIRST_FORWARDED_COMMANDS: ReadonlySet = new Set([ "preview.find", + "preview.zoomIn", + "preview.zoomOut", + "preview.resetZoom", ]); const decodePageShortcutKeys = Schema.decodeUnknownOption( @@ -1553,7 +1556,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); if (forwarded && host) { // The page keeps these keys. Only the main-frame preload can report one it - // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find. + // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find or zoom. if (PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; event.preventDefault(); if (!input.isAutoRepeat) { @@ -2145,6 +2148,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); }); const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); + const stopLoading = (tabId: string) => withWebContents("stopLoading", tabId, (wc) => wc.stop()); const hardReload = (tabId: string) => withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); const findInPage = ( @@ -3528,6 +3532,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function prepareWebview, reapplyZoom, refresh, + stopLoading, registerWebview, resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), setZoomFactor: (tabId: string, zoomFactor: number) => @@ -3729,6 +3734,7 @@ export class PreviewManager extends Context.Service< readonly goBack: (tabId: string) => Effect.Effect; readonly goForward: (tabId: string) => Effect.Effect; readonly refresh: (tabId: string) => Effect.Effect; + readonly stopLoading: (tabId: string) => Effect.Effect; readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; @@ -3903,6 +3909,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { goBack: operations.goBack, goForward: operations.goForward, refresh: operations.refresh, + stopLoading: operations.stopLoading, zoomIn: operations.zoomIn, zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, diff --git a/apps/desktop/src/preview/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts index 8a080183b383..61d3941ba41a 100644 --- a/apps/desktop/src/preview/PickPreload.test.ts +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -62,3 +62,46 @@ it("keeps the tool while typing a tool key in the comment", () => { expect(document.documentElement.getAttribute("data-t3code-annotation-tool")).toBe("select"); }); + +it("keeps comment keystrokes away from the page's capture-phase shortcuts", () => { + const pageShortcut = vi.fn((event: KeyboardEvent) => event.preventDefault()); + window.addEventListener("keydown", pageShortcut, { capture: true }); + const shadowRoot = startAnnotation(); + const comment = shadowRoot.querySelector("textarea"); + if (!comment) throw new Error("comment textarea not found"); + + comment.focus(); + const space = new KeyboardEvent("keydown", { + key: " ", + code: "Space", + bubbles: true, + cancelable: true, + composed: true, + }); + comment.dispatchEvent(space); + window.removeEventListener("keydown", pageShortcut, { capture: true }); + + expect(pageShortcut).not.toHaveBeenCalled(); + expect(space.defaultPrevented).toBe(false); +}); + +it("keeps the annotation when Escape dismisses an IME candidate in the comment", () => { + const shadowRoot = startAnnotation(); + const comment = shadowRoot.querySelector("textarea"); + if (!comment) throw new Error("comment textarea not found"); + + comment.value = "draft"; + comment.focus(); + comment.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + isComposing: true, + bubbles: true, + cancelable: true, + composed: true, + }), + ); + + expect(shadowRoot.host.isConnected).toBe(true); + expect(comment.value).toBe("draft"); +}); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index a9f479d32b81..cbaf873e4933 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -20,6 +20,13 @@ import type { import { createAnnotationIdSource } from "./AnnotationIds.ts"; import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; +import { + addStrokePoint, + openStrokePath, + startStroke, + strokeBounds, + type StrokeGeometry, +} from "./AnnotationStroke.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; import { installRecordingCursor } from "./RecordingCursor.ts"; import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts"; @@ -33,6 +40,7 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + POINTER_DOWN_HOST_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_POINTER_CHANNEL, @@ -149,6 +157,7 @@ interface AnnotationSession { teardown: (notifyMain: boolean) => void; applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; setSendEnabled: (enabled: boolean) => void; + handleOverlayKey: (event: KeyboardEvent) => void; } let activeSession: AnnotationSession | null = null; @@ -185,6 +194,9 @@ const applyAnnotationTheme = ( const reportHumanPointerInput = (event: PointerEvent): void => { if (!event.isTrusted) return; + // The guest does not always take focus on a click, so the host's webview + // `focus` event can't be relied on to dismiss open menus and popovers. + ipcRenderer.sendToHost(POINTER_DOWN_HOST_CHANNEL); ipcRenderer.send(HUMAN_INPUT_CHANNEL, { kind: "pointer", x: event.clientX, @@ -225,6 +237,18 @@ const reportUnhandledShortcut = (event: KeyboardEvent): void => { window.addEventListener("keydown", reportUnhandledShortcut, true); +// Registered before page scripts so their capture listeners never see keys typed into the +// overlay; the closed shadow root retargets those to the host, hiding that they edit text. +const isolateOverlayKey = (event: KeyboardEvent): void => { + if (!activeSession || !(event.target instanceof Element) || !isAnnotationNode(event.target)) + return; + activeSession.handleOverlayKey(event); + event.stopImmediatePropagation(); +}; + +for (const type of ["keydown", "keypress", "keyup"] as const) + window.addEventListener(type, isolateOverlayKey, true); + const MOUSE_BUTTON_BACK = 3; const MOUSE_BUTTON_FORWARD = 4; @@ -533,34 +557,6 @@ function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { return input; } -function pathFromPoints(points: ReadonlyArray): string { - if (points.length === 0) return ""; - if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; - let path = `M ${points[0]!.x} ${points[0]!.y}`; - for (let index = 1; index < points.length - 1; index += 1) { - const current = points[index]!; - const next = points[index + 1]!; - path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; - } - const last = points[points.length - 1]!; - path += ` L ${last.x} ${last.y}`; - return path; -} - -function strokeBounds( - points: ReadonlyArray, - width: number, -): PreviewAnnotationRect { - const xs = points.map((point) => point.x); - const ys = points.map((point) => point.y); - const padding = width + 3; - const left = Math.min(...xs) - padding; - const top = Math.min(...ys) - padding; - const right = Math.max(...xs) + padding; - const bottom = Math.max(...ys) + padding; - return { x: left, y: top, width: right - left, height: bottom - top }; -} - function startAnnotation( sendEnabled: boolean, voice: DesktopPreviewAnnotationVoiceConfig | undefined, @@ -897,7 +893,12 @@ function startAnnotation( } let tool: AnnotationTool = isPdf ? "marquee" : "select"; let dragStart: PreviewAnnotationPoint | null = null; - let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; + let activeStroke: { + target: PreviewAnnotationStrokeTarget; + geometry: StrokeGeometry; + group: SVGGElement; + path: SVGPathElement; + } | null = null; let pendingCapture = false; let editorExpanded = false; let editorWasShown = false; @@ -1497,15 +1498,18 @@ function startAnnotation( return; } if (tool === "draw" && activeStroke) { - activeStroke.target.points = [ - ...activeStroke.target.points, - { x: event.clientX, y: event.clientY }, - ]; - activeStroke.target.bounds = strokeBounds( - activeStroke.target.points, - activeStroke.target.width, - ); - activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); + const closedPath = addStrokePoint(activeStroke.geometry, { + x: event.clientX, + y: event.clientY, + }); + activeStroke.target.bounds = strokeBounds(activeStroke.geometry, activeStroke.target.width); + if (closedPath !== null) { + activeStroke.path.setAttribute("d", closedPath); + activeStroke.path = activeStroke.group.appendChild( + document.createElementNS("http://www.w3.org/2000/svg", "path"), + ); + } + activeStroke.path.setAttribute("d", openStrokePath(activeStroke.geometry)); } }; @@ -1524,23 +1528,27 @@ function startAnnotation( } dragStart = { x: event.clientX, y: event.clientY }; if (tool === "draw") { + const geometry = startStroke(dragStart); const stroke: PreviewAnnotationStrokeTarget = { id: nextId("stroke"), color: annotationTheme?.primary ?? "#2563eb", width: 4, - points: [dragStart], + points: geometry.points, bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, }; - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute(OVERLAY_ATTRIBUTE, ""); - path.setAttribute("data-stroke-id", stroke.id); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", stroke.color); - path.setAttribute("stroke-width", String(stroke.width)); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - svg.appendChild(path); - activeStroke = { target: stroke, path }; + const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); + group.setAttribute(OVERLAY_ATTRIBUTE, ""); + group.setAttribute("data-stroke-id", stroke.id); + group.setAttribute("fill", "none"); + group.setAttribute("stroke", stroke.color); + group.setAttribute("stroke-width", String(stroke.width)); + group.setAttribute("stroke-linecap", "round"); + group.setAttribute("stroke-linejoin", "round"); + const path = group.appendChild( + document.createElementNS("http://www.w3.org/2000/svg", "path"), + ); + svg.appendChild(group); + activeStroke = { target: stroke, geometry, group, path }; } }; @@ -1567,7 +1575,7 @@ function startAnnotation( } } else if (tool === "draw" && activeStroke) { if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); - else activeStroke.path.remove(); + else activeStroke.group.remove(); activeStroke = null; } dragStart = null; @@ -1673,7 +1681,7 @@ function startAnnotation( return; } } - if (event.key === "Escape") { + if (event.key === "Escape" && !event.isComposing) { event.preventDefault(); event.stopPropagation(); teardown(true); @@ -1762,15 +1770,17 @@ function startAnnotation( }); }; submit.addEventListener("click", () => submitAnnotation("attach")); - root.addEventListener("keydown", (event) => { + const handleOverlayKey = (event: KeyboardEvent): void => { + if (event.type === "keyup") onVoiceKeyUp(event); + if (event.type !== "keydown") return; + onKeyDown(event); + if (event.cancelBubble) return; const submission = - event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null; - // Bubble phase lets editor inputs handle events before hiding them from the page. - event.stopImmediatePropagation(); + shadowRoot.activeElement === comment ? resolveAnnotationSubmission(event, sendEnabled) : null; if (!submission) return; event.preventDefault(); submitAnnotation(submission); - }); + }; window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); @@ -1806,6 +1816,7 @@ function startAnnotation( sendEnabled = enabled; updateSendHint(); }, + handleOverlayKey, }; } diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 868e1c139fbd..04406f63c6b3 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -4,6 +4,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; @@ -160,6 +161,49 @@ describe("DesktopSettings", () => { ), ); + it.effect("keeps saved preferences when a read fails before a bounds save", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + yield* writeSettingsPatch({ serverExposureMode: "network-accessible" }); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.desktopSettingsPath, + }); + let failRead = true; + const settings = yield* DesktopAppSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => { + if (failRead && path === environment.desktopSettingsPath) { + failRead = false; + return Effect.fail(cause); + } + return fileSystem.readFileString(path); + }), + }), + ), + ); + + yield* Effect.exit(settings.load); + yield* Effect.exit( + settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, false), + ); + + const persisted = yield* decodeDesktopSettingsPatch( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + ); + assert.equal(persisted.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("loads lenient persisted desktop settings JSON", () => withSettings( Effect.gen(function* () { @@ -196,6 +240,28 @@ describe("DesktopSettings", () => { ), ); + it.effect("keeps Local environment off when another value in the file is unreadable", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ "localEnvironmentEnabled": false, "serverExposureMode": "tailnet" }\n`, + ); + + assert.isFalse((yield* settings.load).localEnvironmentEnabled); + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 800, height: 600 }, false); + assert.include( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + `"localEnvironmentEnabled":false`, + ); + }), + ), + ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 4c2ae8d1d134..e81da7066064 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -13,6 +13,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -88,20 +90,25 @@ const DesktopWindowBoundsDocument = Schema.Struct({ height: Schema.Number, }); +// A value this build cannot read drops only its own key, so the rest of the file +// still loads and is written back instead of every setting resetting to defaults. +const optionalReadableKey = (schema: S) => + Schema.optionalKey(schema.pipe(Schema.catchDecoding(() => Effect.succeedNone))); + const DesktopSettingsDocument = Schema.Struct({ - localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), - linuxPasswordStore: Schema.optionalKey(Schema.Unknown), - mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), - mainWindowMaximized: Schema.optionalKey(Schema.Boolean), - serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), - tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), - tailscaleServePort: Schema.optionalKey(Schema.Number), - updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), - updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), - wslBackendEnabled: Schema.optionalKey(Schema.Boolean), - wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), - wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), - wslOnly: Schema.optionalKey(Schema.Boolean), + localEnvironmentEnabled: optionalReadableKey(Schema.Boolean), + linuxPasswordStore: optionalReadableKey(Schema.Unknown), + mainWindowBounds: optionalReadableKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: optionalReadableKey(Schema.Boolean), + serverExposureMode: optionalReadableKey(DesktopServerExposureModeSchema), + tailscaleServeEnabled: optionalReadableKey(Schema.Boolean), + tailscaleServePort: optionalReadableKey(Schema.Number), + updateChannel: optionalReadableKey(DesktopUpdateChannelSchema), + updateChannelConfiguredByUser: optionalReadableKey(Schema.Boolean), + wslBackendEnabled: optionalReadableKey(Schema.Boolean), + wslMode: optionalReadableKey(Schema.Literals(["local", "wsl"])), + wslDistro: optionalReadableKey(Schema.NullOr(Schema.String)), + wslOnly: optionalReadableKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -379,15 +386,20 @@ function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } +// Fails only when the file may exist but could not be read; a missing file means defaults. function readSettings( fileSystem: FileSystem.FileSystem, settingsPath: string, appVersion: string, -): Effect.Effect { +): Effect.Effect { const defaultSettings = resolveDefaultDesktopSettings(appVersion); return fileSystem.readFileString(settingsPath).pipe( - Effect.option, + Effect.asSome, + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed(Option.none()), + ), Effect.flatMap( Option.match({ onNone: () => Effect.succeed(defaultSettings), @@ -476,6 +488,8 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const random = yield* SecureRandom.SecureRandom; const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); + // Writing over a settings file we could not read would erase the preferences it holds. + const persistenceEnabled = yield* Ref.make(true); const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) => SynchronizedRef.modify(settingsRef, (settings) => { @@ -492,17 +506,23 @@ export const make = Effect.gen(function* () { return Effect.succeed([settingsChange(settings, false), settings] as const); } - return random.uuidV4().pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeSettings({ - fileSystem, - path, - settingsPath: environment.desktopSettingsPath, - settings: nextSettings, - defaultSettings: environment.defaultDesktopSettings, - suffix, - }), + return Ref.get(persistenceEnabled).pipe( + Effect.flatMap((enabled) => + enabled + ? random.uuidV4().pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.flatMap((suffix) => + writeSettings({ + fileSystem, + path, + settingsPath: environment.desktopSettingsPath, + settings: nextSettings, + defaultSettings: environment.defaultDesktopSettings, + suffix, + }), + ), + ) + : Effect.void, ), Effect.as([settingsChange(nextSettings, true), nextSettings] as const), ); @@ -515,6 +535,17 @@ export const make = Effect.gen(function* () { fileSystem, environment.desktopSettingsPath, environment.appVersion, + ).pipe( + Effect.catch((cause) => + Effect.logWarning( + "Could not read desktop settings; using defaults without saving changes.", + cause, + ).pipe( + Effect.annotateLogs({ settingsPath: environment.desktopSettingsPath }), + Effect.andThen(Ref.set(persistenceEnabled, false)), + Effect.as(resolveDefaultDesktopSettings(environment.appVersion)), + ), + ), ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1ab9b3159e1f..90c8740f8372 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -110,7 +110,7 @@ const withClientSettings = ( prefix: "t3-desktop-client-settings-test-", }); return yield* effect.pipe(Effect.provide(layer(baseDir))); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); describe("DesktopClientSettings", () => { it.effect("saves through a symlinked client settings file without replacing the link", () => diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 0001548f4534..7be39dbe1562 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -150,7 +150,7 @@ const withSavedEnvironments = ( prefix: "t3-desktop-saved-environments-test-", }); return yield* effect.pipe(Effect.provide(layer(baseDir, options))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }).pipe(Effect.provide(NodeServices.layer)); describe("DesktopSavedEnvironments", () => { it.effect("loads lenient saved environment registry documents", () => diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index 1e5b6dafc6ca..15977b34f398 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -144,7 +144,7 @@ describe("DesktopSshPasswordPrompts", () => { yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); assert.equal(yield* Fiber.join(fiber), "secret"); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }).pipe(Effect.provide(layer(testWindow.window))); }); it.effect.each(["answered", "cancelled", "timed out", "interrupted"] as const)( @@ -189,7 +189,7 @@ describe("DesktopSshPasswordPrompts", () => { .map((message) => message.args), [[request.requestId]], ); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }).pipe(Effect.provide(layer(testWindow.window))); }, ); @@ -219,6 +219,6 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(password, "secret"); assert.equal(testWindow.isFocused(), false); assert.equal(testWindow.closedListenerCount(), 0); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }).pipe(Effect.provide(layer(testWindow.window))); }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 15c481f488db..7ee2ff5dd51c 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -247,6 +247,26 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("checks shortly after startup and then every four hours", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* TestClock.adjust("15 seconds"); + assert.equal(harness.checkCount(), 1); + + yield* TestClock.adjust("3 hours"); + assert.equal(harness.checkCount(), 1); + + yield* TestClock.adjust("1 hour"); + assert.equal(harness.checkCount(), 2); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect( "rejects install while a refresh check is in progress and releases the reservation", () => @@ -524,14 +544,17 @@ describe("DesktopUpdates", () => { it.effect("marks the backend stop for an install as an update restart", () => { let markersAtStop: ReadonlyArray = []; + let inProgressAtStop: string | undefined; const harness = makeHarness({ stopBackend: Effect.sync(() => { markersAtStop = [...harness.updateRestartMarkers]; + inProgressAtStop = harness.updateInProgressMarker(); }), }); return Effect.scoped( Effect.gen(function* () { + yield* TestClock.setTime(1_000); const environment = yield* DesktopEnvironment.DesktopEnvironment; const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; @@ -542,6 +565,12 @@ describe("DesktopUpdates", () => { assert.deepEqual(markersAtStop, [ environment.path.join(environment.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE), ]); + // SSH launchers wait on this until the replacement backend publishes its runtime. + assert.deepEqual(JSON.parse(inProgressAtStop ?? "null"), { + startedAt: 1_000, + expiresAt: 61_000, + }); + assert.isDefined(harness.updateInProgressMarker()); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); @@ -624,9 +653,11 @@ describe("DesktopUpdates", () => { const installFiber = yield* updates.install.pipe(Effect.forkScoped); yield* Deferred.await(stopping); assert.equal(harness.updateRestartMarkers.size, 1); + assert.isDefined(harness.updateInProgressMarker()); yield* Fiber.interrupt(installFiber); assert.equal(harness.updateRestartMarkers.size, 0); + assert.isUndefined(harness.updateInProgressMarker()); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), @@ -661,6 +692,7 @@ describe("DesktopUpdates", () => { "startBackend", ]); assert.equal(harness.updateRestartMarkers.size, 0); + assert.isUndefined(harness.updateInProgressMarker()); assert.equal(harness.installRecords.size, 0); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 077cad616147..db31e0bc112b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,5 +1,7 @@ import { DESKTOP_UPDATE_RESTART_MARKER_FILE, + DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE, + DESKTOP_UPDATE_IN_PROGRESS_TTL_MS, DesktopUpdateChannelSchema, type DesktopRuntimeInfo, type DesktopUpdateActionResult, @@ -60,7 +62,7 @@ import { const CHANNEL_CHANGE_RETRY_DELAY = Duration.seconds(1); const CHANNEL_CHANGE_RETRIES = 30; const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; -const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; +const AUTO_UPDATE_POLL_INTERVAL = "4 hours"; const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90); type UpdateAction = "check" | "download" | "install" | "install-recovery" | "channel"; @@ -669,6 +671,34 @@ export const make = Effect.gen(function* () { ), ); + const updateInProgressMarkerPath = environment.path.join( + updateRestartMarkerDir, + DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE, + ); + const encodeUpdateMarker = Schema.encodeEffect( + Schema.fromJsonString(Schema.Struct({ startedAt: Schema.Number, expiresAt: Schema.Number })), + ); + const updateInProgressTempPath = `${updateInProgressMarkerPath}.tmp`; + const writeUpdateInProgressMarker = Effect.gen(function* () { + const startedAt = DateTime.toEpochMillis(yield* DateTime.now); + const contents = yield* encodeUpdateMarker({ + startedAt, + expiresAt: startedAt + DESKTOP_UPDATE_IN_PROGRESS_TTL_MS, + }); + yield* fileSystem.writeFileString(updateInProgressTempPath, contents); + yield* fileSystem.rename(updateInProgressTempPath, updateInProgressMarkerPath); + }).pipe( + Effect.ensuring( + fileSystem.remove(updateInProgressTempPath, { force: true }).pipe(Effect.ignore), + ), + Effect.catch((error) => + logUpdaterWarning("Could not write the update-in-progress marker.", { errorTag: error._tag }), + ), + ); + const removeUpdateInProgressMarker = fileSystem + .remove(updateInProgressMarkerPath, { force: true }) + .pipe(Effect.ignore); + // Failed or interrupted installs leave no replacement backend; later quits must release the tunnel. const removeUpdateRestartMarker = fileSystem .remove(updateRestartMarkerPath, { force: true }) @@ -724,6 +754,7 @@ export const make = Effect.gen(function* () { Ref.set(desktopState.quitting, false), Ref.set(installRunningBackendsRef, []), removeUpdateRestartMarker, + removeUpdateInProgressMarker, removeInstallRecord, ], { discard: true }, @@ -741,6 +772,7 @@ export const make = Effect.gen(function* () { yield* Ref.set(desktopState.quitting, false); yield* removeUpdateRestartMarker; + yield* removeUpdateInProgressMarker; yield* removeInstallRecord; yield* Effect.gen(function* () { const runningInstances = yield* Ref.getAndSet(installRunningBackendsRef, []); @@ -816,6 +848,7 @@ export const make = Effect.gen(function* () { return yield* Effect.gen(function* () { yield* writeUpdateRestartMarker; + yield* writeUpdateInProgressMarker; const { downloadedVersion } = yield* Ref.get(updateStateRef); if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion); // Stop pooled backends and SSH tunnels before quitAndInstall can outrun their scope finalizers. diff --git a/apps/desktop/src/updates/githubUpdateFeed.test.ts b/apps/desktop/src/updates/githubUpdateFeed.test.ts new file mode 100644 index 000000000000..d9e928fce186 --- /dev/null +++ b/apps/desktop/src/updates/githubUpdateFeed.test.ts @@ -0,0 +1,107 @@ +import * as NodeModule from "node:module"; + +import { GitHubProvider } from "electron-updater/out/providers/GitHubProvider.js"; +import { describe, expect, it } from "vite-plus/test"; + +type ProviderArgs = ConstructorParameters; + +// electron-updater's own dependency, which apps/desktop does not declare. +const semver = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("electron-updater"), +)("semver") as { parse: (version: string) => ProviderArgs[1]["currentVersion"] }; + +const REPO = "/pingdotgg/t3code"; +const nightly = (build: number) => `v0.1.10-nightly.20261112.${build}`; + +const atomFeed = (tags: ReadonlyArray) => + `${tags + .map( + (tag) => + `${tag}${tag} feed notes`, + ) + .join("")}`; + +/** + * The release a nightly install on 4580 is offered when GitHub serves this feed, this `update-feed` + * pointer, and this newest nightly from the REST API scan. + */ +const offeredRelease = async ( + feedTags: ReadonlyArray, + pointerTag: string | undefined, + listedTag?: string | Error, +) => { + let apiScans = 0; + const request = async ({ path = "" }: { readonly path?: string | null }) => { + if (path === `${REPO}/releases.atom`) return atomFeed(feedTags); + if (path === `${REPO}/releases/download/update-feed/nightly.json`) { + if (pointerTag === undefined) throw new Error("404"); + return JSON.stringify({ tag_name: pointerTag, name: pointerTag, body: "pointer notes" }); + } + const manifest = /\/releases\/download\/v([^/]+)\/nightly\.yml$/.exec(path ?? ""); + if (manifest) return `version: ${manifest[1]}\npath: T3-Code.exe\nsha512: abc\n`; + apiScans += 1; + if (listedTag instanceof Error) throw listedTag; + return JSON.stringify(listedTag ? [{ tag_name: listedTag, body: "API notes" }] : []); + }; + const provider = new GitHubProvider( + { provider: "github", owner: "pingdotgg", repo: "t3code" }, + // Only the four fields the provider reads; a real AppUpdater needs Electron. + { + allowPrerelease: true, + channel: "nightly", + currentVersion: semver.parse("0.1.10-nightly.20261112.4580"), + fullChangelog: true, + } as ProviderArgs[1], + { + isUseMultipleRangeRequest: false, + platform: "win32", + executor: { request }, + } as ProviderArgs[2], + ); + const { tag, releaseNotes } = await provider.getLatestVersion(); + return { tag, releaseNotes, apiScans }; +}; + +// What filled releases.atom on 2026-11-12: ten tags, none of them a nightly. +const crowdedFeed = Array.from({ length: 10 }, (_, i) => `v0.1.10-preview.20261112.${4550 + i}`); + +describe("GitHubProvider nightly lookup", () => { + it.each([ + ["other tags fill the feed", crowdedFeed], + ["the feed's first nightly is an older one", [nightly(4603)]], + ])("finds the newest nightly at the fixed pointer when %s", async (_case, feedTags) => { + expect(await offeredRelease(feedTags, nightly(4606))).toEqual({ + tag: nightly(4606), + releaseNotes: "pointer notes", + apiScans: 0, + }); + }); + + it("still scans the API when the feed has no nightly and the pointer offers nothing new", async () => { + expect(await offeredRelease(crowdedFeed, nightly(4580), nightly(4606))).toEqual({ + tag: nightly(4606), + releaseNotes: "API notes", + apiScans: 1, + }); + }); + + it("keeps the pointer's answer when the API scan fails", async () => { + const rateLimited = new Error("403"); + expect(await offeredRelease(crowdedFeed, nightly(4580), rateLimited)).toMatchObject({ + tag: nightly(4580), + apiScans: 1, + }); + }); + + it.each([ + ["the feed is newer than the pointer", nightly(4603)], + ["there is no pointer", undefined], + ["the pointer names another channel", "v0.1.10-preview.20261112.4700"], + ])("keeps the feed's nightly when %s", async (_case, pointerTag) => { + expect(await offeredRelease([nightly(4606)], pointerTag)).toEqual({ + tag: nightly(4606), + releaseNotes: [{ version: nightly(4606).slice(1), note: `${nightly(4606)} feed notes` }], + apiScans: 0, + }); + }); +}); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 06f9e86a97d5..b118d15ced67 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -244,6 +244,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { : DesktopAppSettings.layer; const updateRestartMarkers = new Set(); + const updateMarkerContents = new Map(); const installRecords = new Map(); let previousInstallRecord = options.installRecord; const isInstallRecord = (path: string) => @@ -269,11 +270,21 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { writeFileString: (path, data) => Effect.sync(() => { if (isInstallRecord(path)) installRecords.set(path, data); - else updateRestartMarkers.add(path); + else { + if (path.endsWith("/desktop-update-restart")) updateRestartMarkers.add(path); + updateMarkerContents.set(path, data); + } + }), + rename: (from, to) => + Effect.sync(() => { + const contents = updateMarkerContents.get(from); + if (contents !== undefined) updateMarkerContents.set(to, contents); + updateMarkerContents.delete(from); }), remove: (path) => Effect.sync(() => { updateRestartMarkers.delete(path); + updateMarkerContents.delete(path); installRecords.delete(path); if (isInstallRecord(path)) previousInstallRecord = undefined; }), @@ -306,6 +317,8 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { quitAndInstalls: () => quitAndInstallCount, installSteps, updateRestartMarkers, + updateInProgressMarker: () => + [...updateMarkerContents].find(([path]) => path.endsWith("/desktop-update-in-progress"))?.[1], installRecords, previousInstallRecord: () => previousInstallRecord, downloadCount: () => downloadCount, diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 8468a9538630..d9ae6e76c29c 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -154,7 +154,7 @@ describe("DesktopWslBackend", () => { assert.equal(startCount, 1); assert.deepEqual(yield* backend.lastPreflightError, refusal); - yield* clearFailure(new URL("http://127.0.0.1:41773")); + yield* clearFailure(new URL("http://127.0.0.1:41773"), 123); assert.deepEqual(yield* backend.lastPreflightError, Option.none()); yield* recordOwnershipFailure(); const settings = yield* DesktopAppSettings.DesktopAppSettings; diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts index 7eaf362a326a..34dcbea08a0e 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -6,7 +6,6 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Scope from "effect/Scope"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -42,20 +41,14 @@ const layerEnvironment = (input: { ), ); -const withTempDir = ( - run: (tempDir: string) => Effect.Effect, -): Effect.Effect< - A, - E | PlatformError.PlatformError, - FileSystem.FileSystem | Exclude -> => +const withTempDir = (run: (tempDir: string) => Effect.Effect) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-wsl-server-tree-test-", }); return yield* run(tempDir); - }).pipe(Effect.scoped); + }); const ensureWith = (input: { readonly baseDir: string; diff --git a/apps/desktop/src/wsl/wslPathParsing.test.ts b/apps/desktop/src/wsl/wslPathParsing.test.ts index 0a814229665d..ed491f58381a 100644 --- a/apps/desktop/src/wsl/wslPathParsing.test.ts +++ b/apps/desktop/src/wsl/wslPathParsing.test.ts @@ -77,8 +77,15 @@ describe("wslUncPathToLinuxPath", () => { expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\Debian\\")).toBe("/"); }); + it("maps UNC paths of distros whose names start or end with a dot or hyphen", () => { + for (const name of ["Ubuntu-", "Ubuntu.", ".Ubuntu", "-Ubuntu"]) { + expect(wslUncPathToLinuxPath(`\\\\wsl.localhost\\${name}\\home\\josh`)).toBe("/home/josh"); + } + }); + it("rejects invalid distro names and non-WSL paths", () => { expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\bad!name\\home")).toBeNull(); + expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\..\\home")).toBeNull(); expect(wslUncPathToLinuxPath("C:\\Users\\Josh\\repo")).toBeNull(); }); }); @@ -107,9 +114,13 @@ describe("resolveWslPickFolderDefaultPath", () => { }); describe("isValidDistroName", () => { - it("rejects names with trailing whitespace, hyphen, or dot", () => { - for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { - expect(isValidDistroName(name)).toBe(false); + it("rejects names with trailing whitespace", () => { + expect(isValidDistroName("Ubuntu ")).toBe(false); + }); + + it("accepts names WSL registers with a leading or trailing dot or hyphen", () => { + for (const name of ["Ubuntu-", "Ubuntu.", ".Ubuntu", "-Ubuntu"]) { + expect(isValidDistroName(name)).toBe(true); } }); diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index a1ce99c129d1..1df64f91dac0 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -9,7 +9,8 @@ export interface WslConfig { } // Literal spaces only; \s would accept controls and corrupt UNC distro paths. -const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; +// "." and ".." would be path segments in a UNC path, not distro names. +const DISTRO_NAME_PATTERN = /^(?!\.{1,2}$)[\w.-](?:[\w .-]*[\w.-])?$/; export function parseWslDistroList(stdout: Buffer): readonly WslDistro[] { const hasUtf16Bom = stdout.length >= 2 && stdout[0] === 0xff && stdout[1] === 0xfe; diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index 2abb1858b3c6..e7623faa8fe2 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,4 +1,4 @@ -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, signed image sources, the `onLinksChange` link list (`renderers`, `resolveImageSource`, `onLinksChange`) and the iOS streaming memory bounds are upstream. The fork carries only changes with an open upstream PR: 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 hash-route link fragments ([mity/md4c#454](https://github.com/mity/md4c/pull/454), arriving through [md4c#7](https://github.com/software-mansion-labs/md4c/pull/7)), `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [mity/md4c#477](https://github.com/mity/md4c/pull/477)), GitHub's strikethrough rules for `~` ([md4c#6](https://github.com/software-mansion-labs/md4c/pull/6)), `\(…\)` and `\[…\]` math delimiters ([#1043](https://github.com/software-mansion/enriched-markdown/pull/1043), [md4c#4](https://github.com/software-mansion-labs/md4c/pull/4)), iOS Copy as Markdown keeping bold spans whole ([#1143](https://github.com/software-mansion/enriched-markdown/pull/1143)), line breaks in copied table cells written as `
` ([#1144](https://github.com/software-mansion/enriched-markdown/pull/1144)), and Android measuring text in the span order its view draws it, so code blocks nested in list items no longer make a reply overlap the next message ([#1145](https://github.com/software-mansion/enriched-markdown/pull/1145)). +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, signed image sources, the `onLinksChange` link list (`renderers`, `resolveImageSource`, `onLinksChange`) and the iOS streaming memory bounds are upstream. The fork carries only changes with an open upstream PR: 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 hash-route link fragments ([mity/md4c#454](https://github.com/mity/md4c/pull/454), arriving through [md4c#7](https://github.com/software-mansion-labs/md4c/pull/7)), `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [mity/md4c#477](https://github.com/mity/md4c/pull/477)), GitHub's strikethrough rules for `~` ([md4c#6](https://github.com/software-mansion-labs/md4c/pull/6)), `\(…\)` and `\[…\]` math delimiters ([#1043](https://github.com/software-mansion/enriched-markdown/pull/1043), [md4c#4](https://github.com/software-mansion-labs/md4c/pull/4)), iOS Copy as Markdown keeping bold spans whole ([#1143](https://github.com/software-mansion/enriched-markdown/pull/1143)), line breaks in copied table cells written as `
` ([#1144](https://github.com/software-mansion/enriched-markdown/pull/1144)), and Android measuring text in the span order its view draws it, so code blocks nested in list items no longer make a reply overlap the next message ([#1145](https://github.com/software-mansion/enriched-markdown/pull/1145)). It also carries one fork-only change: iOS code blocks too big for one backing store draw through tiles, so a tall block with long lines no longer shows an empty pane ([pingdotgg/enriched-markdown#15](https://github.com/pingdotgg/enriched-markdown/pull/15)). 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.18-4287c5d3.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.18-0dc7ed81.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz new file mode 100644 index 000000000000..74fcb8768906 Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz differ diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-497fbe63.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-497fbe63.tgz deleted file mode 100644 index 2dbfd6d79604..000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.18-497fbe63.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 deleted file mode 100644 index f1eb3858e986..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.50.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.50.tgz new file mode 100644 index 000000000000..74c351ddc337 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.50.tgz differ diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt index 1df8c646d483..4ac5b47a42f3 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt @@ -7,6 +7,7 @@ import android.os.Build import android.text.Editable import android.text.InputType import android.text.TextWatcher +import android.text.method.PasswordTransformationMethod import android.util.TypedValue import android.view.Gravity import android.view.KeyEvent @@ -42,6 +43,7 @@ class T3SecretInputModule : Module() { view.input.setTextSize(TypedValue.COMPLEX_UNIT_DIP, value) } Prop("editable") { view: T3SecretInputView, value: Boolean -> view.input.isEnabled = value } + Prop("revealed") { view: T3SecretInputView, value: Boolean -> view.setRevealed(value) } Events("onChangeText", "onSubmit") } } @@ -133,6 +135,18 @@ class T3SecretInputView(context: Context, appContext: AppContext) : ExpoView(con applyingValue = false } + /** Swapping the transformation re-sets the text, so it neither echoes to JS nor moves the caret. */ + fun setRevealed(revealed: Boolean) { + val method = if (revealed) null else PasswordTransformationMethod.getInstance() + if (input.transformationMethod == method) return + val start = input.selectionStart + val end = input.selectionEnd + applyingValue = true + input.transformationMethod = method + if (start >= 0 && end >= 0) input.setSelection(start, end) + applyingValue = false + } + fun setCursorColor(color: Int) { input.highlightColor = Color.argb(0x52, Color.red(color), Color.green(color), Color.blue(color)) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 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 e5c8f6c0fd8f..c41d2d545ca6 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 @@ -67,6 +67,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private var streamedOutput: TerminalOutputBuffer? = null private var cols = 0 private var rows = 0 + private var cellSize = 0 to 0 private var measuredCols = 0 private var measuredRows = 0 private var clearingInput = false @@ -356,25 +357,24 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex applyGrid() } + @Suppress("ComplexCondition") 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 + val nextCellSize = terminalCanvas.cellWidthPx.toInt() to terminalCanvas.cellHeightPx.toInt() + if (nextCols == cols && nextRows == rows && nextCellSize == cellSize && terminalHandle != 0L) { + return + } cols = nextCols rows = nextRows + cellSize = nextCellSize val response = if (terminalHandle == 0L) { createTerminal() ByteArray(0) } else { - GhosttyBridge.nativeResize( - terminalHandle, - cols, - rows, - terminalCanvas.cellWidthPx.toInt(), - terminalCanvas.cellHeightPx.toInt(), - ) + GhosttyBridge.nativeResize(terminalHandle, cols, rows, cellSize.first, cellSize.second) } emitResponse(response) if (!hasPendingBuffer) feedPendingBuffer() @@ -387,8 +387,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalHandle = GhosttyBridge.nativeCreate( cols, rows, - terminalCanvas.cellWidthPx.toInt(), - terminalCanvas.cellHeightPx.toInt(), + cellSize.first, + cellSize.second, foregroundColorValue, backgroundColorValue, cursorColorValue, diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index d8f0b29ebce5..1ed02e6a16cb 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -285,7 +285,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != fontSize else { return } inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) - resetSurface() + applyConfigInPlace() } } @@ -321,14 +321,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != appearanceScheme else { return } appearance = TerminalAppearanceScheme(value: appearanceScheme) - resetSurface() + applyConfigInPlace() } } var themeConfig: String = "" { didSet { guard oldValue != themeConfig else { return } - resetSurface() + applyConfigInPlace() } } @@ -663,7 +663,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte surfaceCreationFailed = true return } - loadThemeConfig(into: config) + // Creation proceeds without the file, as before; font_size is also set on the surface config. + _ = loadThemeConfig(into: config) ghostty_config_finalize(config) defer { ghostty_config_free(config) } @@ -710,6 +711,27 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte setNeedsLayout() } + // Font and theme change in place: a new surface would replay output and re-answer its queries. + private func applyConfigInPlace() { + guard let app, let surface else { + resetSurface() + return + } + guard let config = ghostty_config_new() else { return } + defer { ghostty_config_free(config) } + let loaded = loadThemeConfig(into: config) + ghostty_config_finalize(config) + ghostty_app_set_color_scheme(app, appearance.ghosttyColorScheme) + ghostty_surface_set_color_scheme(surface, appearance.ghosttyColorScheme) + tickApp() + // Without the file, the config lacks font-size and would reset the surface to defaults. + if loaded { + ghostty_surface_update_config(surface, config) + } + resizeSurface() + setNeedsLayout() + } + private func destroySurface() { // Unblock the io thread before ghostty_surface_free joins it. tickApp() @@ -933,16 +955,16 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte terminalViewport.backgroundColor = backgroundColorValue } - private func loadThemeConfig(into config: ghostty_config_t) { - guard let path = writeThemeConfigFile() else { return } + private func loadThemeConfig(into config: ghostty_config_t) -> Bool { + guard let path = writeThemeConfigFile() else { return false } path.withCString { cString in ghostty_config_load_file(config, cString) } + return true } private func writeThemeConfigFile() -> String? { - guard !themeConfig.isEmpty else { return nil } - let configContents = themeConfig + let configContents = "\(themeConfig)\nfont-size = \(String(format: "%g", Double(fontSize)))\n" let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty") diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ca0b86a7a06d..1892b649061f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -126,7 +126,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-native": "0.88.1", - "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.18-497fbe63.tgz", + "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.18-0dc7ed81.tgz", "react-native-gesture-handler": "~3.5.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.23.0", @@ -134,7 +134,7 @@ "react-native-pdf-jsi": "6.1.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.11.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.48.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.50.tgz", "react-native-svg": "15.16.1", "react-native-webview": "14.0.1", "react-native-worklets": "0.13.1", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index d2577b07c004..fc4dfdf3a417 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -4,7 +4,7 @@ import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; import { requireNativeModule } from "expo"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Platform, StatusBar } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; @@ -77,6 +77,12 @@ import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarning import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; +import { + launchSplashHomeRowsPending, + settleLaunchSplashHomeRows, + splashCanHide, + useLaunchSplashHomeRowsPending, +} from "./features/home/home-launch-splash"; import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; import { hydrateWorkspaceSelections, @@ -166,6 +172,8 @@ function handleNavigationStateChange(state: NavigationState | undefined): void { resolvePendingNewTaskPromptLink(state); resolvePendingThreadPromptLink(state); if (!state) return; + // A launch that opens another route has no Home rows for the splash to wait for. + if (state.routes[state.index]?.name !== "Home") settleLaunchSplashHomeRows(); setAgentNotificationPathname(workspaceLocationFromState(state).pathname); recordAppShortcutNavigationState(state); pruneNewTaskFlowStates(state); @@ -204,28 +212,38 @@ disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.ho for (const dispose of appServiceDisposers) dispose(); }); -// Cached shells are local reads that end in a fraction of this time; the limit only keeps a -// failed read from holding the splash screen. -const SPLASH_SHELL_CACHE_WAIT_MS = 1_500; +// Cached shells are local reads that end in a fraction of this time; the limit keeps a failed +// read, or a list that does not lay out, from holding the splash screen. +const SPLASH_WAIT_MS = 1_500; function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); const shellCachesRead = useAtomValue(shellCachesReadAtom); + const homeRowsPending = useLaunchSplashHomeRowsPending(); const [waitEnded, setWaitEnded] = useState(false); + const hidden = useRef(false); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the limit on how long the native splash screen waits for cached shells + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the limit on how long the native splash screen waits for cached shells and their rows useEffect(() => { - const timer = setTimeout(() => setWaitEnded(true), SPLASH_SHELL_CACHE_WAIT_MS); + const timer = setTimeout(() => setWaitEnded(true), SPLASH_WAIT_MS); return () => clearTimeout(timer); }, []); - // The thread list paints every environment's cached rows at once when the splash hides - // after they are read. - const canHide = isReady && (shellCachesRead || waitEnded); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once preferences and cached shells have loaded + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once preferences have loaded and the cached rows are laid out useEffect(() => { - if (canHide) void SplashScreen.hide(); - }, [canHide]); + const canHide = splashCanHide({ + preferencesReady: isReady, + shellCachesRead, + // Home holds the splash in a layout effect of the commit that gives it rows. That can be + // this commit, after this render read the store. + homeRowsPending: homeRowsPending || launchSplashHomeRowsPending(), + waitEnded, + }); + if (!canHide || hidden.current) return; + hidden.current = true; + settleLaunchSplashHomeRows(); + void SplashScreen.hide(); + }, [isReady, shellCachesRead, homeRowsPending, waitEnded]); return null; } diff --git a/apps/mobile/src/components/ui/AppBar.android.tsx b/apps/mobile/src/components/ui/AppBar.android.tsx index f647535a1d42..731872951180 100644 --- a/apps/mobile/src/components/ui/AppBar.android.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.tsx @@ -2,6 +2,7 @@ import { type RefObject, use, useRef, useState } from "react"; import { Keyboard, type TextInputInstance, View } from "react-native"; import { useHardwareBack } from "../../lib/backHandler"; import { useToolbarLayout } from "./useToolbarLayout"; +import { BottomSheetContext } from "./BottomSheetContext"; import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useSidebarToggle } from "../../features/layout/useSidebarToggle"; @@ -82,12 +83,14 @@ function AppBarView(props: AppBarProps & { readonly searchState?: SearchState }) const inWorkspace = useHasWorkspaceAppBar(); const sidebarToggle = useSidebarToggle(); const closeInspector = use(InspectorCloseContext); + // A bottom sheet owns the pull at its top, so refresh needs a button there. + const inBottomSheet = use(BottomSheetContext); const inline = search?.mode === "inline"; const searching = searchState?.searching === true; const actions = props.refresh ? [ ...(props.actions ?? []), - { ...props.refresh, icon: "arrow.clockwise" as const, overflowOnly: true }, + { ...props.refresh, icon: "arrow.clockwise" as const, overflowOnly: !inBottomSheet }, ] : props.actions; const openSearch = () => searchState?.open(); diff --git a/apps/mobile/src/components/ui/AppBar.tsx b/apps/mobile/src/components/ui/AppBar.tsx index 7f76d9106613..b691faa300da 100644 --- a/apps/mobile/src/components/ui/AppBar.tsx +++ b/apps/mobile/src/components/ui/AppBar.tsx @@ -1,8 +1,8 @@ import { Toolbar } from "./Toolbar"; import { AppActivityIndicator } from "./AppActivityIndicator"; -import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; -import { NavigationContext } from "@react-navigation/native"; -import { use, useId, useLayoutEffect } from "react"; +import type { HeaderBarButtonMailSearchToolbarItem, SearchBarCommands } from "react-native-screens"; +import { NavigationContext, NavigationRouteContext } from "@react-navigation/native"; +import { use, useId, useLayoutEffect, useRef, useSyncExternalStore } from "react"; import { View } from "react-native"; import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; import { ScreenHeaderButton } from "./ScreenHeaderButton"; @@ -63,6 +63,17 @@ export function AppBar(props: AppBarProps) { ? { ...props.backInSplitView, onPress: splitBackPress } : null; const navigation = use(NavigationContext); + const route = use(NavigationRouteContext); + // A thread opened from another thread sits on it, not beside the list, so it keeps Back. + const threadBelow = useSyncExternalStore( + (onChange) => navigation?.addListener("state", onChange) ?? (() => {}), + () => { + const routes = navigation?.getState()?.routes; + const index = routes?.findIndex((candidate) => candidate.key === route?.key) ?? -1; + return route?.name === "Thread" && index > 0 && routes?.[index - 1]?.name === "Thread"; + }, + ); + const listBeside = layout.usesSplitView && props.onBackToList !== undefined && !threadBelow; const rootBack = !layout.usesSplitView && props.onBackToList && navigation?.canGoBack() === false ? props.onBackToList @@ -78,6 +89,19 @@ export function AppBar(props: AppBarProps) { const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const { search, menus } = props; const menu = menus?.[0]; + const searchBarRef = useRef(null); + const nativeSearchText = useRef(search?.value ?? ""); + const searchValue = search?.value; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the native search bar keeps its own text; a value the caller changes (a reset) has to be pushed into it + useLayoutEffect(() => { + if (searchValue === undefined || searchValue === nativeSearchText.current) return; + nativeSearchText.current = searchValue; + searchBarRef.current?.setText(searchValue); + }, [searchValue]); + const reportSearchText = (text: string) => { + nativeSearchText.current = text; + search?.onChangeText(text); + }; const compactSearch = search !== undefined && (search.compactToolbar ?? !layout.usesSplitView) && @@ -100,7 +124,7 @@ export function AppBar(props: AppBarProps) { headerBackVisible: props.onClose === undefined && splitBack === null && - !(layout.usesSplitView && props.onBackToList) && + !listBeside && !(props.guardBack && props.onBack), ...(props.guardBack ? { gestureEnabled: false } : undefined), title: props.title, @@ -142,8 +166,9 @@ export function AppBar(props: AppBarProps) { ? { obscureBackground: false } : undefined), placeholder: search.placeholder, - onChangeText: (event) => search.onChangeText(event.nativeEvent.text), - onCancelButtonPress: () => search.onChangeText(""), + ref: searchBarRef, + onChangeText: (event) => reportSearchText(event.nativeEvent.text), + onCancelButtonPress: () => reportSearchText(""), }, } : undefined), diff --git a/apps/mobile/src/components/ui/AppBar.types.ts b/apps/mobile/src/components/ui/AppBar.types.ts index 88d711be7154..071b0609606e 100644 --- a/apps/mobile/src/components/ui/AppBar.types.ts +++ b/apps/mobile/src/components/ui/AppBar.types.ts @@ -78,7 +78,10 @@ export interface AppBarProps { readonly titleContent?: ReactNode; readonly subtitle?: string | null; readonly actions?: ReadonlyArray; - /** Pull-to-refresh is the gesture on both platforms; Android also lists this in the overflow menu. */ + /** + * Pull-to-refresh is the gesture on both platforms. Android also lists this in the overflow + * menu, or shows it as a button in a bottom sheet, where the sheet owns the pull. + */ readonly refresh?: Pick; readonly menus?: ReadonlyArray; readonly search?: AppBarSearch; diff --git a/apps/mobile/src/components/ui/BottomSheetContext.ts b/apps/mobile/src/components/ui/BottomSheetContext.ts new file mode 100644 index 000000000000..c635f860e02d --- /dev/null +++ b/apps/mobile/src/components/ui/BottomSheetContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; + +/** True inside a draggable bottom sheet, which owns the downward drag at the top of its content. */ +export const BottomSheetContext = createContext(false); diff --git a/apps/mobile/src/components/ui/ContentBand.android.tsx b/apps/mobile/src/components/ui/ContentBand.android.tsx new file mode 100644 index 000000000000..5a1eb8354302 --- /dev/null +++ b/apps/mobile/src/components/ui/ContentBand.android.tsx @@ -0,0 +1,46 @@ +import { View } from "react-native"; + +import { cn } from "../../lib/cn"; +import { AppActivityIndicator } from "./AppActivityIndicator"; +import { AppText as Text } from "./AppText"; +import type { ContentBandProps, ContentStatusProps } from "./ContentBand.types"; +export type { ContentBandProps, ContentStatusProps } from "./ContentBand.types"; + +/** A rounded card floating in the content's margin. */ +export function ContentBand(props: ContentBandProps) { + return ( + + {props.children} + + ); +} + +/** Material centers an empty state in the space it has. */ +export function ContentStatus(props: ContentStatusProps) { + if (props.loading) { + return ( + + + {props.detail} + + ); + } + return ( + + {props.title ? ( + {props.title} + ) : null} + + {props.detail} + + + ); +} + +export const contentStatusContainerStyle = { flexGrow: 1, justifyContent: "center" } as const; diff --git a/apps/mobile/src/components/ui/ContentBand.tsx b/apps/mobile/src/components/ui/ContentBand.tsx new file mode 100644 index 000000000000..cb6bca78fdca --- /dev/null +++ b/apps/mobile/src/components/ui/ContentBand.tsx @@ -0,0 +1,44 @@ +import { View } from "react-native"; + +import { cn } from "../../lib/cn"; +import { AppActivityIndicator } from "./AppActivityIndicator"; +import { AppText as Text } from "./AppText"; +import type { ContentBandProps, ContentStatusProps } from "./ContentBand.types"; +export type { ContentBandProps, ContentStatusProps } from "./ContentBand.types"; + +/** A full-width band above or in place of content, ruled off from what follows. */ +export function ContentBand(props: ContentBandProps) { + return ( + + {props.children} + + ); +} + +export function ContentStatus(props: ContentStatusProps) { + if (props.loading) { + return ( + + + {props.detail} + + ); + } + return ( + + {props.title ? ( + {props.title} + ) : null} + {props.detail} + + ); +} + +/** The scroll content style for a ContentStatus with nothing else in the scroll view. */ +export const contentStatusContainerStyle = undefined; diff --git a/apps/mobile/src/components/ui/ContentBand.types.ts b/apps/mobile/src/components/ui/ContentBand.types.ts new file mode 100644 index 000000000000..95843c3e0765 --- /dev/null +++ b/apps/mobile/src/components/ui/ContentBand.types.ts @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +export interface ContentBandProps { + readonly tone?: "card" | "warning"; + /** "roomy" for a band holding a block of content, such as a raw patch. */ + readonly spacing?: "compact" | "roomy"; + readonly children: ReactNode; +} + +/** A status at the top of otherwise empty content, such as "No changes" or a loading diff. */ +export interface ContentStatusProps { + readonly title?: string; + readonly detail: string; + readonly loading?: boolean; +} diff --git a/apps/mobile/src/components/ui/FitSheet.android.tsx b/apps/mobile/src/components/ui/FitSheet.android.tsx new file mode 100644 index 000000000000..a797e87a8a65 --- /dev/null +++ b/apps/mobile/src/components/ui/FitSheet.android.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from "react"; +import { ScrollView, useWindowDimensions, View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { fitSheetBottomInset, type FitSheetProps } from "./FitSheet.types"; +import { ScreenContent } from "./ScreenContent"; +export type { FitSheetProps } from "./FitSheet.types"; + +/** The route sizes the sheet to this content, so the column shrinks to fit and stops short of the top. */ +export function FitSheet({ + header, + footer, + avoidsKeyboard, + children, + contentContainerClassName = "gap-2 p-2", + contentContainerStyle, + ...scrollProps +}: FitSheetProps) { + const insets = useSafeAreaInsets(); + return ( + // Material sheet drags follow the first nested-scrolling native view, and a header's + // Compose button is one, so the content renders first in a reversed column. + + + + {children} + {/* Partial-height sheets keep submit actions inside the scrolling form. */} + {footer ? ( + + {footer} + + ) : null} + + + {header} + + ); +} + +function SheetContainer(props: { + readonly avoidsKeyboard?: boolean; + readonly children: ReactNode; +}) { + const { height } = useWindowDimensions(); + const style = { maxHeight: height * 0.92 }; + return props.avoidsKeyboard ? ( + + {props.children} + + ) : ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/FitSheet.tsx b/apps/mobile/src/components/ui/FitSheet.tsx new file mode 100644 index 000000000000..f783dbcd964a --- /dev/null +++ b/apps/mobile/src/components/ui/FitSheet.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { ScrollView, View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { fitSheetBottomInset, type FitSheetProps } from "./FitSheet.types"; +import { ScreenContent } from "./ScreenContent"; +export type { FitSheetProps } from "./FitSheet.types"; + +export function FitSheet({ + header, + footer, + avoidsKeyboard, + children, + contentContainerClassName = "gap-4 px-5 pt-2", + contentContainerStyle, + ...scrollProps +}: FitSheetProps) { + const insets = useSafeAreaInsets(); + return ( + + {header} + + + {children} + + {footer ? ( + + {footer} + + ) : null} + + + ); +} + +function SheetContainer(props: { + readonly avoidsKeyboard?: boolean; + readonly children: ReactNode; +}) { + return props.avoidsKeyboard ? ( + + {props.children} + + ) : ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/FitSheet.types.ts b/apps/mobile/src/components/ui/FitSheet.types.ts new file mode 100644 index 000000000000..a47fb124ea36 --- /dev/null +++ b/apps/mobile/src/components/ui/FitSheet.types.ts @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import type { ScrollViewProps } from "react-native"; + +/** The body of a `sheetRoute({ androidFit: true })` screen: a header over one scrolling form. */ +export interface FitSheetProps extends Omit< + ScrollViewProps, + "children" | "className" | "nestedScrollEnabled" | "contentInsetAdjustmentBehavior" +> { + readonly header: ReactNode; + readonly children: ReactNode; + /** Submit actions, kept reachable below the form. */ + readonly footer?: ReactNode; + /** Lifts the sheet above the keyboard, for forms whose inputs sit low. */ + readonly avoidsKeyboard?: boolean; +} + +export const fitSheetBottomInset = (bottom: number) => Math.max(bottom, 18) + 18; diff --git a/apps/mobile/src/components/ui/FormSection.android.tsx b/apps/mobile/src/components/ui/FormSection.android.tsx new file mode 100644 index 000000000000..8f68b99477e1 --- /dev/null +++ b/apps/mobile/src/components/ui/FormSection.android.tsx @@ -0,0 +1,23 @@ +import { View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "./AppText"; +import type { FormFieldProps, FormSectionProps } from "./FormSection.types"; +export type { FormFieldProps, FormSectionProps } from "./FormSection.types"; + +export function FormSection(props: FormSectionProps) { + return ( + + {props.title} + {props.children} + + ); +} + +export function FormField({ label, ...props }: FormFieldProps) { + return ( + <> + {label ? {label} : null} + + + ); +} diff --git a/apps/mobile/src/components/ui/FormSection.tsx b/apps/mobile/src/components/ui/FormSection.tsx new file mode 100644 index 000000000000..7f3e7a5d8063 --- /dev/null +++ b/apps/mobile/src/components/ui/FormSection.tsx @@ -0,0 +1,20 @@ +import { View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "./AppText"; +import type { FormFieldProps, FormSectionProps } from "./FormSection.types"; +export type { FormFieldProps, FormSectionProps } from "./FormSection.types"; + +export function FormSection(props: FormSectionProps) { + return ( + + + {props.title} + + {props.children} + + ); +} + +export function FormField({ label: _label, ...props }: FormFieldProps) { + return ; +} diff --git a/apps/mobile/src/components/ui/FormSection.types.ts b/apps/mobile/src/components/ui/FormSection.types.ts new file mode 100644 index 000000000000..4ef7015aa98c --- /dev/null +++ b/apps/mobile/src/components/ui/FormSection.types.ts @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +import type { AppTextInputProps } from "./AppText"; + +/** A titled card holding a short form: its fields and the action that submits them. */ +export interface FormSectionProps { + readonly title: string; + readonly children: ReactNode; +} + +export interface FormFieldProps extends Omit { + /** Shown where the platform labels fields; the placeholder carries it elsewhere. */ + readonly label?: string; + readonly accessibilityLabel: string; +} diff --git a/apps/mobile/src/components/ui/KeyboardSheetBody.android.tsx b/apps/mobile/src/components/ui/KeyboardSheetBody.android.tsx new file mode 100644 index 000000000000..8f889fd9682d --- /dev/null +++ b/apps/mobile/src/components/ui/KeyboardSheetBody.android.tsx @@ -0,0 +1,41 @@ +import { View } from "react-native"; +import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import type { KeyboardSheetBodyProps } from "./KeyboardSheetBody.types"; +export type { KeyboardSheetBodyProps } from "./KeyboardSheetBody.types"; + +const TOOLBAR_CLEARANCE = 72; + +/** The toolbar sticks to the keyboard on its own, over the body's bottom edge. */ +export function KeyboardSheetBody(props: KeyboardSheetBodyProps) { + const insets = useSafeAreaInsets(); + return ( + <> + + + {props.children} + + + {props.toolbar ? ( + + + {props.toolbar} + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ui/KeyboardSheetBody.tsx b/apps/mobile/src/components/ui/KeyboardSheetBody.tsx new file mode 100644 index 000000000000..c09e9f5ce727 --- /dev/null +++ b/apps/mobile/src/components/ui/KeyboardSheetBody.tsx @@ -0,0 +1,30 @@ +import { View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import type { KeyboardSheetBodyProps } from "./KeyboardSheetBody.types"; +import { useBarOverlap } from "./useBarInset"; +export type { KeyboardSheetBodyProps } from "./KeyboardSheetBody.types"; + +/** A sheet's body that stays above the keyboard, with its toolbar between the two. */ +export function KeyboardSheetBody(props: KeyboardSheetBodyProps) { + const insets = useSafeAreaInsets(); + // The sheet's glass bar floats over its content, and only scroll views inset for it on their own. + const barOverlap = useBarOverlap(); + return ( + + + {props.children} + + {props.toolbar ? ( + {props.toolbar} + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ui/KeyboardSheetBody.types.ts b/apps/mobile/src/components/ui/KeyboardSheetBody.types.ts new file mode 100644 index 000000000000..00c2ac30f3c6 --- /dev/null +++ b/apps/mobile/src/components/ui/KeyboardSheetBody.types.ts @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export interface KeyboardSheetBodyProps { + readonly children: ReactNode; + /** A row of actions that rides the keyboard, such as attach and send. */ + readonly toolbar?: ReactNode; +} diff --git a/apps/mobile/src/components/ui/PaneListItem.android.tsx b/apps/mobile/src/components/ui/PaneListItem.android.tsx new file mode 100644 index 000000000000..bcc227457b36 --- /dev/null +++ b/apps/mobile/src/components/ui/PaneListItem.android.tsx @@ -0,0 +1,21 @@ +import { Pressable } from "react-native"; + +import { cn } from "../../lib/cn"; +import type { PaneListItemProps } from "./PaneListItem.types"; +export type { PaneListItemProps } from "./PaneListItem.types"; + +export function PaneListItem(props: PaneListItemProps) { + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/PaneListItem.tsx b/apps/mobile/src/components/ui/PaneListItem.tsx new file mode 100644 index 000000000000..4a4346872917 --- /dev/null +++ b/apps/mobile/src/components/ui/PaneListItem.tsx @@ -0,0 +1,22 @@ +import { Pressable } from "react-native"; + +import type { PaneListItemProps } from "./PaneListItem.types"; +export type { PaneListItemProps } from "./PaneListItem.types"; + +/** A selectable entry in a pane's navigation list, such as a changed file. */ +export function PaneListItem(props: PaneListItemProps) { + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/PaneListItem.types.ts b/apps/mobile/src/components/ui/PaneListItem.types.ts new file mode 100644 index 000000000000..2f661883ea0b --- /dev/null +++ b/apps/mobile/src/components/ui/PaneListItem.types.ts @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export interface PaneListItemProps { + readonly selected: boolean; + readonly onPress: () => void; + readonly children: ReactNode; +} diff --git a/apps/mobile/src/components/ui/PaneSheet.android.tsx b/apps/mobile/src/components/ui/PaneSheet.android.tsx new file mode 100644 index 000000000000..c0e572f53451 --- /dev/null +++ b/apps/mobile/src/components/ui/PaneSheet.android.tsx @@ -0,0 +1,26 @@ +import { View } from "react-native"; +import { FormSheet } from "react-native-screens"; +import { useResolveClassNames } from "uniwind"; + +import { BottomSheetContext } from "./BottomSheetContext"; +import type { PaneSheetProps } from "./PaneSheet.types"; +export type { PaneSheetProps } from "./PaneSheet.types"; + +/** A native sheet holding a pane that draws its own bar, which runs up under the drawn grabber. */ +export function PaneSheet({ children, ...props }: PaneSheetProps) { + const containerStyle = useResolveClassNames("bg-header"); + return ( + + + {/* Android FormSheet does not bound flex children. */} + + {children} + + + + + ); +} diff --git a/apps/mobile/src/components/ui/PaneSheet.tsx b/apps/mobile/src/components/ui/PaneSheet.tsx new file mode 100644 index 000000000000..422635120cc6 --- /dev/null +++ b/apps/mobile/src/components/ui/PaneSheet.tsx @@ -0,0 +1,21 @@ +import { View } from "react-native"; +import { FormSheet } from "react-native-screens"; +import { useResolveClassNames } from "uniwind"; + +import { BottomSheetContext } from "./BottomSheetContext"; +import type { PaneSheetProps } from "./PaneSheet.types"; +export type { PaneSheetProps } from "./PaneSheet.types"; + +/** A native sheet holding a pane that draws its own bar. */ +export function PaneSheet({ children, ...props }: PaneSheetProps) { + const containerStyle = useResolveClassNames("bg-sheet"); + return ( + + + + {children} + + + + ); +} diff --git a/apps/mobile/src/components/ui/PaneSheet.types.ts b/apps/mobile/src/components/ui/PaneSheet.types.ts new file mode 100644 index 000000000000..3b9bc8634133 --- /dev/null +++ b/apps/mobile/src/components/ui/PaneSheet.types.ts @@ -0,0 +1,7 @@ +import type { ComponentProps } from "react"; +import type { FormSheet } from "react-native-screens"; + +export type PaneSheetProps = Omit< + ComponentProps, + "prefersGrabberVisible" | "preferredCornerRadius" | "nativeContainerStyle" +>; diff --git a/apps/mobile/src/components/ui/RefreshControl.android.tsx b/apps/mobile/src/components/ui/RefreshControl.android.tsx new file mode 100644 index 000000000000..e83dd2663398 --- /dev/null +++ b/apps/mobile/src/components/ui/RefreshControl.android.tsx @@ -0,0 +1,22 @@ +import { use } from "react"; +// oxlint-disable-next-line eslint/no-restricted-imports -- The wrapper every screen must use. +import { RefreshControl as NativeRefreshControl } from "react-native"; +import { View, type RefreshControlProps } from "react-native"; + +import { BottomSheetContext } from "./BottomSheetContext"; + +/** + * A bottom sheet takes the pull at the top, and Material reads the swipe-refresh layout as unable + * to scroll up, so the sheet collapses instead of scrolling back. Sheets get a plain wrapper that + * keeps the layout style the scroll view hands its refresh control. + */ +export function RefreshControl(props: RefreshControlProps) { + if (use(BottomSheetContext)) { + return ( + + {props.children} + + ); + } + return ; +} diff --git a/apps/mobile/src/components/ui/RefreshControl.tsx b/apps/mobile/src/components/ui/RefreshControl.tsx new file mode 100644 index 000000000000..02b2b648b7bd --- /dev/null +++ b/apps/mobile/src/components/ui/RefreshControl.tsx @@ -0,0 +1,2 @@ +// oxlint-disable-next-line eslint/no-restricted-imports -- The wrapper every screen must use. +export { RefreshControl } from "react-native"; diff --git a/apps/mobile/src/components/ui/SecretRevealButton.tsx b/apps/mobile/src/components/ui/SecretRevealButton.tsx new file mode 100644 index 000000000000..2ba628eec181 --- /dev/null +++ b/apps/mobile/src/components/ui/SecretRevealButton.tsx @@ -0,0 +1,25 @@ +import { Pressable } from "react-native"; + +import { SymbolView } from "./AppSymbol"; + +/** Eye toggle that shows or hides a secret field's typed text. */ +export function SecretRevealButton(props: { + readonly revealed: boolean; + readonly onPress: () => void; +}) { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/ui/SecretTextInput.android.tsx b/apps/mobile/src/components/ui/SecretTextInput.android.tsx index f93b091cf9b5..9a9f98bd55c1 100644 --- a/apps/mobile/src/components/ui/SecretTextInput.android.tsx +++ b/apps/mobile/src/components/ui/SecretTextInput.android.tsx @@ -1,8 +1,10 @@ import { requireNativeView } from "expo"; +import { useState } from "react"; import { processColor, View, type NativeSyntheticEvent, type ViewProps } from "react-native"; import { flattenThemeColor } from "../../lib/mobileTheme"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { SecretRevealButton } from "./SecretRevealButton"; import type { SecretTextInputProps } from "./SecretTextInput.types"; interface NativeSecretInputProps extends ViewProps { @@ -14,6 +16,7 @@ interface NativeSecretInputProps extends ViewProps { readonly cursorColor: number; readonly fontSize: number; readonly editable: boolean; + readonly revealed: boolean; readonly onChangeText: (event: NativeSyntheticEvent<{ readonly value: string }>) => void; readonly onSubmit: (event: NativeSyntheticEvent<{ readonly value: string }>) => void; } @@ -36,8 +39,9 @@ function nativeColor(value: string) { export function SecretTextInput(props: SecretTextInputProps) { const theme = useUniwindTheme(); const surface = theme["--color-input"]; + const [revealed, setRevealed] = useState(false); return ( - + props.onChangeText(event.nativeEvent.value)} onSubmit={(event) => props.onSubmitEditing(event.nativeEvent.value)} - style={{ height: LINE_HEIGHT }} + style={{ flex: 1, height: LINE_HEIGHT }} /> + setRevealed(!revealed)} /> ); } diff --git a/apps/mobile/src/components/ui/SecretTextInput.tsx b/apps/mobile/src/components/ui/SecretTextInput.tsx index ed8bba58daaa..643fe5acbb63 100644 --- a/apps/mobile/src/components/ui/SecretTextInput.tsx +++ b/apps/mobile/src/components/ui/SecretTextInput.tsx @@ -1,24 +1,35 @@ +import { useState } from "react"; +import { View } from "react-native"; + import { AppTextInput } from "./AppText"; +import { SecretRevealButton } from "./SecretRevealButton"; import type { SecretTextInputProps } from "./SecretTextInput.types"; /** Masked field for a value that must stay out of password managers and suggestions. */ export function SecretTextInput(props: SecretTextInputProps) { + const [revealed, setRevealed] = useState(false); return ( - props.onSubmitEditing(event.nativeEvent.text)} - /> + + props.onSubmitEditing(event.nativeEvent.text)} + /> + + setRevealed(!revealed)} /> + + ); } diff --git a/apps/mobile/src/components/ui/Sheet.android.tsx b/apps/mobile/src/components/ui/Sheet.android.tsx index d4eac0cb94c1..29efc04d810d 100644 --- a/apps/mobile/src/components/ui/Sheet.android.tsx +++ b/apps/mobile/src/components/ui/Sheet.android.tsx @@ -3,6 +3,7 @@ import { View } from "react-native"; import { FormSheet } from "react-native-screens"; import { useResolveClassNames } from "uniwind"; import { AppBar } from "./AppBar"; +import { BottomSheetContext } from "./BottomSheetContext"; import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; import { ScreenContent } from "./ScreenContent"; import type { SheetProps } from "./Sheet.types"; @@ -21,22 +22,24 @@ export function Sheet(props: SheetProps) { onDismiss={props.onClose} onNativeDismiss={props.onClose} > - - {/* 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 - /> - - - + + + {/* Android FormSheet does not bound flex children. */} + + setIsOpen(false)} + hideBottomBorder + /> + {props.children} + + + + ); } diff --git a/apps/mobile/src/components/ui/SheetIntro.android.tsx b/apps/mobile/src/components/ui/SheetIntro.android.tsx new file mode 100644 index 000000000000..0dd6ac142b4f --- /dev/null +++ b/apps/mobile/src/components/ui/SheetIntro.android.tsx @@ -0,0 +1,17 @@ +import { View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import type { SheetIntroProps } from "./SheetIntro.types"; +export type { SheetIntroProps } from "./SheetIntro.types"; + +/** Material sheets lead with the question itself, so the eyebrow is left out. */ +export function SheetIntro(props: SheetIntroProps) { + return ( + + {props.title} + + {props.description} + + + ); +} diff --git a/apps/mobile/src/components/ui/SheetIntro.tsx b/apps/mobile/src/components/ui/SheetIntro.tsx new file mode 100644 index 000000000000..a3b8e507445d --- /dev/null +++ b/apps/mobile/src/components/ui/SheetIntro.tsx @@ -0,0 +1,21 @@ +import { View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import type { SheetIntroProps } from "./SheetIntro.types"; +export type { SheetIntroProps } from "./SheetIntro.types"; + +export function SheetIntro(props: SheetIntroProps) { + return ( + + {props.eyebrow ? ( + + {props.eyebrow} + + ) : null} + {props.title} + + {props.description} + + + ); +} diff --git a/apps/mobile/src/components/ui/SheetIntro.types.ts b/apps/mobile/src/components/ui/SheetIntro.types.ts new file mode 100644 index 000000000000..d9ed545d6bed --- /dev/null +++ b/apps/mobile/src/components/ui/SheetIntro.types.ts @@ -0,0 +1,7 @@ +/** The lead of a decision sheet: what is being asked, above the choices. */ +export interface SheetIntroProps { + /** A short kicker such as "Confirm", where the platform uses one. */ + readonly eyebrow?: string; + readonly title: string; + readonly description: string; +} diff --git a/apps/mobile/src/components/ui/SheetPane.android.tsx b/apps/mobile/src/components/ui/SheetPane.android.tsx new file mode 100644 index 000000000000..7f707a976092 --- /dev/null +++ b/apps/mobile/src/components/ui/SheetPane.android.tsx @@ -0,0 +1,13 @@ +import { View } from "react-native"; + +import type { SheetPaneProps } from "./SheetPane.types"; +export type { SheetPaneProps } from "./SheetPane.types"; + +/** The root of a pane or sheet: the bar's color, so ScreenContent's card reads as set into it. */ +export function SheetPane(props: SheetPaneProps) { + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/SheetPane.tsx b/apps/mobile/src/components/ui/SheetPane.tsx new file mode 100644 index 000000000000..f420964034f4 --- /dev/null +++ b/apps/mobile/src/components/ui/SheetPane.tsx @@ -0,0 +1,16 @@ +import { View } from "react-native"; + +import type { SheetPaneProps } from "./SheetPane.types"; +export type { SheetPaneProps } from "./SheetPane.types"; + +/** The root of a pane or sheet that draws an AppBar above its ScreenContent. */ +export function SheetPane(props: SheetPaneProps) { + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ui/SheetPane.types.ts b/apps/mobile/src/components/ui/SheetPane.types.ts new file mode 100644 index 000000000000..743ff1cae132 --- /dev/null +++ b/apps/mobile/src/components/ui/SheetPane.types.ts @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export interface SheetPaneProps { + /** The iOS surface under the bar: a sheet's, or the screen's where the pane sits in a column. */ + readonly surface?: "sheet" | "screen"; + readonly children: ReactNode; +} diff --git a/apps/mobile/src/components/ui/listMetrics.android.ts b/apps/mobile/src/components/ui/listMetrics.android.ts new file mode 100644 index 000000000000..63ded41c1514 --- /dev/null +++ b/apps/mobile/src/components/ui/listMetrics.android.ts @@ -0,0 +1,10 @@ +/** Material's 48dp touch target and 56dp one-line list item. */ +export const ROW_MIN_HEIGHT = { control: 48, item: 56 } as const; + +/** Android sheets span the window, so a list stops at a readable width on a tablet or fold. */ +export const READABLE_WIDTH_STYLE = { width: "100%", maxWidth: 720, alignSelf: "center" } as const; + +/** Sections are cards with their own padding, set close in from ScreenContent's edges. */ +export function groupedListInsets(_inInspector: boolean) { + return { paddingHorizontal: 8, gap: 8 }; +} diff --git a/apps/mobile/src/components/ui/listMetrics.ts b/apps/mobile/src/components/ui/listMetrics.ts new file mode 100644 index 000000000000..c7f8e2f2d221 --- /dev/null +++ b/apps/mobile/src/components/ui/listMetrics.ts @@ -0,0 +1,10 @@ +/** Minimum heights of tappable rows: a header control, and a list item. */ +export const ROW_MIN_HEIGHT = { control: 44, item: 44 } as const; + +/** Keeps a list at a readable width. iOS sheets and columns already bound it. */ +export const READABLE_WIDTH_STYLE = undefined; + +/** Insets for a scroll view of grouped list sections. */ +export function groupedListInsets(inInspector: boolean) { + return { paddingHorizontal: inInspector ? 12 : 20, gap: 14 }; +} diff --git a/apps/mobile/src/components/ui/useBarInset.ts b/apps/mobile/src/components/ui/useBarInset.ts index f00b64c8e0b7..3a917d67dee4 100644 --- a/apps/mobile/src/components/ui/useBarInset.ts +++ b/apps/mobile/src/components/ui/useBarInset.ts @@ -65,3 +65,18 @@ export function useSearchToolbarInset(search: AppBarSearch | undefined): number ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET : 0; } + +/** Where a sheet's scrolling content ends: clear of the bottom safe area, with a little room. */ +export function useSheetContentBottomInset(): number { + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + return Platform.OS === "ios" + ? (columnMetrics?.safeArea.bottom ?? insets.bottom) + 8 + : Math.max(insets.bottom, 18) + 18; +} + +/** The bottom safe area a list in a sheet pads itself: Android's, which its sheets don't adjust for. */ +export function useUnadjustedBottomInset(): number { + const insets = useSafeAreaInsets(); + return Platform.OS === "android" ? insets.bottom : 0; +} diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index e9a24552ce36..1205c746e9fa 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -24,6 +24,7 @@ import * as Fiber from "effect/Fiber"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import * as MobileDatabase from "../persistence/mobile-database"; import { make } from "./environment-cache-store"; @@ -567,7 +568,7 @@ describe("mobile SQLite environment cache store", () => { }), ); - it.live("returns cached threads before their pull request links, which load after", () => + it.effect("returns cached threads before their pull request links, which load after", () => Effect.gen(function* () { const memory = makeDatabase(); const store = yield* make().pipe( @@ -592,12 +593,13 @@ describe("mobile SQLite environment cache store", () => { const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); expect(shell.threads.map((thread) => thread.pullRequests)).toEqual([undefined, undefined]); - const links = yield* shell.loadPullRequests!; - expect([...links]).toEqual([[THREAD_ID, [link]]]); + const links = yield* Effect.forkChild(shell.loadPullRequests!); + yield* TestClock.adjust("1 millis"); + expect([...(yield* Fiber.join(links))]).toEqual([[THREAD_ID, [link]]]); }), ); - it.live("keeps cached threads when their pull request links cannot be read", () => { + it.effect("keeps cached threads when their pull request links cannot be read", () => { const messages: Array = []; const logger = Logger.make(({ message }) => { messages.push(message); @@ -633,7 +635,9 @@ describe("mobile SQLite environment cache store", () => { const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); expect(shell.threads.map((thread) => thread.id)).toEqual([THREAD_ID, readable.id]); - expect([...(yield* shell.loadPullRequests!)]).toEqual([[readable.id, [link]]]); + const links = yield* Effect.forkChild(shell.loadPullRequests!); + yield* TestClock.adjust("1 millis"); + expect([...(yield* Fiber.join(links))]).toEqual([[readable.id, [link]]]); expect(messages).toHaveLength(1); expect(memory.removed).toEqual([]); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index fd3ce42403c8..4a1c77371b86 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -16,7 +16,8 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppBar } from "../../components/ui/AppBar"; import { SymbolView } from "../../components/ui/AppSymbol"; import { useMemo, useRef, type ComponentProps } from "react"; -import { RefreshControl, useWindowDimensions, View } from "react-native"; +import { useWindowDimensions, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { GestureDetector, useNativeGesture } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx index d83852f26046..b312eeb64534 100644 --- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -35,16 +35,33 @@ type BrowserPreviewRouteScreenProps = StaticScreenProps<{ export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) { const navigation = useNavigation(); const onClose = () => navigation.goBack(); + const { environmentId, threadId } = route.params; + if (!environmentId?.trim() || !threadId?.trim()) { + return ; + } return ( ); } +function BrowserUnavailableScreen({ onClose }: { readonly onClose: () => void }) { + const insets = useSafeAreaInsets(); + const contentTopInset = useContentTopInset(); + return ( + + + + ); +} + function BrowserPreviewScreen({ environmentId, threadId, diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index 3fae93f483bc..d4a4690dbaf4 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -405,7 +405,7 @@ function PreviewStreamDocumentView({ onPictureInPicture?.(message, message.detail); return; case "status": - clearBootstrapTimer(); + if (message.status !== "connecting") clearBootstrapTimer(); if (message.status === "error") { fail(message.detail ?? "Browser stream failed."); return; diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 463c11aaf648..3913eaa47557 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -5,7 +5,8 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScroll import { useAuth } from "@clerk/expo"; import { useNavigation } from "@react-navigation/native"; import { useState } from "react"; -import { RefreshControl, View } from "react-native"; +import { View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx index 03b6daaa4e71..71de00071fda 100644 --- a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -7,7 +7,8 @@ import type { MenuAction } from "@react-native-menu/menu"; import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { type ReactNode, useRef, useState } from "react"; -import { Pressable, RefreshControl, ScrollView, Text, View } from "react-native"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { SymbolView } from "../../components/ui/AppSymbol"; diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 73629f02fd25..2d0de616bc69 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -5,7 +5,8 @@ import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects"; import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter"; import { useState } from "react"; import { StackActions, useNavigation } from "@react-navigation/native"; -import { RefreshControl, ScrollView, View } from "react-native"; +import { ScrollView, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { FONT_FAMILIES } from "../../lib/fontFamilies"; diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index 279269b8a13c..24d82a93daa7 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -5,7 +5,8 @@ import type { ProjectEntry } from "@t3tools/contracts"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { SymbolView } from "../../components/ui/AppSymbol"; import { useEffect, useRef, useState } from "react"; -import { Pressable, RefreshControl, View } from "react-native"; +import { Pressable, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { AppText as Text } from "../../components/ui/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 36da917e83d5..6c796848b6c2 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -7,13 +7,13 @@ import { useLayoutEffect, useRef, useState } from "react"; import { Platform, Pressable, - RefreshControl, ScrollView, Text as NativeText, useWindowDimensions, View, } from "react-native"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { WebView } from "react-native-webview"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 11fa0a0c7af4..6de31137fb7d 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -226,7 +226,7 @@ function FileSourceSurface(props: ComponentProps) { setSelection({ line, active: true }) : undefined } @@ -250,6 +250,7 @@ function FileContent(props: { readonly threadId: ThreadId | null; readonly initialLine: number | null; readonly truncated: boolean; + readonly selectable: boolean; readonly onRefresh?: () => Promise | void; }) { // Reopening a mutable host file must not reuse a poster from an earlier visit. @@ -351,6 +352,7 @@ function FileContent(props: { path={props.relativePath} initialLine={props.initialLine} headerInsetTop={props.truncated ? 0 : undefined} + selectable={props.selectable} onRefresh={props.onRefresh} /> )} @@ -650,6 +652,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { readonly path: string; readonly mode: FileViewMode; } | null>(null); + const [textSelectionPath, setTextSelectionPath] = useState(null); const [previewRevision, setPreviewRevision] = useState(0); const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); const [fullScreenPreview, setFullScreenPreview] = useState(null); @@ -749,6 +752,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { : null, ); const fileData = fileQuery.data as ProjectReadFileResult | null; + // iOS only: Android lays out a whole-file selectable TextView on the UI thread, which stalls. + const canSelectText = + Platform.OS === "ios" && resolvedActiveMode === "source" && fileData?.contents != null; + const selectingText = canSelectText && textSelectionPath === relativePath; const handleSelectFile = (path: string) => { const targetPath = @@ -837,6 +844,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => setCodeWordBreak(!appearance.codeWordBreak), } as const) : null, + canSelectText + ? ({ + id: "select-text", + title: selectingText ? "Done selecting" : "Select text", + icon: "character.cursor.ibeam", + inline: false, + onPress: () => setTextSelectionPath(selectingText ? null : relativePath), + } as const) + : null, ...(mediaSource ? mediaActions.actions .filter(({ id }) => id !== "open-file") @@ -1046,6 +1062,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { relativePath={relativePath} threadId={threadId} truncated={fileData?.truncated ?? false} + selectable={selectingText} onRefresh={() => fileQuery.refresh()} /> diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index 46b26d7e3bf3..c58316af3698 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { fileRoutePathSegments, + isAudioPreviewFile, + isMarkdownPreviewFile, needsWorkspaceFileContents, resolveFileLinkPath, resolveWorkspaceRelativeFilePath, @@ -58,3 +60,14 @@ describe("needsWorkspaceFileContents", () => { expect(needsWorkspaceFileContents({ ...input, metadataKind: "directory" })).toBe(false); }); }); + +describe("workspace preview classifiers", () => { + it("treat # and ? as literal filename characters", () => { + for (const path of ["notes/clip#1.wav", "notes/clip?1.wav", "notes#old/clip.wav"]) { + expect(isAudioPreviewFile(path)).toBe(true); + } + for (const path of ["docs/draft#1.md", "docs/draft?1.md", "docs#old/draft.md"]) { + expect(isMarkdownPreviewFile(path)).toBe(true); + } + }); +}); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index e38ff86fa665..330ff5784da6 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -100,15 +100,15 @@ export function isVideoPreviewFile(path: string): boolean { } export function isAudioPreviewFile(path: string): boolean { - return isWorkspaceAudioPreviewPath(path.split(/[?#]/, 1)[0] ?? ""); + return isWorkspaceAudioPreviewPath(path); } export function isSvgImagePreviewFile(path: string): boolean { - return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? ""); + return /\.svg$/i.test(path); } export function isMarkdownPreviewFile(path: string): boolean { - return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); + return /\.(?:md|mdx)$/i.test(path); } export function needsWorkspaceFileContents(input: { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx new file mode 100644 index 000000000000..21f64998a3be --- /dev/null +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx @@ -0,0 +1,79 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const queries = vi.hoisted(() => [] as { cwd: string | null; searchQuery: string }[]); + +vi.mock("@t3tools/client-runtime/state/filesystem", () => ({ + resolveFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false }), +})); +vi.mock("../../state/session", () => ({ environmentSession: { sessionStateAtom: () => null } })); +vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null, error: null }) })); +vi.mock("../../state/presentation", () => ({ + useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), +})); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ themeAppearance: "dark" }), +})); +vi.mock("./preload-workspace-file", () => ({ preloadWorkspaceFileContents: () => {} })); +vi.mock("./FileTreeBrowser", () => ({ FileTreeBrowser: () => null })); +vi.mock("./useFileTreeEntries", () => ({ + useFileTreeEntries: (input: { cwd: string | null; searchQuery: string }) => { + queries.push({ cwd: input.cwd, searchQuery: input.searchQuery }); + return { refresh: () => {}, selectPath: () => {} }; + }, +})); +vi.mock("../../components/ui/SheetPane", () => ({ + SheetPane: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("../../components/ui/AppBar", () => ({ + AppBar: (props: { search: { value: string; onChangeText: (value: string) => void } }) => ( + props.search.onChangeText(event.target.value)} + /> + ), +})); + +import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; + +it("starts unfiltered when the mounted pane switches to another workspace", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + const render = (cwd: string) => + act(() => + root.render( + {}} + />, + ), + ); + try { + render("/first"); + const input = container.querySelector("input")!; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + input, + "alpha", + ); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(queries.at(-1)).toEqual({ cwd: "/first", searchQuery: "alpha" }); + + render("/second"); + expect(queries.at(-1)).toEqual({ cwd: "/second", searchQuery: "" }); + expect(container.querySelector("input")!.value).toBe(""); + } finally { + act(() => root.unmount()); + container.remove(); + } +}); diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index dd95959b3089..cc0c86e6d3e4 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -4,7 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { AppBar } from "../../components/ui/AppBar"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { useState } from "react"; -import { View } from "react-native"; +import { SheetPane } from "../../components/ui/SheetPane"; import { useEnvironmentQuery } from "../../state/query"; import { useEnvironmentPresentation } from "../../state/presentation"; @@ -20,7 +20,11 @@ export function ThreadFileNavigatorPane(props: { readonly selectedPath: string | null; readonly onSelectFile: (path: string) => void; }) { - const [searchQuery, setSearchQuery] = useState(""); + const workspaceKey = JSON.stringify([props.environmentId, props.cwd]); + // The pane stays mounted across thread switches; a query only filters the workspace it was typed in. + const [search, setSearch] = useState({ workspaceKey, query: "" }); + const searchQuery = search.workspaceKey === workspaceKey ? search.query : ""; + const setSearchQuery = (query: string) => setSearch({ workspaceKey, query }); const { themeAppearance: highlightTheme } = useAppearancePreferences(); const fileAccessSession = useEnvironmentQuery( environmentSession.sessionStateAtom(props.environmentId), @@ -48,7 +52,7 @@ export function ThreadFileNavigatorPane(props: { }; const fileTree = ( - {fileTree} + - + {fileTree} + ); } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 78b4eca22a47..df5142c15e24 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -26,7 +26,11 @@ import { import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { useHomeListOptions } from "./home-list-options"; -import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; +import { + runWhenHomeCovered, + useAtomValueWhileVisible, + useHomeRouteVisible, +} from "./home-route-visibility"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; @@ -64,13 +68,16 @@ export function HomeRouteScreen() { else if (workspace) workspace.dispatch({ type: "query", query, tab: "threads" }); else setLocalSearchQuery(query); }; - const handleSelectThread = (thread: EnvironmentThreadShell) => - selectDetail({ - name: "Thread", - params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, - }); - const selectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => + // Split layouts keep the query; only the stacked layout opens the thread over the list. + // Android's search field keeps its own open state, which a cleared query would leave open. + const clearsSearchOnOpen = Platform.OS !== "android" && !layout.usesSplitView; + const selectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => { + if (searchQuery !== "" && clearsSearchOnOpen && workspace) + runWhenHomeCovered(() => workspace.cancelSearch("threads")); selectDetail({ name: "Thread", params }); + }; + const handleSelectThread = (thread: EnvironmentThreadShell) => + selectThreadRoute({ environmentId: String(thread.environmentId), threadId: String(thread.id) }); const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { selectThreadRoute({ draft: { @@ -232,6 +239,7 @@ export function HomeRouteScreen() { selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} threads={threads} + visible={visible} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d39bb1381baf..af25be18bb46 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -16,7 +16,15 @@ import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-gro import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { use, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; +import { + use, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; import { Platform, ScrollView, @@ -88,10 +96,14 @@ import { import { createSwipeRowActivation } from "./swipe-row-activation"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; import { useFabScroll } from "./FabScrollContext"; +import { holdLaunchSplashForHomeRows, settleLaunchSplashHomeRows } from "./home-launch-splash"; import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; import { ThreadListFilters } from "./ThreadListFilters"; +import { deriveHomeEmptyState } from "./home-empty-state"; interface HomeScreenProps { + /** Whether this list is the one on screen; see `useHomeRouteVisible`. */ + readonly visible: boolean; readonly projects: ReadonlyArray; readonly threads: ReadonlyArray; readonly pendingTasks: ReadonlyArray; @@ -145,73 +157,6 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; // rows that are already built instead of rows still being rebuilt. const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; -function deriveEmptyState(props: { - readonly catalogState: WorkspaceState; - readonly projectCount: number; -}): { readonly title: string; readonly detail: string; readonly loading: boolean } { - const { catalogState } = props; - if (catalogState.isLoadingConnections) { - return { - title: "Loading environments", - detail: "Checking saved environments on this device.", - loading: true, - }; - } - - if (!catalogState.hasConnections) { - return { - title: "No environments connected", - detail: "Add an environment to load projects and start coding sessions.", - loading: false, - }; - } - - if ( - (catalogState.connectionState === "available" || - catalogState.connectionState === "offline" || - catalogState.connectionState === "error" || - catalogState.connectionState === "unsupported") && - !catalogState.hasLoadedShellSnapshot - ) { - return { - title: - catalogState.connectionState === "unsupported" - ? "Client not supported" - : "Environment unavailable", - detail: - catalogState.connectionError ?? - "The saved environment is offline. Check the URL or start the environment, then retry.", - loading: false, - }; - } - - if ( - catalogState.hasConnectingEnvironment && - !catalogState.hasLoadedShellSnapshot && - catalogState.connectionError === null - ) { - return { - title: "Connecting to environment", - detail: "Loading projects and threads from the saved environment.", - loading: true, - }; - } - - if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) { - return { - title: "No projects found", - detail: "The connected environment did not report any projects.", - loading: false, - }; - } - - return { - title: "No threads yet", - detail: "Create a task to start a new coding runtime in one of your connected projects.", - loading: false, - }; -} - function HomeTopContentSpacer() { return ; } @@ -663,15 +608,18 @@ export function HomeScreen(props: HomeScreenProps) { props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0 || threadListV2Layout.settledCount > 0; + // A hidden list gets no layout, so it never reports its rows as laid out. + const hasRowsToLayOut = props.visible && hasAnyThreads && threadListV2Items.length > 0; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the native launch splash, which is outside React, until the rows are gone or this list unmounts; a layout effect runs before the splash hides in this commit + useLayoutEffect(() => { + return hasRowsToLayOut ? holdLaunchSplashForHomeRows() : undefined; + }, [hasRowsToLayOut]); const selectedEnvironmentLabel = props.selectedEnvironmentId === null ? null : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? "this environment"); - const emptyState = deriveEmptyState({ - catalogState: props.catalogState, - projectCount: props.projects.length, - }); + const emptyState = deriveHomeEmptyState(props.catalogState, props.projects.length); if (!hasAnyThreads) { return ( @@ -694,14 +642,10 @@ export function HomeScreen(props: HomeScreenProps) { activateVisibleRows(threadListV2Items)} + onLoad={() => { + settleLaunchSplashHomeRows(); + activateVisibleRows(threadListV2Items); + }} onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)} onTouchCancel={(event) => trackListTouches(event, false)} diff --git a/apps/mobile/src/features/home/home-empty-state.test.ts b/apps/mobile/src/features/home/home-empty-state.test.ts new file mode 100644 index 000000000000..c7703a865533 --- /dev/null +++ b/apps/mobile/src/features/home/home-empty-state.test.ts @@ -0,0 +1,104 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; +import { deriveHomeEmptyState } from "./home-empty-state"; + +function connectingEnvironment( + connectionState: "connecting" | "reconnecting", + connectionError: string | null, +): WorkspaceEnvironment { + return { + environmentId: EnvironmentId.make("environment-1"), + environmentLabel: "corex", + displayUrl: "", + isRelayManaged: false, + isEnabled: true, + connectionState, + connectionError, + connectionErrorTraceId: null, + }; +} + +function workspaceState(overrides: Partial = {}): WorkspaceState { + return { + isLoadingConnections: false, + hasConnections: true, + hasLoadedShellSnapshot: true, + hasPendingShellSnapshot: false, + hasSyncingConnectedEnvironment: false, + hasReadyEnvironment: true, + hasConnectingEnvironment: false, + connectingEnvironments: [], + connectionState: "connected", + connectionError: null, + shellSnapshotError: null, + networkStatus: "online", + ...overrides, + }; +} + +const RETAINED_FAILURE = "Environment did not respond before the connection timeout."; +const reconnecting = connectingEnvironment("reconnecting", RETAINED_FAILURE); + +describe("home empty state", () => { + it.each([ + [ + "waits while a saved environment retries after a retained failure", + workspaceState({ + hasLoadedShellSnapshot: false, + hasPendingShellSnapshot: true, + hasReadyEnvironment: false, + hasConnectingEnvironment: true, + connectingEnvironments: [reconnecting], + connectionState: "reconnecting", + connectionError: RETAINED_FAILURE, + }), + 1, + { title: "Connecting to environment", loading: true, showsAddEnvironment: false }, + ], + [ + "waits for a connected environment's first shell snapshot", + workspaceState({ + hasLoadedShellSnapshot: false, + hasPendingShellSnapshot: true, + hasSyncingConnectedEnvironment: true, + }), + 0, + { title: "Loading threads", loading: true, showsAddEnvironment: false }, + ], + [ + "waits while another environment is still connecting after one loaded empty", + workspaceState({ + hasPendingShellSnapshot: true, + hasConnectingEnvironment: true, + connectingEnvironments: [reconnecting], + connectionError: RETAINED_FAILURE, + }), + 0, + { title: "Loading threads", loading: true, showsAddEnvironment: false }, + ], + [ + "reports no threads once every environment has resolved", + workspaceState(), + 1, + { title: "No threads yet", loading: false, showsAddEnvironment: false }, + ], + [ + "does not wait on retries while the device is offline", + workspaceState({ + hasPendingShellSnapshot: true, + hasReadyEnvironment: false, + hasConnectingEnvironment: true, + connectingEnvironments: [reconnecting], + connectionState: "offline", + networkStatus: "offline", + }), + 1, + { title: "No threads yet", loading: false, showsAddEnvironment: true }, + ], + ] as const)("%s", (_name, state, projectCount, expected) => { + const { title, loading, showsAddEnvironment } = deriveHomeEmptyState(state, projectCount); + expect({ title, loading, showsAddEnvironment }).toEqual(expected); + }); +}); diff --git a/apps/mobile/src/features/home/home-empty-state.ts b/apps/mobile/src/features/home/home-empty-state.ts new file mode 100644 index 000000000000..66f231a54684 --- /dev/null +++ b/apps/mobile/src/features/home/home-empty-state.ts @@ -0,0 +1,81 @@ +import { isWorkspaceCatalogPending, type WorkspaceState } from "../../state/workspaceModel"; + +export interface HomeEmptyState { + readonly title: string; + readonly detail: string; + readonly loading: boolean; + readonly showsAddEnvironment: boolean; +} + +export function deriveHomeEmptyState( + catalogState: WorkspaceState, + projectCount: number, +): HomeEmptyState { + const emptyState = deriveEmptyState(catalogState, projectCount); + return { + ...emptyState, + showsAddEnvironment: !emptyState.loading && !catalogState.hasReadyEnvironment, + }; +} + +function deriveEmptyState( + catalogState: WorkspaceState, + projectCount: number, +): Omit { + if (catalogState.isLoadingConnections) { + return { + title: "Loading environments", + detail: "Checking saved environments on this device.", + loading: true, + }; + } + + if (!catalogState.hasConnections) { + return { + title: "No environments connected", + detail: "Add an environment to load projects and start coding sessions.", + loading: false, + }; + } + + if ( + (catalogState.connectionState === "available" || + catalogState.connectionState === "offline" || + catalogState.connectionState === "error" || + catalogState.connectionState === "unsupported") && + !catalogState.hasLoadedShellSnapshot + ) { + return { + title: + catalogState.connectionState === "unsupported" + ? "Client not supported" + : "Environment unavailable", + detail: + catalogState.connectionError ?? + "The saved environment is offline. Check the URL or start the environment, then retry.", + loading: false, + }; + } + + if (isWorkspaceCatalogPending(catalogState)) { + return { + title: catalogState.hasReadyEnvironment ? "Loading threads" : "Connecting to environment", + detail: "Loading projects and threads from the saved environment.", + loading: true, + }; + } + + if (projectCount === 0 && catalogState.hasLoadedShellSnapshot) { + return { + title: "No projects found", + detail: "The connected environment did not report any projects.", + loading: false, + }; + } + + return { + title: "No threads yet", + detail: "Create a task to start a new coding runtime in one of your connected projects.", + loading: false, + }; +} diff --git a/apps/mobile/src/features/home/home-launch-splash.test.ts b/apps/mobile/src/features/home/home-launch-splash.test.ts new file mode 100644 index 000000000000..d571811b33d7 --- /dev/null +++ b/apps/mobile/src/features/home/home-launch-splash.test.ts @@ -0,0 +1,70 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { createLaunchSplashHomeRows, splashCanHide } from "./home-launch-splash"; + +const launched = { + preferencesReady: true, + shellCachesRead: true, + homeRowsPending: false, + waitEnded: false, +}; + +it.each([ + { + name: "hides when the cached shells are read and no rows wait for layout", + input: launched, + hides: true, + }, + { + name: "waits while Home still lays out the cached rows", + input: { ...launched, homeRowsPending: true }, + hides: false, + }, + { + name: "waits for the cached shells when Home has no rows yet", + input: { ...launched, shellCachesRead: false }, + hides: false, + }, + { + name: "stops waiting for rows at the limit", + input: { ...launched, homeRowsPending: true, waitEnded: true }, + hides: true, + }, + { + name: "stops waiting for a cache read that fails at the limit", + input: { ...launched, shellCachesRead: false, homeRowsPending: true, waitEnded: true }, + hides: true, + }, + { + name: "waits for preferences even after the limit", + input: { ...launched, preferencesReady: false, waitEnded: true }, + hides: false, + }, +])("the launch splash $name", ({ input, hides }) => { + expect(splashCanHide(input)).toBe(hides); +}); + +it("the launch splash is held only while a Home list has rows to lay out", () => { + const rows = createLaunchSplashHomeRows(); + const onChange = vi.fn(); + rows.subscribe(onChange); + + const release = rows.hold(); + expect(rows.pending()).toBe(true); + // The server answered with no rows, or the list unmounted, before its first layout. + release(); + expect(rows.pending(), "a list that lost its rows releases the splash").toBe(false); + expect(onChange).toHaveBeenCalledTimes(2); + + const releaseLater = rows.hold(); + const releaseOther = rows.hold(); + releaseLater(); + release(); + expect(rows.pending(), "another list still has rows to lay out").toBe(true); + + rows.settle(); + expect(rows.pending()).toBe(false); + releaseOther(); + rows.hold(); + expect(rows.pending(), "a list that mounts after the launch holds nothing").toBe(false); +}); diff --git a/apps/mobile/src/features/home/home-launch-splash.ts b/apps/mobile/src/features/home/home-launch-splash.ts new file mode 100644 index 000000000000..16069f051d45 --- /dev/null +++ b/apps/mobile/src/features/home/home-launch-splash.ts @@ -0,0 +1,75 @@ +import { useSyncExternalStore } from "react"; + +/** + * Whether the launch splash still waits for Home's list. A list holds the splash while it + * has cached rows to lay out, so the first frame after the splash shows them. + */ +export function createLaunchSplashHomeRows() { + let holds = 0; + let settled = false; + const listeners = new Set<() => void>(); + const pending = () => !settled && holds > 0; + const change = (apply: () => void) => { + const before = pending(); + apply(); + if (pending() !== before) for (const listener of listeners) listener(); + }; + return { + pending, + /** + * A Home list has rows that are not laid out yet. Call the returned release when it no + * longer has them or unmounts; rows that come later can hold again. + */ + hold: () => { + let held = true; + change(() => { + holds += 1; + }); + return () => { + if (!held) return; + held = false; + change(() => { + holds -= 1; + }); + }; + }, + /** + * The rows are laid out, the launch opened another route, or the splash is gone. It is + * final: lists that mount after the launch must not hold anything. + */ + settle: () => + change(() => { + settled = true; + }), + subscribe: (onChange: () => void) => { + listeners.add(onChange); + return () => { + listeners.delete(onChange); + }; + }, + }; +} + +const homeRows = createLaunchSplashHomeRows(); + +export const holdLaunchSplashForHomeRows = homeRows.hold; +export const settleLaunchSplashHomeRows = homeRows.settle; +export const launchSplashHomeRowsPending = homeRows.pending; + +export function useLaunchSplashHomeRowsPending(): boolean { + return useSyncExternalStore(homeRows.subscribe, homeRows.pending); +} + +/** + * Whether the native splash can hide: after the cached shells are read and Home's list has + * laid out their rows. One limit ends both waits. + */ +export function splashCanHide(input: { + readonly preferencesReady: boolean; + readonly shellCachesRead: boolean; + readonly homeRowsPending: boolean; + readonly waitEnded: boolean; +}): boolean { + if (!input.preferencesReady) return false; + return input.waitEnded || (input.shellCachesRead && !input.homeRowsPending); +} diff --git a/apps/mobile/src/features/home/home-route-visibility.test.ts b/apps/mobile/src/features/home/home-route-visibility.test.ts new file mode 100644 index 000000000000..2ab23be17a71 --- /dev/null +++ b/apps/mobile/src/features/home/home-route-visibility.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + recordHomeTransitionEnd, + recordHomeTransitionStart, + runWhenHomeCovered, +} from "./home-route-visibility"; + +vi.mock("@react-navigation/native", () => ({ useNavigation: vi.fn() })); + +describe("runWhenHomeCovered", () => { + it("waits for the page opening over Home to finish covering it, then runs once", () => { + const clearSearch = vi.fn(); + runWhenHomeCovered(clearSearch); + recordHomeTransitionStart(true); + expect(clearSearch).not.toHaveBeenCalled(); + recordHomeTransitionEnd(true, true); + expect(clearSearch).not.toHaveBeenCalled(); + recordHomeTransitionEnd(true, false); + expect(clearSearch).toHaveBeenCalledTimes(1); + recordHomeTransitionStart(false); + recordHomeTransitionEnd(true, false); + expect(clearSearch).toHaveBeenCalledTimes(1); + }); + + it("drops the action when Home reappears before it is covered", () => { + const clearSearch = vi.fn(); + runWhenHomeCovered(clearSearch); + recordHomeTransitionStart(true); + recordHomeTransitionStart(false); + recordHomeTransitionEnd(false, false); + recordHomeTransitionEnd(true, false); + expect(clearSearch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts index 7a8837b582a1..51bd29234759 100644 --- a/apps/mobile/src/features/home/home-route-visibility.ts +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -33,8 +33,17 @@ function subscribeHomeVisibility(onChange: () => void) { }; } +let onHomeCovered: (() => void) | null = null; + +/** Runs `action` once the page opening over Home covers it; Home starting to reappear first drops it. */ +export function runWhenHomeCovered(action: () => void): void { + onHomeCovered = action; +} + export function recordHomeTransitionStart(closing: boolean): void { - if (!closing) setHomeVisible(true); + if (closing) return; + onHomeCovered = null; + setHomeVisible(true); } /** @@ -42,7 +51,11 @@ export function recordHomeTransitionStart(closing: boolean): void { * disappears last. So a disappear hides Home only when another route has the focus. */ export function recordHomeTransitionEnd(closing: boolean, homeFocused: boolean): void { - if (closing && !homeFocused) setHomeVisible(false); + if (!closing || homeFocused) return; + setHomeVisible(false); + const action = onHomeCovered; + onHomeCovered = null; + action?.(); } /** Popping a sheet or a deep-linked route above Home has no appear event. */ diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index b0f0ce78d0a8..0bea39e419e2 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -7,6 +7,7 @@ import { ProviderInstanceId, RunId, ThreadId, + type ThreadTitleRegenerationOutcome, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; @@ -18,6 +19,7 @@ const state = vi.hoisted(() => ({ dropBusy: false, holdingRows: false, failedAction: null as string | null, + titleOutcome: null as ThreadTitleRegenerationOutcome | null, archivedRefreshes: [] as string[], scopes: new Map>(), shells: [] as EnvironmentThreadShell[], @@ -141,6 +143,7 @@ vi.mock("../../state/threads", () => ({ "reorderPin", "reorderActive", "updateMetadata", + "regenerateTitle", ].map((action) => [ action, async (request: { @@ -155,7 +158,7 @@ vi.mock("../../state/threads", () => ({ if (action === state.failedAction) return AsyncResult.failure(Cause.fail(new Error("Server rejected action"))); state.afterRequest?.(); - return AsyncResult.success(undefined); + return AsyncResult.success(action === "regenerateTitle" ? state.titleOutcome : undefined); }, ]), ), @@ -186,12 +189,13 @@ const mutationCases = [ ["unsnoozeThread", "unsnooze"], ["pinThread", "pin"], ["unpinThread", "unpin"], - ["regenerateThreadTitle", "updateMetadata"], + ["regenerateThreadTitle", "regenerateTitle"], ] as const; beforeEach(() => { state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; state.failedAction = null; + state.titleOutcome = null; state.archivedRefreshes = []; state.pendingOrder = null; state.dropBusy = false; @@ -214,6 +218,16 @@ afterEach(() => { vi.unstubAllEnvs(); }); +it("reports a failed title generation as unsuccessful and allows another request", async () => { + const actions = useThreadListActions(); + const thread = makeThread(); + state.titleOutcome = { status: "failed", message: "Model unavailable" }; + expect(await actions.regenerateThreadTitle(thread)).toBe(false); + + state.titleOutcome = { status: "renamed" }; + expect(await actions.regenerateThreadTitle(thread)).toBe(true); +}); + describe("thread list operation permissions", () => { it.each(mutationCases)( "%s rejects a retained callback after its environment loses permission", diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index aaba12dd8f41..3cd180db6273 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -240,6 +240,9 @@ export function useThreadListActions(): { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const regenerateTitle = useAtomCommand(threadEnvironment.regenerateTitle, { + reportFailure: false, + }); const snoozeInFlightThreadKeys = useRef(new Set()); const titleRegenerationInFlightThreadKeys = useRef(new Set()); @@ -529,9 +532,9 @@ export function useThreadListActions(): { titleRegenerationInFlightThreadKeys.current.add(key); selectionHaptic(); const succeeded = await (async () => { - const result = await updateThreadMetadata({ + const result = await regenerateTitle({ environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, + input: { threadId: thread.id }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -543,6 +546,13 @@ export function useThreadListActions(): { ); return false; } + if (result.value?.status === "failed") { + Dialog.alert( + "Could not regenerate title", + result.value.message ?? "The thread title could not be regenerated.", + ); + return false; + } return true; })(); titleRegenerationInFlightThreadKeys.current.delete(key); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 19ffe072fb58..3a32e3352361 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -112,7 +112,7 @@ import { workspaceTabColumns, workspaceTabNavigationState, } from "./workspace/workspace-tab-navigation"; -import type { WorkspaceTab } from "./workspace/workspace-tabs-state"; +import { workspaceTabOf, type WorkspaceTab } from "./workspace/workspace-tabs-state"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; import { WorkspaceThreadSidebar } from "./workspace/WorkspaceThreadSidebar"; @@ -570,10 +570,7 @@ function AdaptiveWorkspaceLayoutContent( setFileInspectorPreferredVisible(false); setInspectorSheetRoute(null); if (layout.variant === "medium") setPrimarySidebarVisible(false); - workspace?.dispatch({ - type: "select", - tab: detail.name === "Thread" ? "threads" : "pullRequests", - }); + workspace?.dispatch({ type: "select", tab: workspaceTabOf(detail.name) }); startOpeningDetail(detail); navigation.dispatch(selectWorkspaceDetail(detail, sheet)); }); diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx index 2ee2000d400f..d4930f4cdecb 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx @@ -19,6 +19,7 @@ import { } from "@react-navigation/native-stack"; import { use, type ComponentProps } from "react"; +import { BottomSheetContext } from "../../components/ui/BottomSheetContext"; import { RoutedFormSheetContext } from "../../components/ui/RoutedFormSheetContext"; import { @@ -131,7 +132,11 @@ function WorkspaceStackNavigator({ ...(descriptor.options.presentation === "formSheet" ? { render: () => ( - {descriptor.render()} + + + {descriptor.render()} + + ), } : undefined), diff --git a/apps/mobile/src/features/layout/workspace-inspector-sheet.tsx b/apps/mobile/src/features/layout/workspace-inspector-sheet.tsx index 9fec5e64df69..f2f9c0242563 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-sheet.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-sheet.tsx @@ -1,10 +1,8 @@ import { createContext, useRef, useState } from "react"; -import { Platform, View } from "react-native"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; -import { FormSheet } from "react-native-screens"; -import { useResolveClassNames } from "uniwind"; import { PaneContext } from "../../components/ui/Pane"; +import { PaneSheet } from "../../components/ui/PaneSheet"; import { RoutedFormSheetContext } from "../../components/ui/RoutedFormSheetContext"; import { formSheetDetents } from "../../native/sheet-surface"; import { InspectorPanes } from "./inspector-host"; @@ -22,7 +20,6 @@ export function WorkspaceInspectorSheet(props: { readonly onClose: () => void; readonly content: InspectorContent | undefined; }) { - const containerStyle = useResolveClassNames(Platform.OS === "android" ? "bg-header" : "bg-sheet"); const insets = useSafeAreaInsets(); // Stays mounted until the sheet has finished closing, so it doesn't empty as it slides away. // Each opening gets a fresh native sheet, even one that starts while the last is closing. @@ -51,15 +48,12 @@ export function WorkspaceInspectorSheet(props: { }; if (!presented.mounted) return null; return ( - { props.onClose(); @@ -71,25 +65,11 @@ export function WorkspaceInspectorSheet(props: { {/* The sheet starts below the status bar. */} - {/* Android FormSheet does not bound flex children. */} - - close()} /> - {Platform.OS === "android" ? ( - - ) : null} - + close()} /> - + ); } diff --git a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx index 5fc9b50097ba..3612561ede6c 100644 --- a/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx +++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx @@ -3,6 +3,7 @@ import { createContext, use, type ReactNode } from "react"; import { Platform } from "react-native"; import { androidWorkspaceSettingsOpen } from "./android-workspace-tabs"; import { WorkspaceTabsProvider } from "./WorkspaceTabsContext"; +import { workspaceTabOf } from "./workspace-tabs-state"; interface AndroidWorkspaceRoot { /** @@ -35,7 +36,10 @@ export function AndroidWorkspaceTabsProvider(props: { settingsIndex < 0 ? null : (props.state.routes[settingsIndex + 1]?.name ?? null); const root = { key, settingsOpen, settingsRoute, settingsPage }; return Platform.OS === "android" ? ( - + // A cold-start link lands on a page with no list selection; start on that page's list. + route.name !== "Home")?.name)} + > {props.children} ) : ( diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx index a03a36bc9476..75d139621562 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx @@ -24,6 +24,8 @@ import { WorkspaceAppBarScope } from "../../../components/ui/WorkspaceAppBar.and import { usePaneRole } from "../../../components/ui/Pane"; import { AndroidWorkspaceSidebar } from "./AndroidWorkspaceSidebar.android"; import type { WorkspaceTab } from "./workspace-tabs-state"; +import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; +import { useHardwareBack } from "../../../lib/backHandler"; const TABS: readonly WorkspaceTab[] = NAVIGATION_TAB_ORDER; @@ -49,6 +51,18 @@ function WorkspaceRoot() { : 16, }; const tab = workspace.state.selectedTab; + const selectTab = useSelectWorkspaceTab(); + // Tabs are panes of Home, not routes, so Back has nothing to pop and would leave the app. + // An open search keeps Back for itself. + useHardwareBack( + tab !== "threads" && + enabled.includes("threads") && + workspace.state.tabs[tab].searchMode === "resting", + () => { + selectTab("threads"); + return true; + }, + ); return ( diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx index 9ecb6fa933fa..81344be55e4d 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx @@ -26,6 +26,8 @@ export const WorkspaceTabsContext = createContext<{ readonly settingsOpen: boolean; readonly getScrollOffset: (tab: WorkspaceSearchTab) => number; readonly recordScrollOffset: (tab: WorkspaceSearchTab, offset: number) => void; + /** Leaves search and returns the list to where it was when search began. */ + readonly cancelSearch: (tab: WorkspaceSearchTab) => void; } | null>(null); export function useWorkspaceTabs() { @@ -52,8 +54,8 @@ function resolveWorkspaceTabsSelection( // One model per provider. Handlers on frozen screens keep stale closures, so // they read the current selection from the registry at call time. -function createWorkspaceTabsModel() { - const stored = Atom.make(initialWorkspaceTabsState()); +function createWorkspaceTabsModel(selectedTab?: WorkspaceTab) { + const stored = Atom.make(initialWorkspaceTabsState(selectedTab)); const scrollOffsets: Record = { threads: 0, pullRequests: 0, @@ -75,19 +77,25 @@ function createWorkspaceTabsModel() { export function WorkspaceTabsProvider(props: { readonly children: ReactNode; readonly settingsOpen?: boolean; + readonly initialTab?: WorkspaceTab; }) { const registry = use(RegistryContext); - const [model] = useState(createWorkspaceTabsModel); + const [model] = useState(() => createWorkspaceTabsModel(props.initialTab)); const state = useAtomValue(model.resolved); + const dispatch = (action: WorkspaceTabsAction) => + registry.set(model.stored, workspaceTabsReducer(registry.get(model.resolved), action)); const context = { state, - dispatch: (action: WorkspaceTabsAction) => - registry.set(model.stored, workspaceTabsReducer(registry.get(model.resolved), action)), + dispatch, isTabActive: (tab: WorkspaceTab) => registry.get(model.resolved).selectedTab === tab, bottomClearance: 0, settingsOpen: props.settingsOpen ?? false, getScrollOffset: model.getScrollOffset, recordScrollOffset: model.recordScrollOffset, + cancelSearch: (tab: WorkspaceSearchTab) => { + model.recordScrollOffset(tab, registry.get(model.resolved).tabs[tab].searchEntryOffset); + dispatch({ type: "cancel", tab }); + }, }; return {props.children}; } diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx index 5557432ce494..f07076a8fb14 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx @@ -80,10 +80,7 @@ export function WorkspaceToolbar(props: { searchRef.current?.focus(); return true; }; - const cancelSearch = () => { - workspace.recordScrollOffset(selectedTab, tab.searchEntryOffset); - workspace.dispatch({ type: "cancel", tab: selectedTab }); - }; + const cancelSearch = () => workspace.cancelSearch(selectedTab); const searchFocused = () => tab.searchMode === "resting" ? enterSearch() @@ -96,6 +93,8 @@ export function WorkspaceToolbar(props: { // The sidebar remains visible while its detail owns navigation focus. const searchHostVisible = focused || settingsListOnTop || primaryColumn?.presented === true; const restoreSearch = () => { + // A search cancelled while its screen was covered leaves its text in the native field. + if (tab.searchMode === "resting") searchRef.current?.setText(""); if ( searchHostVisible && searchVisible && diff --git a/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts b/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts index dfc8e13d162c..0165634f0a31 100644 --- a/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts +++ b/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts @@ -1,50 +1,29 @@ import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import { useCallback } from "react"; import { BackHandler, Keyboard, Platform } from "react-native"; -import { - resetAndroidWorkspace, - returnToAndroidWorkspaceList, -} from "../../../lib/android-workspace-navigation"; -import { useWorkspaceTabs } from "./WorkspaceTabsContext"; -import type { WorkspaceTab } from "./workspace-tabs-state"; +import { returnToAndroidWorkspaceList } from "../../../lib/android-workspace-navigation"; -function useWorkspaceListNavigation(tab: WorkspaceTab) { +export function useWorkspaceBackNavigation() { const navigation = useNavigation(); - const dispatch = useWorkspaceTabs()?.dispatch; return () => { - if (Platform.OS === "android") { - dispatch?.({ type: "select", tab }); - resetAndroidWorkspace(navigation); - } else if (navigation.canGoBack()) navigation.goBack(); + if (Platform.OS === "android") returnToAndroidWorkspaceList(navigation); + else if (navigation.canGoBack()) navigation.goBack(); else navigation.dispatch(StackActions.replace("Home")); }; } -export function useWorkspaceBackNavigation(tab: WorkspaceTab) { - const navigation = useNavigation(); - const dispatch = useWorkspaceTabs()?.dispatch; - const returnToList = useWorkspaceListNavigation(tab); - return () => { - if (Platform.OS === "android") { - returnToAndroidWorkspaceList(navigation, () => dispatch?.({ type: "select", tab })); - } else returnToList(); - }; -} - -export function useAndroidWorkspaceBack(tab: WorkspaceTab) { - const dispatch = useWorkspaceTabs()?.dispatch; - const goBack = useWorkspaceBackNavigation(tab); +export function useAndroidWorkspaceBack() { + const goBack = useWorkspaceBackNavigation(); useFocusEffect( useCallback(() => { if (Platform.OS !== "android") return; - dispatch?.({ type: "select", tab }); const subscription = BackHandler.addEventListener("hardwareBackPress", () => { if (Keyboard.isVisible()) Keyboard.dismiss(); else goBack(); return true; }); return () => subscription.remove(); - }, [goBack, dispatch, tab]), + }, [goBack]), ); return goBack; } diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts index f1c839630eb3..797da115a20e 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts @@ -11,6 +11,8 @@ import { workspaceSearchBottomClearance, workspaceDockBottomClearance, workspaceUsesColumns, + type WorkspaceTab, + type WorkspaceTabsState, } from "./workspace-tabs-state"; describe("compact list clearance", () => { @@ -179,12 +181,25 @@ describe("workspace root navigation", () => { }); }); -const mocks = vi.hoisted(() => ({ split: false, floating: false })); +const mocks = vi.hoisted(() => ({ + split: false, + floating: false, + state: undefined as WorkspaceTabsState | undefined, + openWorkspaceTab: (_tab: string) => {}, + backListeners: new Set<() => boolean>(), +})); vi.mock("../../settings/appearance/AppearancePreferencesProvider", () => ({ useAndroidFloatingTabs: () => mocks.floating, })); vi.mock("react-native", () => ({ + BackHandler: { + addEventListener: (_event: string, listener: () => boolean) => { + mocks.backListeners.add(listener); + return { remove: () => mocks.backListeners.delete(listener) }; + }, + }, + Keyboard: { dismiss: () => undefined }, useWindowDimensions: () => ({ width: 400, height: 800 }), View: ({ children }: { children: ReactNode }) => createElement("div", null, children), })); @@ -198,12 +213,16 @@ vi.mock("../AdaptiveWorkspaceLayout", () => ({ startNewTask: () => undefined, }), })); +vi.mock("@react-navigation/native", () => ({ useIsFocused: () => true })); +vi.mock("../adaptive-workspace-context", () => ({ + useAdaptiveWorkspaceLayout: () => ({ openWorkspaceTab: mocks.openWorkspaceTab }), +})); vi.mock("./navigation-tabs", () => ({ useEnabledNavigationTabs: () => ["threads", "pullRequests", "usage"], })); vi.mock("./WorkspaceTabsContext", () => ({ WorkspaceTabsContext: createContext(null), - useWorkspaceTabs: () => ({ state: { selectedTab: "usage" } }), + useWorkspaceTabs: () => ({ state: mocks.state, dispatch: () => undefined }), })); vi.mock("./WorkspaceExtraTab", () => ({ WorkspaceExtraTab: function UsageProbe() { @@ -238,13 +257,37 @@ vi.mock("../../../components/ui/WorkspaceAppBar.android", () => ({ WorkspaceAppBarScope: ({ children }: { children: ReactNode }) => children, })); -function setup(split: boolean, floating: boolean) { +function setup(split: boolean, floating: boolean, state = selected("usage")) { mocks.split = split; mocks.floating = floating; + mocks.state = state; + mocks.openWorkspaceTab = () => undefined; + mocks.backListeners.clear(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); return { [Symbol.dispose]: () => vi.unstubAllGlobals() }; } +function selected(tab: WorkspaceTab) { + return workspaceTabsReducer(initialWorkspaceTabsState(), { type: "select", tab }); +} + +/** Android calls the newest listener first and stops at the first that handles Back. */ +function pressBack() { + return [...mocks.backListeners].reverse().some((listener) => listener()); +} + +it("Back from the Pulls tab returns to Threads instead of leaving the app", async () => { + using _actEnvironment = setup(false, false, selected("pullRequests")); + const opened: string[] = []; + mocks.openWorkspaceTab = (tab) => opened.push(tab); + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + await act(async () => root.render(createElement(WorkspaceRootScreen))); + + expect(pressBack()).toBe(true); + expect(opened).toEqual(["threads"]); +}); + it.each([ [false, false], [true, false], diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts index 3598894115dc..3f6754c679c5 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts @@ -38,9 +38,16 @@ function emptyTab(): WorkspaceTabState { return { query: "", searchMode: "resting", scrollOffset: 0, searchEntryOffset: 0 }; } -export function initialWorkspaceTabsState(): WorkspaceTabsState { +/** The list a workspace page belongs to. */ +export function workspaceTabOf(routeName: string | undefined): WorkspaceTab { + return routeName === "PullRequest" ? "pullRequests" : "threads"; +} + +export function initialWorkspaceTabsState( + selectedTab: WorkspaceTab = "threads", +): WorkspaceTabsState { return { - selectedTab: "threads", + selectedTab, selectionRevision: 0, tabs: { threads: emptyTab(), diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx index ab121d0c41e7..f495e39cbd26 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx @@ -6,7 +6,7 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime import type { PullRequestReviewVerdict } from "@t3tools/contracts"; import { useRef, useState } from "react"; import { useFocusEffect } from "@react-navigation/native"; -import { Platform, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import { clearSubmittedReview } from "./pullRequestReviewDrafts"; import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; import { reviewPositionLabel } from "./pullRequestDiffComments"; @@ -158,7 +158,7 @@ export function PullRequestReviewSheet({ : "Post comment" } fullWidth - tone={Platform.OS === "android" ? "primary" : "contrast"} + tone="contrast" loading={submission.busy} disabled={!canSubmit || result.isPending || result.error !== null} onPress={() => void perform()} diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index f68236379203..f3c47e258285 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -61,7 +61,8 @@ import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading"; import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink"; import { pullRequestStatus } from "./pullRequestStatus"; import { use, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { Linking, Platform, Pressable, RefreshControl, StyleSheet, View } from "react-native"; +import { Linking, Platform, Pressable, StyleSheet, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -358,7 +359,7 @@ function usePullRequestToolbarHeight(bottomInset: number) { } export function PullRequestRouteScreen({ route }: StaticScreenProps) { - const returnToList = useAndroidWorkspaceBack("pullRequests"); + const returnToList = useAndroidWorkspaceBack(); const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); diff --git a/apps/mobile/src/features/pull-requests/PullRequestSearchSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestSearchSheet.tsx index d51b88258c29..691a3ebcc828 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestSearchSheet.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestSearchSheet.tsx @@ -1,11 +1,10 @@ import { useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; -import { Platform, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { View } from "react-native"; import { AppBar } from "../../components/ui/AppBar"; -import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; -import { NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET } from "../layout/native-mail-search-toolbar"; +import type { AppBarSearch } from "../../components/ui/AppBar.types"; +import { useSearchToolbarInset, useUnadjustedBottomInset } from "../../components/ui/useBarInset"; /** A searchable picker over a pull request list or a pull request. */ export function PullRequestSearchSheet(props: { @@ -19,9 +18,17 @@ export function PullRequestSearchSheet(props: { readonly children: (list: { readonly paddingBottom: number }) => ReactNode; }) { const navigation = useNavigation(); + const search: AppBarSearch = { + mode: "inline", + compactToolbar: true, + value: props.query, + onChangeText: props.onQueryChange, + placeholder: props.placeholder, + clearAccessibilityLabel: props.clearAccessibilityLabel, + }; // The compact search toolbar floats over the end of the list. - const bottomSearch = useNativeMailSearchToolbar(); - const insets = useSafeAreaInsets(); + const searchToolbarInset = useSearchToolbarInset(search); + const bottomInset = useUnadjustedBottomInset(); return ( navigation.goBack()} closeAccessibilityLabel={props.closeAccessibilityLabel} hideBottomBorder - search={{ - mode: "inline", - compactToolbar: true, - value: props.query, - onChangeText: props.onQueryChange, - placeholder: props.placeholder, - clearAccessibilityLabel: props.clearAccessibilityLabel, - }} + search={search} /> {props.children({ // Android's bottom inset also covers the floating workspace tabs while Settings is open. - paddingBottom: - Platform.OS === "android" - ? insets.bottom + 16 - : bottomSearch - ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16 - : 16, + paddingBottom: bottomInset + searchToolbarInset + 16, })} ); diff --git a/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx b/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx index ec3352b40ea3..f60a13bd9dea 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx @@ -1,10 +1,8 @@ import { SheetHeader } from "../../components/ui/SheetHeader"; import { useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; -import { Platform, ScrollView, useWindowDimensions, View } from "react-native"; -import { KeyboardAvoidingView } from "react-native-keyboard-controller"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ScreenContent } from "../../components/ui/ScreenContent"; +import { View } from "react-native"; +import { FitSheet } from "../../components/ui/FitSheet"; export function PullRequestSheetContent(props: { readonly title: string; @@ -14,49 +12,24 @@ export function PullRequestSheetContent(props: { readonly footer?: ReactNode; }) { const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const { height: windowHeight } = useWindowDimensions(); - const android = Platform.OS === "android"; - const footer = props.footer ? ( - - {android ? {props.footer} : props.footer} - - ) : null; return ( - // Material sheet drags follow the first nested-scrolling native view, and the inline - // header's Compose button is one, so Android renders content first and reverses the column. - navigation.goBack()} + dismissDisabled={props.busy} + /> + } + footer={props.footer} + keyboardShouldPersistTaps="handled" + contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 20 }} > - - - - {props.children} - {/* Android partial-height sheets keep submit actions within the scrollable form. */} - {android ? footer : null} - - - {android ? null : footer} - - navigation.goBack()} - dismissDisabled={props.busy} - /> - + + {props.children} + + ); } diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx index 0b2637b7c6b6..6e69e267e3fe 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx @@ -1,6 +1,6 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; import { useState } from "react"; import { View } from "react-native"; -import { useOpenWorkspaceDetail } from "../layout/useOpenWorkspaceDetail"; import { AppText as Text } from "../../components/ui/AppText"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; import { pullRequestLayerParams } from "./pullRequestStack"; @@ -14,7 +14,7 @@ export function PullRequestStackCard(props: { readonly params: PullRequestParams; readonly state: PullRequestStackState; }) { - const openDetail = useOpenWorkspaceDetail(); + const navigation = useNavigation(); const [expanded, setExpanded] = useState(false); const stack = props.state.stack; if (!stack) { @@ -66,12 +66,14 @@ export function PullRequestStackCard(props: { selected ? undefined : () => - openDetail({ - name: "PullRequest", - params: withKnownPullRequestSummary( - pullRequestLayerParams(props.params, layer.number), + navigation.dispatch( + StackActions.push( + "PullRequest", + withKnownPullRequestSummary( + pullRequestLayerParams(props.params, layer.number), + ), ), - }) + ) } last={index === stack.layers.length - 1} /> diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index 4a4dec4e7a6b..d0672f064064 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -9,7 +9,8 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { useNavigation } from "@react-navigation/native"; import { use, useMemo, useRef, useState, type ReactNode } from "react"; import { useAtomValue } from "@effect/atom-react"; -import { Keyboard, Platform, Pressable, RefreshControl, View } from "react-native"; +import { Keyboard, Platform, Pressable, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; diff --git a/apps/mobile/src/features/pull-requests/pullRequestActions.ts b/apps/mobile/src/features/pull-requests/pullRequestActions.ts index e6c04f192b41..132501b60a24 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActions.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActions.ts @@ -33,6 +33,10 @@ export const MERGE_METHODS = { description: "Keep the commits and add a merge commit.", }, rebase: { title: "Rebase and merge", description: "Add the commits without a merge commit." }, + "fast-forward": { + title: "Fast-forward merge", + description: "Add the commits as they are. Needs the branch to contain the base.", + }, } as const satisfies Record; export function pullRequestActionLabel( diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index 85a77a254967..d79682f94488 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -3,7 +3,12 @@ import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads"; import { AsyncResult } from "effect/reactivity"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Dialog from "../../components/ui/Dialog"; -import { useNavigationState, useRoute } from "@react-navigation/native"; +import { + StackActions, + useNavigation, + useNavigationState, + useRoute, +} from "@react-navigation/native"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { AuthOrchestrationOperateScope, @@ -16,7 +21,6 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useRef, useState } from "react"; -import { useOpenWorkspaceDetail } from "../layout/useOpenWorkspaceDetail"; import { pullRequestComposerContext } from "../../lib/composerContext"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { uuidv4 } from "../../lib/uuid"; @@ -82,14 +86,15 @@ export function usePullRequestThreads( const canOperate = useEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope); const canAsk = !connected || canOperate; const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); - const openDetail = useOpenWorkspaceDetail(); + const navigation = useNavigation(); const pending = useRef(false); const previousPrompts = useRef(new Map()); const openThread = (thread: LinkedThread) => { openThreadRoute({ environmentId: params.environmentId, threadId: thread.id }); }; - const openThreadRoute = (destination: ReactNavigation.RootParamList["Thread"]) => - openDetail({ name: "Thread", params: destination }); + const openThreadRoute = (destination: ReactNavigation.RootParamList["Thread"]) => { + navigation.dispatch(StackActions.push("Thread", destination)); + }; const [busy, setBusy] = useState(false); const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => { if (!pr || !canAsk || pending.current) return; diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index d5fa085eb18f..f2164bf49f99 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -2,12 +2,12 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" import { TextInputWrapper } from "expo-paste-input"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useMemo, useState } from "react"; -import { Platform, ScrollView, View, useWindowDimensions } from "react-native"; -import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ScrollView, View, useWindowDimensions } from "react-native"; +import * as Dialog from "../../components/ui/Dialog"; import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; +import { KeyboardSheetBody } from "../../components/ui/KeyboardSheetBody"; import { SheetHeader } from "../../components/ui/SheetHeader"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { ControlPill } from "../../components/ui/ControlPill"; @@ -39,9 +39,7 @@ type ReviewCommentComposerSheetProps = StaticScreenProps<{ }>; export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) { - const isAndroid = Platform.OS === "android"; const navigation = useNavigation(); - const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); const target = useReviewCommentTarget(); @@ -89,13 +87,14 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp const handleNativePaste = useNativePaste((uris) => { void (async () => { try { - const images = await convertPastedImagesToAttachments({ + const result = await convertPastedImagesToAttachments({ uris, existingCount: attachments.length, }); - if (images.length > 0) { - setAttachments((current) => [...current, ...images]); + if (result.attachments.length > 0) { + setAttachments((current) => [...current, ...result.attachments]); } + if (result.error) Dialog.alert("Could not paste image", result.error); } catch (error) { console.error("[review comment] error converting pasted images", error); } @@ -130,167 +129,136 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp return ( - - - {!target ? ( - - No selection - - Select a diff line or range first. + + void handlePickImages()} + /> + + + + ) : undefined + } + > + {!target ? ( + + No selection + + Select a diff line or range first. + + + ) : ( + + + + {selectionLabel} + + + {target.filePath} - ) : ( - - - - {selectionLabel} - - - {target.filePath} - - - + + REVIEW_COMMENT_PREVIEW_MAX_LINES} + nestedScrollEnabled keyboardShouldPersistTaps="always" - showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={ + selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES + } + style={{ height: previewHeight }} > - REVIEW_COMMENT_PREVIEW_MAX_LINES} - nestedScrollEnabled - keyboardShouldPersistTaps="always" - showsVerticalScrollIndicator={ - selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES - } - style={{ height: previewHeight }} - > - - {selectedLines.map((line) => { - const lineNumber = getReviewUnifiedLineNumber(line); + + {selectedLines.map((line) => { + const lineNumber = getReviewUnifiedLineNumber(line); - return ( - - - - {lineNumber ?? ""} - - - - + return ( + + + + {lineNumber ?? ""} + + + - ); - })} - - + + ); + })} + - + + - - Comment - - - - - - - {attachments.length > 0 ? ( - - { - setAttachments((current) => - current.filter((image) => image.id !== imageId), - ); - }} - /> - - ) : null} + + Comment + + + + + + {attachments.length > 0 ? ( + + { + setAttachments((current) => + current.filter((image) => image.id !== imageId), + ); + }} + /> + + ) : null} - )} - - {!isAndroid && target ? ( - - void handlePickImages()} - /> - - - - ) : null} - - {isAndroid && target ? ( - - - void handlePickImages()} - /> - - - - ) : null} + )} + setPreviewFile(null)} /> ); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 2673865359f0..21bf033b761b 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -4,31 +4,28 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import { LegendList } from "@legendapp/list/react-native"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppBar } from "../../components/ui/AppBar"; import type { AppBarMenuItem } from "../../components/ui/AppBar.types"; import type { ReviewSectionItem } from "./reviewModel"; import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation"; import { type Ref, type ReactElement, useImperativeHandle, useRef, useState } from "react"; -import { - Platform, - Pressable, - RefreshControl, - ScrollView, - type NativeSyntheticEvent, - StyleSheet, - View, -} from "react-native"; +import { Pressable, ScrollView, type NativeSyntheticEvent, StyleSheet, View } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReduceMotionEnabled } from "../../lib/appState"; -import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; -import { useContentTopInset } from "../../components/ui/useBarInset"; +import { useContentTopInset, useSheetContentBottomInset } from "../../components/ui/useBarInset"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; +import { + ContentBand, + ContentStatus, + contentStatusContainerStyle, +} from "../../components/ui/ContentBand"; +import { PaneListItem } from "../../components/ui/PaneListItem"; import { ScreenContent } from "../../components/ui/ScreenContent"; -import { cn } from "../../lib/cn"; +import { SheetPane } from "../../components/ui/SheetPane"; import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -149,15 +146,10 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = function ReviewNotice(props: { readonly notice: string }) { return ( - + Partial diff {props.notice} - + ); }; @@ -242,21 +234,7 @@ const ReviewFileNavigatorRow = function ReviewFileNavigatorRow(props: { }; return ( - + - + ); }; @@ -334,7 +312,7 @@ function ReviewFileNavigator({ files, sectionId, onSelectFile, ref }: ReviewFile ); return ( - + - + ); } @@ -378,11 +356,8 @@ export function ReviewSheet(props: ReviewSheetProps) { const { draftMessage, draftContext } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const { selectedThreadCwd } = useSelectedThreadWorktree(); - const columnMetrics = useNativeColumnLayoutMetrics(); - const safeArea = columnMetrics?.safeArea ?? insets; const topContentInset = useContentTopInset(); - const bottomContentInset = - Platform.OS === "ios" ? safeArea.bottom + 8 : Math.max(insets.bottom, 18) + 18; + const bottomContentInset = useSheetContentBottomInset(); const { error, @@ -577,16 +552,10 @@ export function ReviewSheet(props: ReviewSheetProps) { if (error) { children.push( - + Review unavailable {error} - , + , ); } @@ -636,7 +605,7 @@ export function ReviewSheet(props: ReviewSheetProps) { /> - + {showConnectionNotice ? ( - No review diffs - - This thread has no ready turn diffs and the worktree diff is empty. - - + ) ) : selectedSection.isLoading && selectedSection.diff === null ? ( - - - Loading diff… - + ) : parsedDiff.kind === "empty" ? ( - - No changes - - {selectedSection.subtitle ?? "This diff is empty."} - - + ) : parsedDiff.kind === "raw" ? ( - + {parsedDiff.reason} @@ -787,14 +717,9 @@ export function ReviewSheet(props: ReviewSheetProps) { {parsedDiff.text} - + ) : parsedDiff.kind === "files" ? ( - + Native diff view unavailable. Showing the raw patch. @@ -803,7 +728,7 @@ export function ReviewSheet(props: ReviewSheetProps) { {selectedSection?.diff ?? ""} - + ) : null} )} diff --git a/apps/mobile/src/features/review/reviewFileVisibility.test.tsx b/apps/mobile/src/features/review/reviewFileVisibility.test.tsx new file mode 100644 index 000000000000..ac983a81ab1c --- /dev/null +++ b/apps/mobile/src/features/review/reviewFileVisibility.test.tsx @@ -0,0 +1,69 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, it, vi } from "vite-plus/test"; + +import { buildReviewParsedDiff, type ReviewRenderableFile } from "./reviewModel"; +import { useReviewFileVisibility } from "./reviewFileVisibility"; + +const stored = vi.hoisted(() => ({ expanded: undefined as ReadonlyArray | undefined })); +vi.mock("./reviewState", () => ({ + updateReviewExpandedFileIds: ( + _threadKey: string, + _sectionId: string, + update: (current: ReadonlyArray | undefined) => ReadonlyArray | undefined, + ) => { + stored.expanded = update(stored.expanded); + }, + updateReviewViewedFileIds: () => {}, +})); + +function patch(path: string, line: string) { + return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old\n+${line}\n`; +} + +function files(diff: string): ReadonlyArray { + const parsed = buildReviewParsedDiff(diff, "turn:3"); + if (parsed.kind !== "files") throw new Error("Expected structured diff"); + return parsed.files; +} + +function visibility(current: ReadonlyArray) { + let result: ReturnType | undefined; + function Probe({ report }: { report: (value: typeof result) => void }) { + report( + useReviewFileVisibility({ + threadKey: "thread", + sectionId: "turn:3", + files: current, + cachedExpandedFileIds: stored.expanded, + cachedViewedFileIds: undefined, + }), + ); + return null; + } + renderToStaticMarkup( (result = value)} />); + return result!; +} + +it("keeps files the reader left open expanded when the section's diff changes", () => { + stored.expanded = undefined; + const before = files(patch("a.ts", "one") + patch("b.ts", "two")); + visibility(before).toggleExpandedFile(before[0]!.id); + expect(visibility(before).collapsedFileIds).toEqual([before[0]!.id]); + + const after = files(patch("a.ts", "changed") + patch("b.ts", "two")); + const collapsedPaths = visibility(after).collapsedFileIds.map( + (id) => after.find((file) => file.id === id)!.path, + ); + expect(collapsedPaths).not.toContain("b.ts"); +}); + +it("folds only the selected entry when a file becomes a symlink", () => { + stored.expanded = undefined; + const current = files( + "diff --git a/a.ts b/a.ts\ndeleted file mode 100644\n--- a/a.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n" + + "diff --git a/a.ts b/a.ts\nnew file mode 120000\n--- /dev/null\n+++ b/a.ts\n@@ -0,0 +1 @@\n+b.ts\n\\ No newline at end of file\n", + ); + expect(current).toHaveLength(2); + visibility(current).toggleExpandedFile(current[0]!.id); + expect(visibility(current).collapsedFileIds).toEqual([current[0]!.id]); +}); diff --git a/apps/mobile/src/features/review/reviewFileVisibility.ts b/apps/mobile/src/features/review/reviewFileVisibility.ts index 5d01de5700ed..7e1379d2b5ff 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.ts @@ -3,10 +3,25 @@ import { useCallback, useMemo } from "react"; import { updateReviewExpandedFileIds, updateReviewViewedFileIds } from "./reviewState"; import type { ReviewRenderableFile } from "./reviewModel"; -function getDefaultReviewExpandedFileIds( +// File ids are scoped to one diff text, so a refresh renames every file. Expansion is +// stored by path, which survives a refresh; the occurrence index keeps apart entries +// that share a path, such as the deletion and addition when a file becomes a symlink. +function getReviewFileExpansionKeys( files: ReadonlyArray, ): ReadonlyArray { - return files.map((file) => file.id); + const seen = new Map(); + return files.map((file) => { + const occurrence = seen.get(file.path) ?? 0; + seen.set(file.path, occurrence + 1); + return `${occurrence}:${file.path}`; + }); +} + +function getExpandedReviewFileKeys( + keys: ReadonlyArray, + storedKeys: ReadonlyArray | undefined, +): ReadonlyArray { + return storedKeys === undefined ? keys : keys.filter((key) => storedKeys.includes(key)); } function getValidReviewFileIds( @@ -14,7 +29,7 @@ function getValidReviewFileIds( fileIds: ReadonlyArray | undefined, ): ReadonlyArray { if (fileIds === undefined) { - return getDefaultReviewExpandedFileIds(files); + return files.map((file) => file.id); } const fileIdSet = new Set(files.map((file) => file.id)); @@ -49,10 +64,13 @@ export function useReviewFileVisibility(input: { }) { const { cachedExpandedFileIds, cachedViewedFileIds, files, sectionId, threadKey } = input; - const expandedFileIds = useMemo( - () => getValidReviewFileIds(files, cachedExpandedFileIds), - [cachedExpandedFileIds, files], - ); + const expansionKeys = useMemo(() => getReviewFileExpansionKeys(files), [files]); + const expandedFileIds = useMemo(() => { + const expandedKeys = new Set(getExpandedReviewFileKeys(expansionKeys, cachedExpandedFileIds)); + return files + .filter((_, index) => expandedKeys.has(expansionKeys[index]!)) + .map((file) => file.id); + }, [cachedExpandedFileIds, expansionKeys, files]); const viewedFileIds = useMemo( () => getValidExplicitReviewFileIds(files, cachedViewedFileIds), [cachedViewedFileIds, files], @@ -73,11 +91,15 @@ export function useReviewFileVisibility(input: { return; } + const key = expansionKeys[files.findIndex((file) => file.id === fileId)]; + if (key === undefined) { + return; + } updateReviewExpandedFileIds(threadKey, sectionId, (existing) => - toggleReviewFileId(getValidReviewFileIds(files, existing), fileId), + toggleReviewFileId(getExpandedReviewFileKeys(expansionKeys, existing), key), ); }, - [files, sectionId, threadKey], + [expansionKeys, files, sectionId, threadKey], ); const toggleViewedFile = useCallback( @@ -91,13 +113,14 @@ export function useReviewFileVisibility(input: { toggleReviewFileId(getValidExplicitReviewFileIds(files, existing), fileId), ); - if (shouldCollapse) { + const key = expansionKeys[files.findIndex((file) => file.id === fileId)]; + if (shouldCollapse && key !== undefined) { updateReviewExpandedFileIds(threadKey, sectionId, (existing) => - removeReviewFileId(getValidReviewFileIds(files, existing), fileId), + removeReviewFileId(getExpandedReviewFileKeys(expansionKeys, existing), key), ); } }, - [files, sectionId, threadKey, viewedFileIds], + [expansionKeys, files, sectionId, threadKey, viewedFileIds], ); return { diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 75b0cd4e9166..d37d405280a0 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "@effect/atom-react"; import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import { useRef, useState } from "react"; -import { RefreshControl } from "react-native"; +import { RefreshControl } from "../../components/ui/RefreshControl"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SettingsScreen } from "./components/SettingsScreen"; diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index f4604ca4a2f6..3f552c1f0c46 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -87,9 +87,7 @@ import { useScheduledTaskDestinationLabel, useScheduledTaskEditor } from "./sche import { discardScheduledTaskEditor, markScheduledTaskEditorSaved, - scheduledTaskEditorSessionAtom, } from "./scheduled-task-editor-state"; -import { appAtomRegistry } from "../../state/atom-registry"; import { formatNextScheduledTaskRun, formatScheduledTaskInterval, @@ -519,8 +517,6 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } const voiceInput = useVoiceInputController({ ownerKey, label: editor?.draft.title.trim() || title, - subscribeToDraftChanges: (onChange) => - appAtomRegistry.subscribe(scheduledTaskEditorSessionAtom, onChange), selection, readDraftMessage: () => { const current = readEditor(); diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 77b845637062..5026a90e13b9 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -582,6 +582,21 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { onValueChange={(value) => write({ continueThreadsAfterServerUpdate: value })} /> + + write({ keepAwakeWhileAgentsWork: value })} + /> + ) : null} diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index 229ef86273b8..aee091d9f598 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -299,8 +299,8 @@ function LinkRequestSheet(props: { > - Agents here can then launch, message and wait on threads there. It appears in that - machine's Connections, where it can be revoked. + Agents here can then launch, message and wait on threads there. It appears under + Authorized clients in that machine's Sharing & access, where it can be revoked. {item.url !== undefined && target.kind === "machine" ? ( @@ -375,7 +375,7 @@ function LinkRequestSheet(props: { {useExisting ? ( This environment is already linked to {targetName}. The agent can use that link as it - is; change its access in Settings → Connections. + is; change its access in Settings → Environments. ) : target.kind === "none" ? null : ( <> @@ -394,8 +394,8 @@ function LinkRequestSheet(props: { spellCheck={false} /> - Create a link on {targetName}: Settings → Connections → Create link, then paste - its code here. + Create a link on {targetName}: Settings → Sharing & access → Authorized clients → + Create link, then paste its code here. )} diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx index de3041c06b57..747b9a9fc186 100644 --- a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx +++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx @@ -56,6 +56,41 @@ function descendsFrom( /** Resolve apps from stored tool calls so links cannot supply apps the call never produced. */ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { + const navigation = useNavigation(); + const { environmentId, threadId, itemId } = route.params; + if (!environmentId?.trim() || !threadId?.trim() || !itemId?.trim()) { + return ( + + navigation.goBack()} + closeAccessibilityLabel="Exit full screen" + /> + This app cannot be shown. + + ); + } + return ( + + ); +} + +function McpAppFullscreen({ + params, + environmentId, + threadId, + itemId, +}: { + readonly params: McpAppFullscreenScreenProps["route"]["params"]; + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly itemId: TurnItemId; +}) { const navigation = useNavigation(); const insets = useSafeAreaInsets(); // Wait for focus before goBack; an exit under another screen must not pop that screen. @@ -74,10 +109,6 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { }); }; const [size, setSize] = useState({ width: 0, height: 0 }); - const params = route.params; - const environmentId = EnvironmentId.make(params.environmentId); - const threadId = ThreadId.make(params.threadId); - const itemId = TurnItemId.make(params.itemId); const conversationThreadId = useConversationThreadId( environmentId, threadId, diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 4b77761b600a..abb35e793edc 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -90,7 +90,7 @@ const idleBranchCheckoutAtom = Atom.make(AsyncResult.initial) { const currentRoute = useRoute(); - const goBack = useWorkspaceBackNavigation("threads"); + const goBack = useWorkspaceBackNavigation(); const params = route.params ?? {}; const draftId = Array.isArray(params.draftId) ? params.draftId[0] : params.draftId; const projects = useProjects(); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 0fe4f8e267a2..1c90d5fa2df1 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -163,7 +163,7 @@ export function NewTaskDraftScreen(props: { const flow = useNewTaskFlow(); const navigation = useNavigation(); const isFocused = useIsFocused(); - const goBack = useWorkspaceBackNavigation("threads"); + const goBack = useWorkspaceBackNavigation(); const currentRoute = useRoute(); const flowKey = newTaskFlowKey(currentRoute); const { getShare, isLoading: isIncomingShareInboxLoading } = useIncomingShare(); diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 0793537aa0ae..12640fdb6e7e 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -273,6 +273,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { threadId={props.threadId} workspaceRoot={props.workspaceRoot} setDirection={setDirection} + preserveSoftBreaks /> {question.options.map((option) => { diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index 2c369c1e5b7b..b8fa75318860 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -109,14 +109,17 @@ export function QuestionAttachments(props: { uris, existingCount: appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0, }) - .then(async (images) => { + .then(async (result) => { if ( scope.active && (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0 ) { - if (append(key, images) > 0) - Dialog.alert("Could not paste image", "Too many attachments."); - } else await releaseUnusedComposerAttachmentFiles(images); + const problems = [ + ...(result.error ? [result.error] : []), + ...(append(key, result.attachments) > 0 ? ["Too many attachments."] : []), + ]; + if (problems.length) Dialog.alert("Could not paste image", problems.join("\n\n")); + } else await releaseUnusedComposerAttachmentFiles(result.attachments); }) .catch((error) => Dialog.alert( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 9c2eb6d4c38d..3b1b927ef8a9 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -57,13 +57,11 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re import { scopedThreadKey } from "../../lib/scopedEntities"; import { getComposerDraftSnapshot, - composerDraftsAtom, setComposerDraftText, composerContextImportsAtom, countComposerDraftAttachmentsAfterSelection, useComposerDraft, } from "../../state/use-composer-drafts"; -import { appAtomRegistry } from "../../state/atom-registry"; import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject, useVisibleThreadShells } from "../../state/entities"; import { useMentionableEnvironments } from "../../state/environments"; @@ -579,7 +577,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ownerKey: composerDraftKey, label: props.selectedThread.title || "Untitled thread", readDraftMessage: () => getComposerDraftSnapshot(composerDraftKey).text, - subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange), selection: composerMenu.selection, onChangeDraftMessage: (text) => setComposerDraftText(composerDraftKey, text), onChangeSelection: composerMenu.onSelectionChange, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3e7eec1449e2..b36e408c2a5d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1063,11 +1063,16 @@ function ThreadDetailBody( windowHeight: screenAreaHeight, keyboardHeight: keyboardStickyEnabled ? -keyboardTranslation.value : 0, navigationHeaderHeight, - composerOverlapHeight: composerBottomInset, + // An in-flow question shares the room above it with the floating control. + composerOverlapHeight: + composerBottomInset + + (questionInFlow && showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0), }), ); + // A question pads itself clear of the bottom inset, below the card's own height limit. + const questionBottomInset = activeUserInputRequestId !== null ? composerBottomInset : 0; const composerPanelsHeightStyle = useAnimatedStyle(() => ({ - maxHeight: pendingUserInputMaxHeight.value, + maxHeight: pendingUserInputMaxHeight.value + questionBottomInset, flexGrow: 0, })); // Questions overlay the composer on iOS; an approval sits above it, so it gives up its height. diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index dbbcaa1fd9cf..8e8c1c4955b2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -277,6 +277,7 @@ import { ThreadMarkdownImageUnavailable, ThreadMarkdownImageView, } from "./ThreadMarkdownImage"; +import { ZoomableImagePressable } from "./ZoomableImagePressable"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; import { formatMessageTimestamp } from "./messageTimestamp"; @@ -471,6 +472,8 @@ function MessageImage(props: { readonly className: string; readonly maxWidth?: number; readonly height?: number; + readonly accessibilityLabel: string; + readonly onPress: () => void; readonly onLoad?: () => void; readonly onError?: () => void; }) { @@ -485,11 +488,13 @@ function MessageImage(props: { }) : null; return ( - { - if (props.maxWidth !== undefined) setSize(event.source); - props.onLoad?.(); - }} - onError={props.onError} - /> + > + { + if (props.maxWidth !== undefined) setSize(event.source); + props.onLoad?.(); + }} + onError={props.onError} + /> + ); } @@ -550,8 +561,12 @@ function MessageAttachmentImage(props: { return ( - props.onPressPreview( @@ -570,22 +585,15 @@ function MessageAttachmentImage(props: { }), ) } - > - { - retriedImage.current = false; - }} - onError={() => { - if (retriedImage.current) return; - retriedImage.current = true; - void refreshAssetUrl(); - }} - /> - + onLoad={() => { + retriedImage.current = false; + }} + onError={() => { + if (retriedImage.current) return; + retriedImage.current = true; + void refreshAssetUrl(); + }} + /> ); } @@ -960,21 +968,18 @@ function PendingMessageMedia(props: { if (isImageAttachment(attachment)) { const uri = fileBacked ? preview.uri : (attachment.dataUrl ?? attachment.previewUri); return ( - { const source = composerImageGallery([attachment])[0]; if (source) props.onPressPreview(withGallery(source)); }} - > - - + /> ); } if (preview.uri === null) { @@ -3326,7 +3331,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const jumpRunId = jumpReady && !presentedFeed.some((entry) => entry.type === "message" && entry.id === jumpToMessageId) - ? messageFoldRunId(props.feed, jumpToMessageId) + ? messageFoldRunId(props.feed, jumpToMessageId, presentedFeed) : null; if (jumpRunId && !expandedTurnIds.has(jumpRunId)) { setInteractionState((current) => ({ diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index d0045bf5f284..0388bfb29929 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -22,7 +22,7 @@ export function ThreadHeader(props: { readonly inspectorMode: ThreadInspectorMode | null; readonly onOpenFilesInspector: () => void; }) { - const goBack = useWorkspaceBackNavigation("threads"); + const goBack = useWorkspaceBackNavigation(); const { toggleAuxiliaryPane, startNewTask } = useAdaptiveWorkspaceLayout(); const { panes } = useWorkspacePaneState(); const gitMenu = useThreadGitMenuDefinition(props.gitControls); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx index 209cccf8d2ef..82f5cc62a7b6 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx @@ -3,19 +3,71 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; +type HandlerProps = Record void) | undefined>; +type GestureHandlers = Record void>; +type Transform = [{ translateX: number }, { translateY: number }, { scale: number }]; + const harness = vi.hoisted(() => ({ failImage: () => {}, loadImage: (_event: { source: { width: number; height: number } }) => {}, Passthrough: ({ children }: { children?: unknown }) => children, + pressable: {} as HandlerProps, + gestures: {} as Record, + contentStyle: () => ({}) as { transform?: Transform }, })); -vi.mock("react-native", () => ({ - Pressable: harness.Passthrough, - StyleSheet: { absoluteFill: {} }, - View: ({ children, style }: { children?: ReactNode; style?: unknown }) => ( +vi.mock("react-native", () => { + const View = ({ children, style }: { children?: ReactNode; style?: unknown }) => (
{children}
- ), -})); + ); + return { + Platform: { OS: "ios" }, + Pressable: (props: HandlerProps & { children?: ReactNode; style?: unknown }) => { + harness.pressable = props; + return {props.children}; + }, + StyleSheet: { absoluteFill: {} }, + View, + }; +}); +vi.mock("react-native-gesture-handler", () => { + // Records each gesture's callbacks so a test can drive the worklets with native-shaped events. + const builder = (kind: string) => () => { + const handlers: GestureHandlers = {}; + harness.gestures[kind] = handlers; + const gesture: object = new Proxy( + {}, + { + get: (_target, name) => (callback: GestureHandlers[string]) => { + if (typeof name === "string" && name.startsWith("on")) handlers[name] = callback; + return gesture; + }, + }, + ); + return gesture; + }; + return { + Gesture: { Pinch: builder("pinch"), Pan: builder("pan"), Simultaneous: () => ({}) }, + GestureDetector: ({ children }: { children?: ReactNode }) => children, + }; +}); +vi.mock("react-native-reanimated", async () => { + const { useState } = await import("react"); + return { + default: { View: ({ children }: { children?: ReactNode }) =>
{children}
}, + runOnJS: (callback: () => void) => callback, + // Reads the UI-thread style on demand, as Reanimated would after a gesture frame. + useAnimatedStyle: (updater: () => { transform?: Transform }) => { + harness.contentStyle = updater; + return {}; + }, + useSharedValue: (initial: T) => + useState(() => { + let value = initial; + return { get: () => value, set: (next: T) => void (value = next) }; + })[0], + }; +}); vi.mock("expo-image", () => ({ Image: (props: { onError: () => void; @@ -203,3 +255,146 @@ it("does not parse an obsolete SVG response after its last reader unmounts", asy registry.dispose(); } }); + +const zoom = () => { + const [{ translateX }, { translateY }, { scale }] = harness.contentStyle().transform!; + // Clamping at fit can yield -0, which is the same position. + return { scale, x: translateX + 0, y: translateY + 0 }; +}; +const pinch = (phase: "onStart" | "onUpdate", focalX: number, focalY: number, scale: number) => + act(async () => harness.gestures.pinch![phase]!({ focalX, focalY, scale, numberOfPointers: 2 })); +const endPinch = () => act(async () => harness.gestures.pinch!.onFinalize!({})); +const layout = (width: number, height: number) => + act(async () => harness.pressable.onLayout?.({ nativeEvent: { layout: { width, height } } })); +const markdownImage = (sourceKey: string, onPressPreview: () => void = () => {}) => ( + + + +); + +it("does not open the preview when a touch on the inline image pinches or pans it", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + const onPressPreview = vi.fn(); + const touch = (fingers: number) => + act(async () => + harness.pressable.onTouchStart?.({ + nativeEvent: { touches: Array.from({ length: fingers }, () => ({})) }, + }), + ); + const release = () => + act(async () => harness.pressable.onPress?.({ nativeEvent: { identifier: 1 } })); + try { + await act(async () => root.render(markdownImage("hero", onPressPreview))); + await touch(1); + await touch(2); + await release(); + expect(onPressPreview).not.toHaveBeenCalled(); + + await touch(1); + await act(async () => harness.gestures.pan!.onStart!({ translationX: 5, translationY: 0 })); + await release(); + expect(onPressPreview).not.toHaveBeenCalled(); + + await touch(1); + await release(); + expect(onPressPreview).toHaveBeenCalledOnce(); + + // VoiceOver activation has no touch, so an earlier pinch must not swallow it. + await touch(2); + await act(async () => harness.pressable.onPress?.({ nativeEvent: {} })); + expect(onPressPreview).toHaveBeenCalledTimes(2); + } finally { + await act(async () => root.unmount()); + } +}); + +it("zooms the inline image around the pinch and keeps it until the source or frame changes", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + const touchPan = () => { + const manager = { fail: vi.fn() }; + harness.gestures.pan!.onTouchesDown!({}, manager); + return manager; + }; + try { + await act(async () => root.render(markdownImage("a"))); + // The frame center is (172.5, 101); the pinch is off center. + await layout(345, 202); + await pinch("onStart", 300, 150, 1); + await pinch("onUpdate", 300, 150, 2); + expect(zoom()).toEqual({ scale: 2, x: -127.5, y: -49 }); + await pinch("onUpdate", 280, 140, 2); + expect(zoom()).toEqual({ scale: 2, x: -147.5, y: -59 }); + await act(async () => + harness.gestures.pinch!.onUpdate!({ focalX: 90, focalY: 90, scale: 2, numberOfPointers: 1 }), + ); + expect(zoom()).toEqual({ scale: 2, x: -147.5, y: -59 }); + await endPinch(); + + await act(async () => harness.loadImage({ source: { width: 1000, height: 585 } })); + await act(async () => root.render(markdownImage("a"))); + await layout(345, 202); + expect(zoom()).toEqual({ scale: 2, x: -147.5, y: -59 }); + + await pinch("onStart", 100, 50, 1); + await pinch("onUpdate", 100, 50, 1.5); + expect(zoom()).toEqual({ scale: 3, x: -185, y: -63 }); + await pinch("onUpdate", 100, 50, 10); + expect(zoom().scale).toBe(6); + await endPinch(); + + const zoomedPan = touchPan(); + await act(async () => harness.gestures.pan!.onStart!({ translationX: 5, translationY: 0 })); + await act(async () => + harness.gestures.pan!.onUpdate!({ translationX: 5000, translationY: -5000 }), + ); + expect(zoomedPan.fail).not.toHaveBeenCalled(); + expect(zoom()).toEqual({ scale: 6, x: 862.5, y: -505 }); + + await pinch("onStart", 172, 100, 1); + await pinch("onUpdate", 172, 100, 0.01); + await endPinch(); + expect(zoom()).toEqual({ scale: 1, x: 0, y: 0 }); + expect(touchPan().fail).toHaveBeenCalledOnce(); + + await pinch("onStart", 300, 150, 1); + await pinch("onUpdate", 300, 150, 2); + await endPinch(); + await layout(300, 175); + expect(zoom()).toEqual({ scale: 1, x: 0, y: 0 }); + + await pinch("onStart", 300, 150, 1); + await pinch("onUpdate", 300, 150, 2); + await endPinch(); + await act(async () => root.render(markdownImage("b"))); + expect(zoom()).toEqual({ scale: 1, x: 0, y: 0 }); + + for (const panFirst of [true, false]) { + await act(async () => root.render(markdownImage(`handoff-${panFirst}`))); + await layout(300, 200); + await pinch("onStart", 150, 100, 1); + await pinch("onUpdate", 150, 100, 2); + await endPinch(); + await act(async () => harness.gestures.pan!.onStart!({ translationX: 0, translationY: 0 })); + await pinch("onStart", 150, 100, 1); + const panUpdate = () => + act(async () => harness.gestures.pan!.onUpdate!({ translationX: 10, translationY: 0 })); + if (panFirst) await panUpdate(); + await pinch("onUpdate", 150, 100, 1.5); + if (!panFirst) await panUpdate(); + await endPinch(); + await act(async () => harness.gestures.pan!.onUpdate!({ translationX: 20, translationY: 0 })); + expect(zoom()).toEqual({ scale: 3, x: 10, y: 0 }); + } + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index da9ba935838c..90eb97f4e3b8 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -20,6 +20,7 @@ import { useMediaActions } from "../../state/mediaActions"; import type { MediaActionsSource } from "../../lib/mediaActionsSource"; import { useAssetUrlState } from "../../state/assets"; import { WorkspaceMutationIdContext } from "./workspaceMutationIdContext"; +import { ZoomableImagePressable } from "./ZoomableImagePressable"; import { assetImageCacheKey } from "../../lib/assetImageSource"; import { MARKDOWN_IMAGE_MAX_WIDTH, @@ -167,7 +168,8 @@ export function ThreadMarkdownImageView(props: { ) : ( - - - {props.format === "svg" ? ( - - ) : ( - - // Only the first decode counts: later loads report the bitmap - // downsampled for the current frame (969 vs 970 wide), so keeping - // them resizes the frame, which reloads the image, and so on. - setDecoded((current) => - current?.sourceKey === props.sourceKey - ? current - : { sourceKey: props.sourceKey, size }, - ) - } - onError={() => setFailure({ uri: props.uri, failed: true })} - /> - )} - - + {props.format === "svg" ? ( + + ) : ( + + // Only the first decode counts: later loads report the bitmap + // downsampled for the current frame (969 vs 970 wide), so keeping + // them resizes the frame, which reloads the image, and so on. + setDecoded((current) => + current?.sourceKey === props.sourceKey + ? current + : { sourceKey: props.sourceKey, size }, + ) + } + onError={() => setFailure({ uri: props.uri, failed: true })} + /> + )} + )} diff --git a/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx b/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx index dd20c0a162f7..309c67c4999f 100644 --- a/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx @@ -1,7 +1,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { LegendList } from "@legendapp/list/react-native"; import { useMemo } from "react"; -import { Platform, View } from "react-native"; +import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; @@ -44,7 +44,7 @@ export function ThreadPullRequestsSheet( const title = `Linked ${actions.terminology.plural}`; const content = ( - + {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- Sheet content; sheets sit outside workspace columns and keep their own header handling. */} diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 58129ebc0cec..d3b3ba600629 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -5,6 +5,10 @@ import { queuedRunEditBlockedReason, } from "@t3tools/client-runtime/queued-run-edit"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; @@ -20,6 +24,7 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "../../components/ui/Button"; +import * as Dialog from "../../components/ui/Dialog"; import { SheetHeader } from "../../components/ui/SheetHeader"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -132,6 +137,10 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const promote = useAtomCommand(threadEnvironment.promoteQueuedRun, "promote queued message"); const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun, "remove queued message"); const resume = useAtomCommand(threadEnvironment.resumeThreadQueue, "resume queue"); + const resumeHeldQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, { + label: "resume queue", + reportFailure: false, + }); const [resuming, setResuming] = useState(false); const [busyRunId, setBusyRunId] = useState(null); const busyRef = useRef(false); @@ -217,9 +226,20 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { if (busyRef.current) return; busyRef.current = true; setResuming(true); - await resume({ ...target, input: { threadId: target.threadId } }); + const result = await resumeHeldQueue({ + ...target, + input: { threadId: target.threadId }, + }); busyRef.current = false; setResuming(false); + // A usage limit holds the queue until the composer's Resume thread continues the run. + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Could not resume queue", + error instanceof Error ? error.message : "Try again.", + ); + } }} /> diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6205fd128179..8f1463ce1bd8 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -136,7 +136,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null { const InspectorHeaderShownContext = createContext(false); function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) { - const goBack = useWorkspaceBackNavigation("threads"); + const goBack = useWorkspaceBackNavigation(); const inspectorHeaderShown = use(InspectorHeaderShownContext); return ( {content} @@ -208,9 +211,10 @@ function DisclosureRow(props: { accessibilityRole="button" onPress={props.onPress} className={cn( - "min-h-11 flex-row items-center gap-2 bg-grouped-card px-4 py-2 active:bg-subtle android:min-h-14", + "flex-row items-center gap-2 bg-grouped-card px-4 py-2 active:bg-subtle", !props.isLast && "border-b border-border-subtle", )} + style={{ minHeight: ROW_MIN_HEIGHT.item }} > {props.label} @@ -1048,15 +1052,11 @@ function ThreadSettingsMainContent(props: { alwaysBounceVertical automaticallyAdjustsScrollIndicatorInsets className="flex-1 bg-sheet" - style={ - Platform.OS === "android" - ? { width: "100%", maxWidth: 720, alignSelf: "center" } - : undefined - } + style={READABLE_WIDTH_STYLE} contentContainerStyle={{ paddingTop: 4 }} contentInsetAdjustmentBehavior="automatic" data={listItems} - estimatedItemSize={Platform.OS === "android" ? 56 : 48} + estimatedItemSize={ROW_MIN_HEIGHT.item} extraData={animationsReady} getItemType={(item) => item.kind} itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} @@ -1151,11 +1151,7 @@ function ThreadSettingsChoiceContent(props: { return ( , "children"> & { + readonly children: ReactNode; +}; + +/** + * Inline chat image that pinch-zooms in place on iOS. Key it by image source so a + * new source starts at fit; ordinary rerenders keep the zoom and offset. + */ +export function ZoomableImagePressable(props: ZoomableImagePressableProps) { + if (Platform.OS !== "ios") return ; + return ; +} + +function IosZoomableImagePressable({ + children, + onTouchStart, + onPress, + onLayout, + ...pressableProps +}: ZoomableImagePressableProps) { + const gestured = useRef(false); + const layoutSize = useRef(null); + const zoom = useSharedValue(FIT); + const size = useSharedValue({ width: 0, height: 0 }); + const pinching = useSharedValue(false); + const pinchStart = useSharedValue({ zoom: FIT, focalX: 0, focalY: 0, scale: 1, pointers: 0 }); + const panStart = useSharedValue({ zoom: FIT, translationX: 0, translationY: 0 }); + + const markGestured = useEffectEvent(() => { + gestured.current = true; + }); + const [gesture] = useState(() => { + // Focal points arrive in the untransformed frame; the transform is about its center. + const pinch = Gesture.Pinch() + .onStart((event) => { + const frame = size.get(); + pinching.set(true); + pinchStart.set({ + zoom: zoom.get(), + focalX: event.focalX - frame.width / 2, + focalY: event.focalY - frame.height / 2, + scale: event.scale, + pointers: event.numberOfPointers, + }); + runOnJS(markGestured)(); + }) + .onUpdate((event) => { + const frame = size.get(); + const start = pinchStart.get(); + const focalX = event.focalX - frame.width / 2; + const focalY = event.focalY - frame.height / 2; + if (event.numberOfPointers !== start.pointers) { + // A lifted or added finger jumps the focal point; continue from here instead. + pinchStart.set({ + zoom: zoom.get(), + focalX, + focalY, + scale: event.scale, + pointers: event.numberOfPointers, + }); + return; + } + const scale = Math.min( + Math.max((start.zoom.scale * event.scale) / start.scale, 1), + MAX_INLINE_ZOOM, + ); + // Keep the content point that started under the fingers under them. + const contentX = (start.focalX - start.zoom.x) / start.zoom.scale; + const contentY = (start.focalY - start.zoom.y) / start.zoom.scale; + zoom.set( + clampZoom({ scale, x: focalX - scale * contentX, y: focalY - scale * contentY }, frame), + ); + }) + .onFinalize(() => { + panStart.set({ ...panStart.get(), zoom: zoom.get() }); + pinching.set(false); + }); + const pan = Gesture.Pan() + .minDistance(ZOOMED_PAN_MIN_DISTANCE) + .onTouchesDown((_event, manager) => { + // At fit, a one-finger drag belongs to the feed. + if (zoom.get().scale <= 1) manager.fail(); + }) + .onStart((event) => { + panStart.set({ + zoom: zoom.get(), + translationX: event.translationX, + translationY: event.translationY, + }); + runOnJS(markGestured)(); + }) + .onUpdate((event) => { + if (pinching.get()) { + // The pinch owns the transform; resume panning from wherever it leaves it. + panStart.set({ + zoom: zoom.get(), + translationX: event.translationX, + translationY: event.translationY, + }); + return; + } + const start = panStart.get(); + zoom.set( + clampZoom( + { + scale: start.zoom.scale, + x: start.zoom.x + event.translationX - start.translationX, + y: start.zoom.y + event.translationY - start.translationY, + }, + size.get(), + ), + ); + }); + return Gesture.Simultaneous(pinch, pan); + }); + const contentStyle = useAnimatedStyle(() => { + const { scale, x, y } = zoom.get(); + return { transform: [{ translateX: x }, { translateY: y }, { scale }] }; + }); + + return ( + + { + gestured.current = event.nativeEvent.touches.length > 1; + onTouchStart?.(event); + }} + onPress={(event) => { + // Accessibility activation carries no touch identifier and no gesture. + if (typeof event.nativeEvent.identifier === "number" && gestured.current) return; + onPress?.(event); + }} + onLayout={(event) => { + const { width, height } = event.nativeEvent.layout; + const previous = layoutSize.current; + if (previous?.width !== width || previous?.height !== height) { + // The old offsets no longer fit the new frame, so a resize starts over at fit. + layoutSize.current = { width, height }; + size.set({ width, height }); + zoom.set(FIT); + } + onLayout?.(event); + }} + > + {children} + + + ); +} diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx index b2d29db8c8c7..436fb4dd0b40 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx @@ -8,25 +8,26 @@ const checkout = vi.hoisted(() => ({ branches: [] as Array<{ name: string; worktreePath: string | null }>, })); -vi.mock("react-native", () => ({ - Platform: { OS: "android" }, - View: ({ children }: { children: ReactNode }) =>
{children}
, - ScrollView: ({ children }: { children: ReactNode }) =>
{children}
, - Pressable: ({ children, disabled }: { children: ReactNode; disabled: boolean }) => ( - - ), - useWindowDimensions: () => ({ height: 800 }), -})); vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) })); -vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); vi.mock("../../../components/ui/SheetHeader", () => ({ SheetHeader: () => null })); -vi.mock("../../../components/ui/ScreenContent", () => ({ - ScreenContent: ({ children }: { children: ReactNode }) =>
{children}
, +vi.mock("../../../components/ui/FitSheet", () => ({ + FitSheet: ({ children }: { children: ReactNode }) =>
{children}
, })); vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); -vi.mock("../../../components/ui/AppText", () => ({ - AppText: ({ children }: { children: ReactNode }) => {children}, - AppTextInput: () => null, +vi.mock("../../../components/ui/FormSection", () => ({ + FormSection: () => null, + FormField: () => null, +})); +vi.mock("../../../components/ui/ListSection", () => ({ + ListSection: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../../components/ui/ListChoiceRow", () => ({ + ListChoiceRow: (props: { label: string; description: string; disabled: boolean }) => ( + + ), })); vi.mock("./gitSheetComponents", () => ({ SheetActionButton: () => null })); vi.mock("../../../state/entities", () => ({})); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx index 6bd008116bab..fef45b5c8800 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx @@ -17,28 +17,20 @@ const state = vi.hoisted(() => ({ })); vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({ goBack() {} }) })); -vi.mock("react-native", () => ({ - Platform: { OS: "android" }, - useWindowDimensions: () => ({ height: 800 }), - View: ({ children }: { children: ReactNode }) => children, - ScrollView: ({ children }: { children: ReactNode }) => children, - Pressable: () => null, -})); -vi.mock("react-native-safe-area-context", () => ({ - useSafeAreaInsets: () => ({ bottom: 0 }), -})); vi.mock("../../../components/ui/SheetHeader", () => ({ SheetHeader: () => null })); -vi.mock("../../../components/ui/ScreenContent", () => ({ - ScreenContent: ({ children }: { children: ReactNode }) => children, +vi.mock("../../../components/ui/FitSheet", () => ({ + FitSheet: ({ children }: { children: ReactNode }) => children, })); vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); -vi.mock("../../../components/ui/AppText", () => ({ - AppText: ({ children }: { children: ReactNode }) => children, - AppTextInput: (props: InputProps) => { +vi.mock("../../../components/ui/FormSection", () => ({ + FormSection: ({ children }: { children: ReactNode }) => children, + FormField: (props: InputProps) => { state.inputs.set(props.accessibilityLabel, props); return null; }, })); +vi.mock("../../../components/ui/ListSection", () => ({ ListSection: () => null })); +vi.mock("../../../components/ui/ListChoiceRow", () => ({ ListChoiceRow: () => null })); vi.mock("../../../state/query", () => ({ useEnvironmentQuery: () => ({ data: state.refName === null ? undefined : { refName: state.refName }, diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 67d0818ee6c4..0f3d785b4104 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -1,13 +1,12 @@ import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useState } from "react"; -import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SheetHeader } from "../../../components/ui/SheetHeader"; -import { ScreenContent } from "../../../components/ui/ScreenContent"; -import { AppText as Text, AppTextInput as TextInput } from "../../../components/ui/AppText"; -import { cn } from "../../../lib/cn"; +import { FitSheet } from "../../../components/ui/FitSheet"; +import { FormField, FormSection } from "../../../components/ui/FormSection"; +import { ListChoiceRow } from "../../../components/ui/ListChoiceRow"; +import { ListSection } from "../../../components/ui/ListSection"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; @@ -23,8 +22,6 @@ type GitBranchesSheetProps = StaticScreenProps<{ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); @@ -62,160 +59,113 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const disabledExistingBranches = new Set(disabledExistingBranchNames); return ( - navigation.goBack()} />} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} > - - + + { + const branch = sanitizeFeatureBranchName(newBranchName.trim()); + if (branch.length === 0) return; + void gitActions.onCreateSelectedThreadBranch(branch).then((result) => { + if (result === null) return; + setNewBranchName(""); + navigation.goBack(); + }); + }} + /> + + + + + + - - - New branch - - - { + const baseBranch = worktreeBaseBranch.trim(); + const newBranch = worktreeBranchName.trim(); + if (baseBranch.length === 0 || newBranch.length === 0) return; + void gitActions + .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) + .then((result) => { + if (result === null) return; + setWorktreeBranchName(""); + navigation.goBack(); + }); + }} + /> + + + + {availableBranches.map((branch, index) => { + const disabled = disabledExistingBranches.has(branch.name); + const subtitle = branch.worktreePath + ? branch.worktreePath === currentWorktreePath + ? "Checked out in this thread" + : "Checked out in another worktree" + : branch.isDefault + ? "Default branch" + : "Local branch"; + + return ( + 0} + disabled={!canChangeThreadBranch || busy || disabled} onPress={() => { - const branch = sanitizeFeatureBranchName(newBranchName.trim()); - if (branch.length === 0) return; - void gitActions.onCreateSelectedThreadBranch(branch).then((result) => { + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { if (result === null) return; - setNewBranchName(""); navigation.goBack(); }); }} /> - - - - - New worktree - - {Platform.OS === "android" ? ( - Base branch - ) : null} - - {Platform.OS === "android" ? ( - New branch - ) : null} - - { - const baseBranch = worktreeBaseBranch.trim(); - const newBranch = worktreeBranchName.trim(); - if (baseBranch.length === 0 || newBranch.length === 0) return; - void gitActions - .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) - .then((result) => { - if (result === null) return; - setWorktreeBranchName(""); - navigation.goBack(); - }); - }} - /> - - - - - Existing branches - - {branchesLoading ? ( - - Loading branches... - - ) : null} - {!branchesLoading && availableBranches.length === 0 ? ( - - No local branches found. - - ) : null} - {availableBranches.map((branch) => { - const disabled = disabledExistingBranches.has(branch.name); - const subtitle = branch.worktreePath - ? branch.worktreePath === currentWorktreePath - ? "Checked out in this thread" - : "Checked out in another worktree" - : branch.isDefault - ? "Default branch" - : "Local branch"; - - return ( - { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { - if (result === null) return; - navigation.goBack(); - }); - }} - > - {Platform.OS !== "android" ? ( - - ) : null} - - {branch.name} - - {subtitle} - - ); - })} - -
-
- navigation.goBack()} /> - + ); + })} + + ); } diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 637dab6768cf..fb1f7a19af67 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -1,11 +1,10 @@ -import { ScreenContent } from "../../../components/ui/ScreenContent"; +import { FitSheet } from "../../../components/ui/FitSheet"; import { SheetHeader } from "../../../components/ui/SheetHeader"; import { Button } from "../../../components/ui/Button"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useState } from "react"; -import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import { Pressable, View } from "react-native"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../../components/ui/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/ui/AppText"; @@ -24,8 +23,6 @@ type GitCommitSheetProps = StaticScreenProps<{ export function GitCommitSheet(_props: GitCommitSheetProps) { const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); @@ -66,168 +63,149 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { }); }; - const android = Platform.OS === "android"; - const header = ( - navigation.goBack()} - accessibilityLabel="Cancel commit" - /> - ); - return ( - // Material sheet drags follow the first nested-scrolling native view, and the inline - // header's Compose button is one, so Android renders content first and reverses the column. - navigation.goBack()} + accessibilityLabel="Cancel commit" + /> + } + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + automaticallyAdjustKeyboardInsets + contentContainerClassName="px-5 pt-4 gap-5" > - - - {isDefaultRef ? ( - - You’re on the default branch. You can create a new branch when committing. - - ) : null} - - Commit message - + {isDefaultRef ? ( + + You’re on the default branch. You can create a new branch when committing. + + ) : null} + + Commit message + + + Leave empty to generate a message automatically. + + + + + + Changed files - Leave empty to generate a message automatically. + {selectedFiles.length} of {allFiles.length} selected · + + {formatDiffCount(selectedInsertions)} −{formatDiffCount(selectedDeletions)} - - - - Changed files - - {selectedFiles.length} of {allFiles.length} selected · + - {formatDiffCount(selectedInsertions)} −{formatDiffCount(selectedDeletions)} - - - {allFiles.length > 0 ? ( -