diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 95581e2b22781..a9502b26c8c81 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 3030080d946ce..2f3d385755ca6 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/ci.yml b/.github/workflows/ci.yml index c1c4eea6d054b..ee2b75a983d88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -504,8 +504,9 @@ jobs: T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json T3CODE_TRANSFER_BUDGET_SCENARIOS_PATH: ${{ runner.temp }}/thread-transfer-scenarios.json + # One process per file spends ~2s starting before its tests run; one file per vCPU overlaps that. run: >- - vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} + vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} --concurrency 4 --uncached apps/server/integration/transferBudgetV2.integration.test.ts --verify-hits 1 apps/server diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e85ceec19848d..9132df80d0f58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,8 @@ jobs: # published nightly, so stable only ever ships a build that nightly users # have already run. Scheduled runs also decide here whether a nightly is due. # The PAUSE_NIGHTLY repository variable skips scheduled runs only; every later - # job needs this one, so a skip here skips the whole run. + # job needs this one, so a skip here skips the whole run. `update_feed` runs + # without it and checks the variable itself. resolve_commit: name: Resolve release commit if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'schedule' || vars.PAUSE_NIGHTLY != 'true') @@ -165,14 +166,12 @@ jobs: echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then - # Manual-only test train: exercises the whole release flow for a - # commit end users must never receive. Never scheduled. + # Manual-only test train: exercises the release flow up to a draft + # release for a commit end users must never receive. Never scheduled. # Same versioning as nightly under its own prerelease identifier. - # A preview release is reachable only by asking for it: npm gets it - # under the `preview` dist-tag, which nothing resolves by default, - # its desktop builds carry no update feed, and no updater manifest - # is attached to the release, so neither stable nor nightly - # installs can ever be offered one. + # A preview release stays a draft (see "Publish prerelease"), its + # desktop builds carry no update feed, no updater manifest is + # attached, and npm gets nothing, so no install can be offered one. nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ @@ -183,7 +182,6 @@ jobs: --github-output echo "release_channel=preview" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" else @@ -1038,22 +1036,93 @@ jobs: # The action keeps a stable release a draft until its assets finish uploading, but publishes a # prerelease first. Desktop clients update connected servers from the CLI archives as soon as # they see the release, so a prerelease also goes public only once every asset is attached. + # Previews stay drafts, because a draft has no git tag. releases.atom lists the 10 newest tags, + # a day's `preview` tags can sort above its `nightly` ones, and older updaters find nightlies only there. - name: Publish prerelease - if: needs.preflight.outputs.is_prerelease == 'true' + if: needs.preflight.outputs.is_prerelease == 'true' && needs.preflight.outputs.release_channel != 'preview' env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ needs.preflight.outputs.tag }} run: gh release edit "$RELEASE_TAG" --draft=false + # A nightly install finds updates in releases.atom, which holds the 10 newest git tags of any + # kind, so a burst of other tags hides every nightly. The `update-feed` release is the fixed URL + # newer installs also read. Every unpaused scheduled tick and every nightly repairs it, then fails when + # the feed's first nightly is not the newest one. See docs/operations/release.md#update-feed. + update_feed: + name: Check the update feed + needs: [preflight, release] + if: ${{ !cancelled() && github.repository == 'pingdotgg/t3code' && ((github.event_name == 'schedule' && vars.PAUSE_NIGHTLY != 'true') || needs.preflight.outputs.release_channel == 'nightly') }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + sparse-checkout: .github/scripts + + - id: feed + name: Read the update feed + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + retries: 3 + script: | + const { readNightlyUpdateFeed } = require('./.github/scripts/check-nightly-release.cjs'); + const { tag, pointerTag, feedNightly, feedTags } = await readNightlyUpdateFeed({ github, context }); + if (!tag) return; + core.info(`Newest nightly: ${tag}. Pointer: ${pointerTag}. Feed: ${feedTags?.join(', ')}.`); + core.setOutput('tag', tag); + core.setOutput('pointer_stale', pointerTag !== tag); + core.setOutput('feed', feedTags === undefined ? 'unreadable' : feedNightly === tag ? 'ok' : 'hidden'); + + - name: Point the update feed at the newest nightly + if: steps.feed.outputs.pointer_stale == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ steps.feed.outputs.tag }} + run: | + gh api "repos/$GH_REPO/releases/tags/$RELEASE_TAG" --jq '{tag_name, name, body}' > nightly.json + gh release view update-feed > /dev/null 2>&1 || + gh release create update-feed --prerelease --latest=false --title "Update feed" \ + --notes "The desktop updater reads this release to find the newest nightly. Do not delete it. See docs/operations/release.md." + gh release upload update-feed nightly.json --clobber + + # Every run, because the pointer can be current while the release's flags are wrong. A draft's + # files are not public, and a release marked "latest" is the one stable installs update to. + - name: Keep the update feed release public and never latest + if: steps.feed.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release edit update-feed --draft=false --prerelease --latest=false + + # An unreadable feed says nothing about its tags and the next tick reads it again, so only a + # feed that was read and names another nightly first fails the run. + - name: Fail when the feed hides the newest nightly + if: steps.feed.outputs.tag != '' && steps.feed.outputs.feed != 'ok' + env: + RELEASE_TAG: ${{ steps.feed.outputs.tag }} + FEED: ${{ steps.feed.outputs.feed }} + run: | + if [[ "$FEED" == "unreadable" ]]; then + echo "::warning::releases.atom could not be read, so this run did not check it." + exit 0 + fi + echo "::error::The first nightly in releases.atom is not $RELEASE_TAG. Nightly installs older than 0.1.10-nightly.20261112.4603 cannot reach it, and neither can later builds that do not read the pointer when the feed names an older nightly. A preview from a branch not rebased onto main is the usual cause. See docs/operations/release.md#update-feed." + exit 1 + # The `t3` npm package is a launcher that downloads this version's CLI # archive from the GitHub Release, so it publishes only once that release - # exists. Preview publishes too, under the `preview` dist-tag, which - # nothing resolves unless asked for by name. + # exists. A preview release stays a draft, whose files are not public, so + # a preview never publishes the launcher. publish_cli: name: Publish CLI to npm needs: [preflight, build_bundle, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 permissions: diff --git a/.gitignore b/.gitignore index 07ec2e549a7c5..909d69be15573 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ node_modules/ # pnpm content-addressable store; never belongs in the repo. .pnpm-store/ .vitest/ + +/packages/ssh/.ssh-launcher-test-*/ diff --git a/AGENTS.md b/AGENTS.md index f09e55c2475a5..cc1d27b3e9201 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -150,7 +151,7 @@ For mobile verification, a missing or outdated native client is an install step, - Never make a PR unless the developer explicitly asks you to do so. - PRs target this fork (`TrogonStack/t3code`), never `pingdotgg/t3code`, unless the developer asks for the change to go upstream. -- 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. - UI changes need before/after images. Motion or timing needs a short video. diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 3126d12a2e938..e96e036b0c88b 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"; @@ -688,12 +693,21 @@ describe("DesktopBackendManager", () => { Effect.scoped( Effect.gen(function* () { const reports = yield* Queue.unbounded(); - const startInstance = (status: Ref.Ref, onReady?: Effect.Effect) => + const startInstance = ( + status: Ref.Ref, + onReady?: Effect.Effect, + stdout = "", + ) => makeTestInstance({ spawnerLayer: Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => - Effect.succeed(makeProcess({ exitCode: Effect.never })), + Effect.succeed( + makeProcess({ + exitCode: Effect.never, + stdout: Stream.make(new TextEncoder().encode(stdout)), + }), + ), ), ), httpClientLayer: layerHttpClient((request) => @@ -712,7 +726,30 @@ describe("DesktopBackendManager", () => { yield* Deferred.await(ready); assert.isTrue(Option.isNone(yield* Queue.poll(reports))); - yield* startInstance(yield* Ref.make(503)); + 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)); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index d24b2f52f295c..89ebc9e862ecf 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 c46e0b3a26078..3f7d690eab3d3 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,7 +214,199 @@ 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( + "keeps the update marker after HTTP readiness until the backend publishes its runtime", + () => + Effect.scoped( + 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); + 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.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("leaves a replacement update marker written while an earlier wait is pending", () => + Effect.scoped( + 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.scoped( + 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.scoped( Effect.gen(function* () { @@ -253,7 +458,7 @@ describe("DesktopBackendPool", () => { 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))); + }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), { backend }))); }), ), ); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 82d542b322c84..d69e1ce1427fe 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/preview/AnnotationStroke.test.ts b/apps/desktop/src/preview/AnnotationStroke.test.ts new file mode 100644 index 0000000000000..ad8a21c140b45 --- /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 0000000000000..66510fc0b7cfe --- /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/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts index 34beb7f4c8011..61d3941ba41ab 100644 --- a/apps/desktop/src/preview/PickPreload.test.ts +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -84,3 +84,24 @@ it("keeps comment keystrokes away from the page's capture-phase shortcuts", () = 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 0784b21a5308b..88a7fc467e425 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"; @@ -546,34 +553,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, @@ -910,7 +889,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; @@ -1510,15 +1494,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)); } }; @@ -1537,23 +1524,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 }; } }; @@ -1580,7 +1571,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; @@ -1686,7 +1677,7 @@ function startAnnotation( return; } } - if (event.key === "Escape") { + if (event.key === "Escape" && !event.isComposing) { event.preventDefault(); event.stopPropagation(); teardown(true); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 15c481f488dbe..7ee2ff5dd51c1 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 077cad6161479..db31e0bc112ba 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 0000000000000..d9e928fce186b --- /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 06f9e86a97d5c..b118d15ced674 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 8468a9538630c..d9ae6e76c29c2 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/mobile/deps/README.md b/apps/mobile/deps/README.md index 2abb1858b3c60..e7623faa8fe27 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 0000000000000..74fcb8768906f 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 2dbfd6d796048..0000000000000 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 f1eb3858e9865..0000000000000 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 0000000000000..74c351ddc3371 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 1df8c646d4832..4ac5b47a42f32 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/package.json b/apps/mobile/package.json index 9af752b2d2d47..84f78c7cfc2bd 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 d2577b07c004f..fc4dfdf3a417a 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 f647535a1d42c..7318729511805 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.types.ts b/apps/mobile/src/components/ui/AppBar.types.ts index 88d711be71544..071b0609606e3 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 0000000000000..c635f860e02de --- /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 0000000000000..5a1eb83543024 --- /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 0000000000000..cb6bca78fdcae --- /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 0000000000000..95843c3e07651 --- /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 0000000000000..a797e87a8a65d --- /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 0000000000000..f783dbcd964ae --- /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 0000000000000..a47fb124ea36c --- /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 0000000000000..8f68b99477e10 --- /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 0000000000000..7f3e7a5d80634 --- /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 0000000000000..4ef7015aa98cb --- /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 0000000000000..8f889fd9682d1 --- /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 0000000000000..c09e9f5ce7270 --- /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 0000000000000..00c2ac30f3c6b --- /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 0000000000000..bcc227457b365 --- /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 0000000000000..4a43468729172 --- /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 0000000000000..2f661883ea0ba --- /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 0000000000000..c0e572f534517 --- /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 0000000000000..422635120cc62 --- /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 0000000000000..3b9bc8634133f --- /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 0000000000000..e83dd26633989 --- /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 0000000000000..02b2b648b7bd1 --- /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 0000000000000..2ba628eec1814 --- /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 f93b091cf9b58..9a9f98bd55c14 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 ed8bba58daaa7..643fe5acbb63d 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 d4eac0cb94c1b..29efc04d810df 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 0000000000000..0dd6ac142b4f9 --- /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 0000000000000..a3b8e507445dc --- /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 0000000000000..d9ed545d6bed5 --- /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 0000000000000..7f707a9760927 --- /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 0000000000000..f420964034f49 --- /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 0000000000000..743ff1cae1326 --- /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 0000000000000..63ded41c1514e --- /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 0000000000000..c7f8e2f2d2216 --- /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 f00b64c8e0b70..3a917d67dee4d 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/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index fd3ce42403c8a..4a1c77371b86b 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/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 463c11aaf6485..3913eaa47557b 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 03b6daaa4e715..71de00071fda8 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 73629f02fd253..2d0de616bc698 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 279269b8a13c3..24d82a93daa79 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 36da917e83d5c..6c796848b6c2d 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 11fa0a0c7af46..6de31137fb7d4 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/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index dd95959b30899..26cafcc764950 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"; @@ -78,9 +78,7 @@ export function ThreadFileNavigatorPane(props: { ); return ( - // Material drags target the first scroll view, so place content before the header. - - {fileTree} + - + {fileTree} + ); } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 78b4eca22a47d..df5142c15e243 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 d39bb1381bafa..af25be18bb46f 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 0000000000000..c7703a8655339 --- /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 0000000000000..66f231a546840 --- /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 0000000000000..d571811b33d70 --- /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 0000000000000..16069f051d453 --- /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 0000000000000..2ab23be17a71c --- /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 7a8837b582a11..51bd29234759e 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/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 19ffe072fb58e..3a32e33523611 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 2ee2000d400f0..d4930f4cdecb0 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 9fec5e64df699..f2f9c0242563e 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 5fc9b50097ba2..3612561ede6c6 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/WorkspaceTabsContext.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx index 9ecb6fa933fa8..81344be55e4d2 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 5557432ce4946..f07076a8fb141 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 dfc8e13d162c1..0165634f0a31e 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.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts index 3598894115dc3..3f6754c679c5a 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 ab121d0c41e7d..f495e39cbd264 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 f682363792032..f3c47e2582858 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 d51b88258c293..691a3ebcc8282 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 ec3352b40ea3e..f60a13bd9dea6 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 0b2637b7c6b61..6e69e267e3fef 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 4a4dec4e7a6bc..d0672f064064a 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/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index 85a77a254967f..d79682f944886 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 d5fa085eb18fc..4e500887cffc3 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -2,12 +2,11 @@ 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 { 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 +38,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(); @@ -130,167 +127,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 2673865359f0e..21bf033b761be 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 0000000000000..ac983a81ab1c9 --- /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 5d01de5700eda..7e1379d2b5ff5 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 75b0cd4e91669..d37d405280a0a 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/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 77b845637062e..5026a90e13b9a 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/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 4b77761b600a7..abb35e793edc0 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 0fe4f8e267a2b..1c90d5fa2df1b 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 0793537aa0aea..12640fdb6e7e8 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/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3e7eec1449e26..b36e408c2a5d1 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 39cc885e4d2e3..8e8c1c4955b29 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) { diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index d0045bf5f284e..0388bfb299299 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 209cccf8d2ef0..82f5cc62a7b68 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 da9ba935838c0..90eb97f4e3b8a 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 dd20c0a162f7b..309c67c4999f0 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/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6205fd1281797..8f1463ce1bd85 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 b2d29db8c8c72..436fb4dd0b403 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 6bd008116babb..fef45b5c88003 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 67d0818ee6c4d..0f3d785b41041 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 637dab6768cf8..fb1f7a19af670 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 ? ( -