diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 98c1c3b20..fbcd52e69 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -125,31 +125,29 @@ Do not start, stop, erase, or reconfigure an emulator owned by another task. Tra ## Pair each client once -Issue a fresh credential against the running backend's exact base directory: +Use the bundled helper from the repository root. It issues a fresh credential against the running backend's exact base directory, opens the existing Add Environment route with the credential in an encoded query parameter, and asks that route to connect once: ```bash -T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ - --base-dir \ - --base-url \ - --ttl 15m \ - --label agent-mobile- +.agents/skills/test-t3-mobile/scripts/pair-client.sh \ + ios + +.agents/skills/test-t3-mobile/scripts/pair-client.sh \ + android ``` -In PowerShell, set `$env:T3CODE_PORT = ""` first and run the `node ... auth pairing create` command without the leading assignment. +Run only the command for the selected platform. The helper uses `http://127.0.0.1:` for iOS and `http://10.0.2.2:` for Android. Pass a fifth argument only when testing a non-development URL scheme. -If the visible Add Environment action is not exposed as a semantic target, open the app's registered route instead of guessing coordinates: +The helper opens this registered route: -```bash -xcrun simctl openurl 't3code-dev://connections/new' -adb -s shell am start -W \ - -a android.intent.action.VIEW \ - -d 't3code-dev://connections/new' \ - com.t3tools.t3code.dev +```text +t3code-dev://connections/new?pairingUrl=&autoConnect=1 ``` -Run only the command for the selected platform. +The Add Environment route owns the behavior: `pairingUrl` prefills its normal host and token inputs, while `autoConnect=1` submits once in development builds and returns to Home after success. Without `autoConnect`, the same route only prefills the form for manual inspection. + +Do not enter pairing hosts or tokens through simulator keyboard automation. Xcode's semantic typer sends HID-style key events through the simulator's active keyboard state, which can corrupt uppercase tokens and punctuation even when the host Mac uses a U.S. input source. The one-shot route is the deterministic pairing path. Use the visible form only as a fallback, and paste credentials rather than typing them character by character. -In T3 Code Dev, open Add Environment and enter the complete `` and newly printed `Token`. Verify the expected seeded projects appear before exercising the affected flow. +Verify the expected seeded projects appear before exercising the affected flow. Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. @@ -183,6 +181,8 @@ Keep local verification focused. Do not turn this workflow into a full repositor - **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. - **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. - **A second client cannot pair:** pairing tokens are single-use; issue another token. +- **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`. +- **Pairing text changes case or punctuation:** do not retry semantic typing. Use `scripts/pair-client.sh`; the simulator keyboard layout and HID input path are not reliable for credentials. - **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. - **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. - **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator. diff --git a/.agents/skills/test-t3-mobile/scripts/pair-client.sh b/.agents/skills/test-t3-mobile/scripts/pair-client.sh new file mode 100755 index 000000000..9caa06072 --- /dev/null +++ b/.agents/skills/test-t3-mobile/scripts/pair-client.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $0 [url-scheme]" >&2 + exit 2 +} + +[[ $# -ge 4 && $# -le 5 ]] || usage + +platform="$1" +device_id="$2" +server_port="$3" +base_dir="$4" +url_scheme="${5:-t3code-dev}" + +case "$platform" in + ios) + mobile_origin="http://127.0.0.1:${server_port}" + ;; + android) + mobile_origin="http://10.0.2.2:${server_port}" + ;; + *) + usage + ;; +esac + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +if ! pairing_output="$({ + T3CODE_PORT="$server_port" node apps/server/src/bin.ts auth pairing create \ + --base-dir "$base_dir" \ + --base-url "$mobile_origin" \ + --ttl 15m \ + --label "agent-mobile-${device_id:0:8}" +} 2>&1)"; then + echo "Could not mint a mobile pairing credential." >&2 + exit 1 +fi + +pairing_url="$(printf '%s\n' "$pairing_output" | sed -n 's/^Pair URL: //p' | tail -n 1)" +if [[ -z "$pairing_url" ]]; then + echo "Could not parse the mobile pairing URL." >&2 + exit 1 +fi + +deep_link="$(PAIRING_URL="$pairing_url" URL_SCHEME="$url_scheme" node - <<'NODE' +const query = new URLSearchParams({ + pairingUrl: process.env.PAIRING_URL, + autoConnect: "1", +}); +process.stdout.write(`${process.env.URL_SCHEME}://connections/new?${query}`); +NODE +)" + +case "$platform" in + ios) + xcrun simctl openurl "$device_id" "$deep_link" + ;; + android) + # adb shell re-joins its arguments and evaluates them through the device + # shell, so the deep link's `?`/`&` must be quoted once more for that shell. + adb -s "$device_id" shell \ + "am start -W -a android.intent.action.VIEW -d '$deep_link' com.t3tools.t3code.dev" \ + >/dev/null + ;; +esac + +echo "Opened the existing Add Environment route with a fresh pairing credential." diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 29910f522..71e576e5c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -38,3 +38,4 @@ github:jappyjan github:justsomelegs github:UtkarshUsername github:SunkenInTime +github:bil0000 diff --git a/AGENTS.md b/AGENTS.md index 154259b7e..d6e5ee94f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,6 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Never make a PR unless the developer explicitly asks you to do so. - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. -- **Rebase onto latest main before opening.** Stale branches conflict and burn a review round. - UI changes need before/after images. Motion or timing needs a short video. - One concern per PR. If the description says "also", split it. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. diff --git a/apps/desktop/src/preview/FaviconCapture.test.ts b/apps/desktop/src/preview/FaviconCapture.test.ts new file mode 100644 index 000000000..a18c839a7 --- /dev/null +++ b/apps/desktop/src/preview/FaviconCapture.test.ts @@ -0,0 +1,999 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + MAX_FAVICON_CANDIDATES, + MAX_FAVICON_RESPONSE_BYTES, + captureFavicon, + selectFaviconCandidates, +} from "./FaviconCapture.ts"; + +const PNG = "data:image/png;base64,cG5n"; +const SOURCE_PNG = Buffer.alloc(24); +Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG); +SOURCE_PNG.writeUInt32BE(1, 16); +SOURCE_PNG.writeUInt32BE(1, 20); +const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`; + +function sourceGif( + width: number, + height: number, + frameWidth = width, + frameHeight = height, + additionalFrames: ReadonlyArray<{ + readonly left?: number; + readonly top?: number; + readonly width: number; + readonly height: number; + }> = [], +): Buffer { + const frames = [{ width: frameWidth, height: frameHeight }, ...additionalFrames]; + const buffer = Buffer.alloc(13 + frames.length * 12 + 1); + buffer.write("GIF89a", 0, "ascii"); + buffer.writeUInt16LE(width, 6); + buffer.writeUInt16LE(height, 8); + let offset = 13; + for (const frame of frames) { + buffer[offset] = 0x2c; + buffer.writeUInt16LE(frame.left ?? 0, offset + 1); + buffer.writeUInt16LE(frame.top ?? 0, offset + 3); + buffer.writeUInt16LE(frame.width, offset + 5); + buffer.writeUInt16LE(frame.height, offset + 7); + offset += 10; + buffer[offset] = 2; + buffer[offset + 1] = 0; + offset += 2; + } + buffer[offset] = 0x3b; + return buffer; +} + +function sourceJpeg( + width: number, + height: number, + orientations: number | ReadonlyArray = [], +): Buffer { + const frame = Buffer.from([ + 0xff, + 0xd8, + 0xff, + 0xc0, + 0x00, + 0x07, + 0x08, + height >>> 8, + height & 0xff, + width >>> 8, + width & 0xff, + ]); + const app1Segments = (typeof orientations === "number" ? [orientations] : orientations).map( + (orientation) => sourceJpegExifSegment([orientation]), + ); + return Buffer.concat([frame.subarray(0, 2), ...app1Segments, frame.subarray(2)]); +} + +function sourceJpegApp1Segment(payload: Buffer): Buffer { + const app1 = Buffer.alloc(4 + payload.byteLength); + app1[0] = 0xff; + app1[1] = 0xe1; + app1.writeUInt16BE(payload.byteLength + 2, 2); + payload.copy(app1, 4); + return app1; +} + +function sourceJpegExifSegment( + orientations: ReadonlyArray, + options?: { + readonly byteOrder?: "II" | "MM"; + readonly magic?: number; + readonly padding?: number; + }, +): Buffer { + const exif = Buffer.alloc(20 + orientations.length * 12); + exif.write("Exif\0\0", 0, "binary"); + exif[5] = options?.padding ?? 0; + const littleEndian = options?.byteOrder !== "MM"; + exif.write(littleEndian ? "II" : "MM", 6, "ascii"); + const writeUInt16 = (value: number, offset: number) => + littleEndian ? exif.writeUInt16LE(value, offset) : exif.writeUInt16BE(value, offset); + const writeUInt32 = (value: number, offset: number) => + littleEndian ? exif.writeUInt32LE(value, offset) : exif.writeUInt32BE(value, offset); + writeUInt16(options?.magic ?? 42, 8); + writeUInt32(8, 10); + writeUInt16(orientations.length, 14); + orientations.forEach((orientation, index) => { + const entryOffset = 16 + index * 12; + writeUInt16(0x0112, entryOffset); + writeUInt16(3, entryOffset + 2); + writeUInt32(1, entryOffset + 4); + writeUInt16(orientation, entryOffset + 8); + }); + return sourceJpegApp1Segment(exif); +} + +function sourceJpegWithApp1Segments( + width: number, + height: number, + segments: ReadonlyArray, +): Buffer { + const frame = sourceJpeg(width, height); + return Buffer.concat([frame.subarray(0, 2), ...segments, frame.subarray(2)]); +} + +function sourceJpegWithOrientationEntries( + width: number, + height: number, + orientations: ReadonlyArray, +): Buffer { + return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]); +} + +function sourceJpegWithEndianAlias(alias: number, byteOrder: "II" | "MM"): Buffer { + const exif = sourceJpegExifSegment([6], { byteOrder }); + exif[10] = alias; + exif[11] = alias; + return sourceJpegWithApp1Segments(64, 32, [exif]); +} + +function sourceJpegExifWithSubIfd(options: { + readonly rootOrientation?: number; + readonly subIfdFirst?: boolean; + readonly subIfdOrientation: number; +}): Buffer { + const rootEntries = options.rootOrientation === undefined ? 1 : 2; + const rootIfdOffset = 14; + const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; + const exif = Buffer.alloc(subIfdOffset + 2 + 12 + 4); + exif.write("Exif\0\0", 0, "binary"); + exif.write("II", 6, "ascii"); + exif.writeUInt16LE(42, 8); + exif.writeUInt32LE(8, 10); + exif.writeUInt16LE(rootEntries, rootIfdOffset); + + const writeOrientation = (offset: number, orientation: number) => { + exif.writeUInt16LE(0x0112, offset); + exif.writeUInt16LE(3, offset + 2); + exif.writeUInt32LE(1, offset + 4); + exif.writeUInt16LE(orientation, offset + 8); + }; + const writeSubIfdPointer = (offset: number) => { + exif.writeUInt16LE(0x8769, offset); + exif.writeUInt16LE(4, offset + 2); + exif.writeUInt32LE(1, offset + 4); + exif.writeUInt32LE(subIfdOffset - 6, offset + 8); + }; + + const firstRootEntryOffset = rootIfdOffset + 2; + if (options.rootOrientation === undefined) { + writeSubIfdPointer(firstRootEntryOffset); + } else if (options.subIfdFirst) { + writeSubIfdPointer(firstRootEntryOffset); + writeOrientation(firstRootEntryOffset + 12, options.rootOrientation); + } else { + writeOrientation(firstRootEntryOffset, options.rootOrientation); + writeSubIfdPointer(firstRootEntryOffset + 12); + } + + exif.writeUInt16LE(1, subIfdOffset); + writeOrientation(subIfdOffset + 2, options.subIfdOrientation); + return sourceJpegApp1Segment(exif); +} + +function sourceJpegExifWithSubIfdPointers(options: { + readonly pointerCount: number; + readonly subIfdEntries: number; +}): Buffer { + const { pointerCount, subIfdEntries } = options; + const rootIfdOffset = 14; + const rootEntries = pointerCount + 1; + const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; + const exif = Buffer.alloc(subIfdOffset + 2 + subIfdEntries * 12 + 4); + exif.write("Exif\0\0", 0, "binary"); + exif.write("II", 6, "ascii"); + exif.writeUInt16LE(42, 8); + exif.writeUInt32LE(8, 10); + exif.writeUInt16LE(rootEntries, rootIfdOffset); + for (let index = 0; index < pointerCount; index += 1) { + const entryOffset = rootIfdOffset + 2 + index * 12; + exif.writeUInt16LE(0x8769, entryOffset); + exif.writeUInt16LE(4, entryOffset + 2); + exif.writeUInt32LE(1, entryOffset + 4); + exif.writeUInt32LE(subIfdOffset - 6, entryOffset + 8); + } + const orientationOffset = rootIfdOffset + 2 + pointerCount * 12; + exif.writeUInt16LE(0x0112, orientationOffset); + exif.writeUInt16LE(3, orientationOffset + 2); + exif.writeUInt32LE(1, orientationOffset + 4); + exif.writeUInt16LE(6, orientationOffset + 8); + + exif.writeUInt16LE(subIfdEntries, subIfdOffset); + for (let index = 0; index < subIfdEntries; index += 1) { + const entryOffset = subIfdOffset + 2 + index * 12; + exif.writeUInt16LE(1, entryOffset); + exif.writeUInt16LE(3, entryOffset + 2); + exif.writeUInt32LE(1, entryOffset + 4); + } + return sourceJpegApp1Segment(exif); +} + +function sourceJpegExifWithOverlappingSubIfds(pointerCount: number, subIfdEntries: number): Buffer { + const rootIfdOffset = 14; + const rootEntries = pointerCount + 1; + const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; + const exif = Buffer.alloc(subIfdOffset + pointerCount * 2 + 2 + subIfdEntries * 12); + exif.write("Exif\0\0", 0, "binary"); + exif.write("II", 6, "ascii"); + exif.writeUInt32LE(8, 10); + exif.writeUInt16LE(rootEntries, rootIfdOffset); + for (let index = 0; index < pointerCount; index += 1) { + const entryOffset = rootIfdOffset + 2 + index * 12; + exif.writeUInt16LE(0x8769, entryOffset); + exif.writeUInt16LE(4, entryOffset + 2); + exif.writeUInt32LE(1, entryOffset + 4); + exif.writeUInt32LE(subIfdOffset + index * 2 - 6, entryOffset + 8); + exif.writeUInt16LE(subIfdEntries, subIfdOffset + index * 2); + } + const orientationOffset = rootIfdOffset + 2 + pointerCount * 12; + exif.writeUInt16LE(0x0112, orientationOffset); + exif.writeUInt16LE(3, orientationOffset + 2); + exif.writeUInt32LE(1, orientationOffset + 4); + exif.writeUInt16LE(6, orientationOffset + 8); + return sourceJpegApp1Segment(exif); +} + +function sourceWebp(width: number, height: number): Buffer { + const buffer = Buffer.alloc(30); + buffer.write("RIFF", 0, "ascii"); + buffer.write("WEBP", 8, "ascii"); + buffer.write("VP8X", 12, "ascii"); + buffer.writeUIntLE(width - 1, 24, 3); + buffer.writeUIntLE(height - 1, 27, 3); + return buffer; +} + +function sourceIco(embedded: Buffer): Buffer { + const buffer = Buffer.alloc(22 + embedded.byteLength); + buffer.writeUInt16LE(1, 2); + buffer.writeUInt16LE(1, 4); + buffer.writeUInt32LE(embedded.byteLength, 14); + buffer.writeUInt32LE(22, 18); + embedded.copy(buffer, 22); + return buffer; +} + +function makeUnsafePng(): Buffer { + const buffer = Buffer.from(SOURCE_PNG); + buffer.writeUInt32BE(4096, 16); + buffer.writeUInt32BE(4096, 20); + return buffer; +} + +function sourcePng(width: number, height: number): Buffer { + const buffer = Buffer.from(SOURCE_PNG); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +} + +function makeUnsafeDib(): Buffer { + const buffer = Buffer.alloc(40); + buffer.writeUInt32LE(40, 0); + buffer.writeInt32LE(4096, 4); + buffer.writeInt32LE(4096, 8); + return buffer; +} + +function makeWebContents(options?: { + readonly fetch?: (url: string, init?: RequestInit) => Promise; + readonly rasterize?: (code: string) => Promise; +}) { + const fetch = vi.fn( + options?.fetch ?? + (async () => + new Response(new Uint8Array(SOURCE_PNG), { + headers: { "content-type": "image/png" }, + })), + ); + const executeJavaScriptInIsolatedWorld = vi.fn( + async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => + options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : PNG, + ); + return { + webContents: { + session: { fetch }, + executeJavaScriptInIsolatedWorld, + } as never, + executeJavaScriptInIsolatedWorld, + fetch, + }; +} + +const JPEG_LANDSCAPE_LAYOUT = { + draw: "context.drawImage(bitmap, 0, 8, 32, 16)", + resizeHeight: 16, + resizeWidth: 32, +} as const; +const JPEG_PORTRAIT_LAYOUT = { + draw: "context.drawImage(bitmap, 8, 0, 16, 32)", + resizeHeight: 32, + resizeWidth: 16, +} as const; + +async function expectJpegLayout( + source: Buffer, + layout: typeof JPEG_LANDSCAPE_LAYOUT | typeof JPEG_PORTRAIT_LAYOUT, +): Promise { + const { webContents } = makeWebContents({ + rasterize: async (code) => { + expect(code).toContain(`resizeWidth: ${layout.resizeWidth}`); + expect(code).toContain(`resizeHeight: ${layout.resizeHeight}`); + expect(code).toContain(layout.draw); + return PNG; + }, + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:image/jpeg;base64,${source.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); +} + +describe("selectFaviconCandidates", () => { + it("filters and deduplicates before applying the candidate cap", () => { + const valid = Array.from( + { length: MAX_FAVICON_CANDIDATES + 2 }, + (_, index) => `https://example.com/favicon-${index}.png`, + ); + expect( + selectFaviconCandidates([ + ...Array.from({ length: 64 }, () => "javascript:alert(1)"), + valid[0]!, + valid[0]!, + ...valid.slice(1), + ]), + ).toEqual(valid.slice(0, MAX_FAVICON_CANDIDATES)); + }); + + it("bounds raw candidate scanning independently of the usable-candidate cap", () => { + const oversizedInvalid = `javascript:${"x".repeat(2_048)}`; + expect( + selectFaviconCandidates([ + ...Array.from({ length: 128 }, () => oversizedInvalid), + "https://example.com/too-late.png", + ]), + ).toEqual([]); + }); +}); + +describe("captureFavicon", () => { + it.each([ + { + label: "same-origin", + pageUrl: "https://example.com/page", + faviconUrl: "https://example.com/favicon.png", + credentials: "include", + }, + { + label: "cross-origin", + pageUrl: "https://example.com/page", + faviconUrl: "https://cdn.example.net/favicon.png", + credentials: "omit", + }, + ])("uses the explicit credential policy for $label requests", async (testCase) => { + const { webContents, fetch } = makeWebContents(); + const result = await captureFavicon({ + webContents, + pageUrl: testCase.pageUrl, + candidates: [testCase.faviconUrl], + signal: new AbortController().signal, + }); + + expect(result).toEqual({ kind: "captured", dataUrl: PNG }); + expect(fetch).toHaveBeenCalledWith( + testCase.faviconUrl, + expect.objectContaining({ credentials: testCase.credentials, redirect: "error" }), + ); + }); + + it("decodes base64 and percent-encoded inline images without fetching", async () => { + const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents(); + const percentEncodedPng = [...SOURCE_PNG] + .map((byte) => `%${byte.toString(16).padStart(2, "0")}`) + .join(""); + + for (const candidate of [SOURCE_PNG_URL, `data:image/png,${percentEncodedPng}`]) { + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [candidate], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + } + + expect(fetch).not.toHaveBeenCalled(); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2); + }); + + it("tries the next candidate after an ordinary rejection", async () => { + const { webContents, fetch } = makeWebContents({ + fetch: async (url) => + url.endsWith("first.png") + ? new Response(null, { status: 404 }) + : new Response(new Uint8Array(SOURCE_PNG), { + headers: { "content-type": "image/png" }, + }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/first.png", "https://example.com/second.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("cancels a rejected response body before trying the next candidate", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1)); + }, + cancel, + }); + const { webContents, fetch } = makeWebContents({ + fetch: async (url) => + url.endsWith("first.png") + ? new Response(body, { status: 404 }) + : new Response(new Uint8Array(SOURCE_PNG), { + headers: { "content-type": "image/png" }, + }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/first.png", "https://example.com/second.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + expect(cancel).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("stops a pending fetch when its capture is aborted", async () => { + const controller = new AbortController(); + const { webContents } = makeWebContents({ + fetch: (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }), + }); + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: controller.signal, + }); + controller.abort(); + expect(await capture).toEqual({ kind: "none" }); + }); + + it("ends candidate fallback when the overall capture deadline expires", async () => { + const timeoutController = new AbortController(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + const { webContents, fetch } = makeWebContents({ + fetch: (url, init) => { + if (url.endsWith("first.png")) return Promise.resolve(new Response(null, { status: 404 })); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }); + }, + }); + try { + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [ + "https://example.com/first.png", + "https://example.com/second.png", + "https://example.com/third.png", + ], + signal: new AbortController().signal, + }); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); + + expect(await capture).toEqual({ kind: "timed-out" }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(timeout).toHaveBeenCalledOnce(); + } finally { + timeout.mockRestore(); + } + }); + + it("does not publish a rasterization that completes after the capture deadline", async () => { + const captureTimeoutController = new AbortController(); + const rasterTimeoutController = new AbortController(); + const timeout = vi + .spyOn(AbortSignal, "timeout") + .mockImplementation((milliseconds) => + milliseconds === 5_000 ? captureTimeoutController.signal : rasterTimeoutController.signal, + ); + let resolveRasterization!: (value: unknown) => void; + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ + rasterize: () => + new Promise((resolve) => { + resolveRasterization = resolve; + }), + }); + try { + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }); + await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce()); + captureTimeoutController.abort(new DOMException("capture timed out", "TimeoutError")); + + expect(await capture).toEqual({ kind: "timed-out" }); + resolveRasterization(PNG); + } finally { + timeout.mockRestore(); + } + }); + + it("cancels a stalled response body when the capture deadline expires", async () => { + const timeoutController = new AbortController(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + const cancel = vi.fn(); + const { webContents } = makeWebContents({ + fetch: async () => + new Response( + new ReadableStream({ + cancel, + }), + { headers: { "content-type": "image/png" } }, + ), + }); + try { + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: new AbortController().signal, + }); + timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); + + expect(await capture).toEqual({ kind: "timed-out" }); + expect(cancel).toHaveBeenCalledOnce(); + } finally { + timeout.mockRestore(); + } + }); + + it("rejects and cancels an oversized streamed response", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MAX_FAVICON_RESPONSE_BYTES)); + controller.enqueue(new Uint8Array(1)); + }, + cancel, + }); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ + fetch: async () => new Response(body, { headers: { "content-type": "image/png" } }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(cancel).toHaveBeenCalledOnce(); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("retains bounded compatibility with common favicon formats", async () => { + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + for (const [mime, buffer] of [ + ["image/gif", sourceGif(32, 32)], + ["image/jpeg", sourceJpeg(32, 32)], + ["image/webp", sourceWebp(32, 32)], + ["image/x-icon", sourceIco(SOURCE_PNG)], + ] as const) { + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:${mime};base64,${buffer.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + } + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(4); + }); + + it.each([ + { + label: "landscape", + source: sourcePng(64, 32), + resizeWidth: 32, + resizeHeight: 16, + draw: "context.drawImage(bitmap, 0, 8, 32, 16)", + }, + { + label: "portrait", + source: sourcePng(32, 64), + resizeWidth: 16, + resizeHeight: 32, + draw: "context.drawImage(bitmap, 8, 0, 16, 32)", + }, + ])("preserves $label aspect ratio within the 32x32 output", async (testCase) => { + const { webContents } = makeWebContents({ + rasterize: async (code) => { + expect(code).toContain(`resizeWidth: ${testCase.resizeWidth}`); + expect(code).toContain(`resizeHeight: ${testCase.resizeHeight}`); + expect(code).toContain('resizeQuality: "high"'); + expect(code).toContain(testCase.draw); + return PNG; + }, + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:image/png;base64,${testCase.source.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "captured", dataUrl: PNG }); + }); + + it.each([ + ...[1, 2, 3, 4].map((orientation) => ({ + label: `keeps stored dimensions for orientation ${orientation}`, + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpeg(64, 32, orientation), + })), + ...[5, 6, 7, 8].map((orientation) => ({ + label: `uses display dimensions for orientation ${orientation}`, + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpeg(64, 32, orientation), + })), + { + label: "uses the first separate EXIF segment when it is transposed", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpeg(64, 32, [6, 1]), + }, + { + label: "uses the first separate EXIF segment when it is untransposed", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpeg(64, 32, [1, 6]), + }, + { + label: "does not consult a later EXIF segment after an invalid orientation", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpeg(64, 32, [9, 6]), + }, + { + label: "uses a later valid orientation in the same IFD", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithOrientationEntries(64, 32, [9, 6]), + }, + { + label: "skips a non-EXIF APP1 segment", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegApp1Segment(Buffer.from("not-exif")), + sourceJpegExifSegment([6]), + ]), + }, + { + label: "skips an empty EXIF APP1 segment", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegApp1Segment(Buffer.from("Exif\0\0", "binary")), + sourceJpegExifSegment([6]), + ]), + }, + { + label: "stops after a malformed qualifying EXIF APP1 segment", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegApp1Segment(Buffer.from("Exif\0\0broken", "binary")), + sourceJpegExifSegment([6]), + ]), + }, + { + label: "ignores the EXIF padding byte", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { padding: 0xff })]), + }, + { + label: "reads big-endian EXIF", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { byteOrder: "MM" })]), + }, + { + label: "matches Chromium for a nonstandard TIFF magic field", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { magic: 0 })]), + }, + { + label: "rejects a high-bit little-endian alias", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpegWithEndianAlias(0xc9, "II"), + }, + { + label: "rejects a high-bit big-endian alias", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpegWithEndianAlias(0xcd, "MM"), + }, + { + label: "reads an orientation from a SubIFD", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithSubIfd({ subIfdOrientation: 6 }), + ]), + }, + { + label: "uses a SubIFD orientation before a later root orientation", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithSubIfd({ + rootOrientation: 1, + subIfdFirst: true, + subIfdOrientation: 6, + }), + ]), + }, + { + label: "uses a root orientation before a later SubIFD orientation", + layout: JPEG_LANDSCAPE_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithSubIfd({ rootOrientation: 1, subIfdOrientation: 6 }), + ]), + }, + { + label: "memoizes repeated aliases to the same SubIFD", + layout: JPEG_PORTRAIT_LAYOUT, + source: sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithSubIfdPointers({ pointerCount: 32, subIfdEntries: 32 }), + ]), + }, + ])("matches Chromium JPEG layout: $label", async ({ source, layout }) => { + await expectJpegLayout(source, layout); + }); + + it("rejects JPEG metadata when distinct SubIFDs exhaust the linear work budget", async () => { + const source = sourceJpegWithApp1Segments(64, 32, [ + sourceJpegExifWithOverlappingSubIfds(32, 32), + ]); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:image/jpeg;base64,${source.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("rejects JPEGs with multiple frame headers before rasterization", async () => { + const buffer = Buffer.concat([sourceJpeg(4096, 4096), sourceJpeg(1, 1).subarray(2)]); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [`data:image/jpeg;base64,${buffer.toString("base64")}`], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("rejects an unsafe PNG size before rasterization", async () => { + const buffer = makeUnsafePng(); + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ + fetch: async () => + new Response(new Uint8Array(buffer), { + headers: { "content-type": "image/png" }, + }), + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/favicon.png"], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it.each([ + ["GIF", "image/gif", sourceGif(4096, 4096)], + ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)], + ["GIF later frame", "image/gif", sourceGif(1, 1, 1, 1, [{ width: 4096, height: 4096 }])], + [ + "GIF cumulative frames", + "image/gif", + sourceGif( + 64, + 64, + 64, + 64, + Array.from({ length: 256 }, () => ({ width: 64, height: 64 })), + ), + ], + ["JPEG", "image/jpeg", sourceJpeg(4096, 4096)], + ["WebP", "image/webp", sourceWebp(4096, 4096)], + ["ICO with PNG", "image/x-icon", sourceIco(makeUnsafePng())], + ["ICO with DIB", "image/x-icon", sourceIco(makeUnsafeDib())], + ["SVG", "image/svg+xml", Buffer.from('')], + [ + "SVG with embedded bitmap", + "image/svg+xml", + Buffer.from( + ``, + ), + ], + [ + "ICO invalid payload span", + "image/x-icon", + (() => { + const buffer = Buffer.alloc(22); + buffer.writeUInt16LE(1, 2); + buffer.writeUInt16LE(1, 4); + buffer.writeUInt32LE(100, 14); + buffer.writeUInt32LE(22, 18); + return buffer; + })(), + ], + ])("rejects unsafe or unsupported %s before rasterization", async (_label, mime, buffer) => { + const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); + const candidate = `data:${mime};base64,${buffer.toString("base64")}`; + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [candidate], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); + }); + + it("ignores output that is not a bounded PNG data URL", async () => { + const { webContents } = makeWebContents({ + rasterize: async () => "data:image/svg+xml;base64,c3Zn", + }); + + expect( + await captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }), + ).toEqual({ kind: "none" }); + }); + + it("waits for physical rasterization settlement after a logical timeout", async () => { + vi.useFakeTimers(); + try { + let resolveOld!: (value: unknown) => void; + let executions = 0; + const { webContents } = makeWebContents({ + rasterize: () => { + executions += 1; + return executions === 1 + ? new Promise((resolve) => { + resolveOld = resolve; + }) + : Promise.resolve(PNG); + }, + }); + const input = { + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }; + const timedOut = captureFavicon(input); + await vi.advanceTimersByTimeAsync(1_001); + expect(await timedOut).toEqual({ kind: "timed-out" }); + + const newer = captureFavicon(input); + await Promise.resolve(); + expect(executions).toBe(1); + resolveOld(PNG); + expect(await newer).toEqual({ kind: "captured", dataUrl: PNG }); + expect(executions).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("ends candidate fallback after a rasterization timeout", async () => { + vi.useFakeTimers(); + try { + let resolveRasterization!: (value: unknown) => void; + const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents({ + rasterize: () => + new Promise((resolve) => { + resolveRasterization = resolve; + }), + }); + const capture = captureFavicon({ + webContents, + pageUrl: "https://example.com/page", + candidates: ["https://example.com/first.png", "https://example.com/second.png"], + signal: new AbortController().signal, + }); + + await vi.advanceTimersByTimeAsync(1_001); + + expect(await capture).toEqual({ kind: "timed-out" }); + expect(fetch).toHaveBeenCalledOnce(); + expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce(); + resolveRasterization(PNG); + } finally { + vi.useRealTimers(); + } + }); + + it("coalesces queued rasterizations so only the latest pending capture launches", async () => { + let resolveFirst!: (value: unknown) => void; + let executions = 0; + const { webContents } = makeWebContents({ + rasterize: () => { + executions += 1; + return executions === 1 + ? new Promise((resolve) => { + resolveFirst = resolve; + }) + : Promise.resolve(PNG); + }, + }); + const input = { + webContents, + pageUrl: "https://example.com/page", + candidates: [SOURCE_PNG_URL], + signal: new AbortController().signal, + }; + const first = captureFavicon(input); + const superseded = captureFavicon(input); + const newest = captureFavicon(input); + + expect(executions).toBe(1); + resolveFirst(PNG); + expect(await first).toEqual({ kind: "captured", dataUrl: PNG }); + expect(await superseded).toEqual({ kind: "none" }); + expect(await newest).toEqual({ kind: "captured", dataUrl: PNG }); + expect(executions).toBe(2); + }); +}); diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts new file mode 100644 index 000000000..c72662822 --- /dev/null +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -0,0 +1,679 @@ +import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts"; + +export const MAX_FAVICON_RESPONSE_BYTES = 100_000; +export const MAX_FAVICON_CANDIDATES = 8; +export const MAX_FAVICON_HTTP_URL_LENGTH = 2_048; + +const MAX_FAVICON_CANDIDATE_INPUT_UNITS = 262_144; +const MIN_FAVICON_CANDIDATE_INPUT_UNITS = 256; +const MAX_FAVICON_SOURCE_PIXELS = 1_048_576; +const MAX_FAVICON_INLINE_URL_LENGTH = Math.ceil((MAX_FAVICON_RESPONSE_BYTES * 4) / 3) + 128; +const FAVICON_CAPTURE_TIMEOUT_MS = 5_000; +const FAVICON_RASTER_WORLD_ID = 1001; +const FAVICON_RASTER_TIMEOUT_MS = 1_000; +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +interface RasterizationGate { + generation: number; + launchAllowed?: Promise; +} + +const rasterizationGates = new WeakMap(); + +async function waitForRasterLaunch(previous: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return; + await new Promise((resolve) => { + const finish = () => { + signal.removeEventListener("abort", finish); + resolve(); + }; + signal.addEventListener("abort", finish, { once: true }); + void previous.then(finish); + }); +} + +export type FaviconCaptureResult = + | { readonly kind: "captured"; readonly dataUrl: string } + | { readonly kind: "none" } + | { readonly kind: "timed-out" }; + +type RasterizationResult = + | { readonly kind: "completed"; readonly value: unknown } + | { readonly kind: "timed-out" }; + +export function safeHttpOrigin(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null; + } catch { + return null; + } +} + +export function selectFaviconCandidates(candidates: ReadonlyArray): ReadonlyArray { + const selected: string[] = []; + const seen = new Set(); + let inputUnits = 0; + for (const candidate of candidates) { + // Charge a minimum per entry so a large array of tiny malformed values is bounded too. + inputUnits += Math.max(MIN_FAVICON_CANDIDATE_INPUT_UNITS, candidate.length); + if (inputUnits > MAX_FAVICON_CANDIDATE_INPUT_UNITS) break; + if (!isSupportedFaviconUrl(candidate) || seen.has(candidate)) continue; + seen.add(candidate); + selected.push(candidate); + if (selected.length === MAX_FAVICON_CANDIDATES) break; + } + return selected; +} + +export async function captureFavicon(input: { + readonly webContents: Electron.WebContents; + readonly pageUrl: string; + readonly candidates: ReadonlyArray; + readonly signal: AbortSignal; +}): Promise { + const pageOrigin = safeHttpOrigin(input.pageUrl); + if (!pageOrigin) return { kind: "none" }; + const captureTimeout = AbortSignal.timeout(FAVICON_CAPTURE_TIMEOUT_MS); + const captureSignal = AbortSignal.any([input.signal, captureTimeout]); + + for (const candidate of selectFaviconCandidates(input.candidates)) { + if (captureSignal.aborted) { + return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" }; + } + const captured = await captureCandidate({ + webContents: input.webContents, + pageOrigin, + candidate, + signal: captureSignal, + }); + if (captureSignal.aborted) { + return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" }; + } + if (captured.kind === "captured" || captured.kind === "timed-out") return captured; + } + + return { kind: "none" }; +} + +async function captureCandidate(input: { + readonly webContents: Electron.WebContents; + readonly pageOrigin: string; + readonly candidate: string; + readonly signal: AbortSignal; +}): Promise { + try { + const inline = parseInlineFavicon(input.candidate); + if (inline) { + return await normalizeFaviconBuffer( + input.webContents, + inline.mime, + inline.buffer, + input.signal, + ); + } + + const candidateOrigin = safeHttpOrigin(input.candidate); + if (!candidateOrigin) return { kind: "none" }; + const response = await input.webContents.session.fetch(input.candidate, { + credentials: candidateOrigin === input.pageOrigin ? "include" : "omit", + redirect: "error", + signal: input.signal, + }); + if (!response.ok) { + await response.body?.cancel(); + return { kind: "none" }; + } + const buffer = await readFaviconResponse(response, input.signal); + if (!buffer || input.signal.aborted) return { kind: "none" }; + const mime = response.headers.get("content-type")?.split(";", 1)[0] ?? null; + return await normalizeFaviconBuffer(input.webContents, mime, buffer, input.signal); + } catch { + return { kind: "none" }; + } +} + +async function readFaviconResponse( + response: Response, + signal: AbortSignal, +): Promise { + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_FAVICON_RESPONSE_BYTES) { + await response.body?.cancel(); + return null; + } + if (!response.body) { + const buffer = Buffer.from(await response.arrayBuffer()); + return buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES ? buffer : null; + } + + const reader = response.body.getReader(); + const cancelForAbort = () => { + void reader.cancel(signal.reason).catch(() => undefined); + }; + signal.addEventListener("abort", cancelForAbort, { once: true }); + if (signal.aborted) cancelForAbort(); + const chunks: Buffer[] = []; + let byteLength = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) return Buffer.concat(chunks, byteLength); + byteLength += next.value.byteLength; + if (byteLength > MAX_FAVICON_RESPONSE_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(Buffer.from(next.value)); + } + } finally { + signal.removeEventListener("abort", cancelForAbort); + reader.releaseLock(); + } +} + +function isSupportedFaviconUrl(url: string): boolean { + if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return false; + if (/^data:/i.test(url)) return /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,/i.test(url); + try { + const protocol = new URL(url).protocol; + return ( + (protocol === "http:" || protocol === "https:") && url.length <= MAX_FAVICON_HTTP_URL_LENGTH + ); + } catch { + return false; + } +} + +function decodeInlineFaviconPayload(payload: string): Buffer | null { + const decoded = Buffer.allocUnsafe(Buffer.byteLength(payload)); + let inputOffset = 0; + let outputOffset = 0; + while (inputOffset < payload.length) { + const escapeOffset = payload.indexOf("%", inputOffset); + const literalEnd = escapeOffset === -1 ? payload.length : escapeOffset; + outputOffset += decoded.write(payload.slice(inputOffset, literalEnd), outputOffset, "utf8"); + if (escapeOffset === -1) break; + const hex = payload.slice(escapeOffset + 1, escapeOffset + 3); + if (!/^[0-9a-f]{2}$/i.test(hex)) return null; + decoded[outputOffset] = Number.parseInt(hex, 16); + outputOffset += 1; + inputOffset = escapeOffset + 3; + } + return decoded.subarray(0, outputOffset); +} + +function parseInlineFavicon( + url: string, +): { readonly buffer: Buffer; readonly mime: string } | null { + if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return null; + const match = /^data:(image\/[a-z0-9.+-]+)((?:;[^,]*)?),(.*)$/is.exec(url); + if (!match) return null; + const mime = match[1]?.toLowerCase(); + const parameters = match[2] + ?.split(";") + .filter(Boolean) + .map((parameter) => parameter.toLowerCase()); + const payload = match[3]; + if (!mime || !parameters || !payload) return null; + const base64 = parameters.at(-1) === "base64"; + if (parameters.includes("base64") && !base64) return null; + + let buffer: Buffer; + try { + if (base64) { + if (!/^[a-z0-9+/]*={0,2}$/i.test(payload) || payload.length % 4 === 1) return null; + buffer = Buffer.from(payload, "base64"); + if (buffer.toString("base64").replace(/=+$/, "") !== payload.replace(/=+$/, "")) { + return null; + } + } else { + const decoded = decodeInlineFaviconPayload(payload); + if (!decoded) return null; + buffer = decoded; + } + } catch { + return null; + } + + return buffer.byteLength > 0 && buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES + ? { buffer, mime } + : null; +} + +interface ImageDimensions { + readonly width: number; + readonly height: number; +} + +function safeDimensions(dimensions: ImageDimensions | null): dimensions is ImageDimensions { + return ( + dimensions !== null && + Number.isSafeInteger(dimensions.width) && + Number.isSafeInteger(dimensions.height) && + dimensions.width > 0 && + dimensions.height > 0 && + dimensions.width * dimensions.height <= MAX_FAVICON_SOURCE_PIXELS + ); +} + +function pngDimensions(buffer: Buffer): ImageDimensions | null { + if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || buffer.byteLength < 24) { + return null; + } + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null { + let offset = startOffset; + while (offset < buffer.byteLength) { + const blockLength = buffer[offset]!; + offset += 1; + if (blockLength === 0) return offset; + if (offset + blockLength > buffer.byteLength) return null; + offset += blockLength; + } + return null; +} + +function gifDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) { + return null; + } + const logicalWidth = buffer.readUInt16LE(6); + const logicalHeight = buffer.readUInt16LE(8); + if (!safeDimensions({ width: logicalWidth, height: logicalHeight })) return null; + const packed = buffer[10]!; + let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1)); + if (offset > buffer.byteLength) return null; + let width = logicalWidth; + let height = logicalHeight; + let frameCount = 0; + let framePixels = 0; + while (offset < buffer.byteLength) { + const marker = buffer[offset]; + if (marker === 0x3b) return frameCount > 0 ? { width, height } : null; + if (marker === 0x2c) { + if (offset + 10 > buffer.byteLength) return null; + const left = buffer.readUInt16LE(offset + 1); + const top = buffer.readUInt16LE(offset + 3); + const frameWidth = buffer.readUInt16LE(offset + 5); + const frameHeight = buffer.readUInt16LE(offset + 7); + if (frameWidth === 0 || frameHeight === 0) return null; + framePixels += frameWidth * frameHeight; + if (framePixels > MAX_FAVICON_SOURCE_PIXELS) return null; + width = Math.max(width, left + frameWidth); + height = Math.max(height, top + frameHeight); + if (!safeDimensions({ width, height })) return null; + const framePacked = buffer[offset + 9]!; + offset += 10; + if ((framePacked & 0x80) !== 0) { + offset += 3 * 2 ** ((framePacked & 0x07) + 1); + } + if (offset >= buffer.byteLength) return null; + const minimumCodeSize = buffer[offset]!; + if (minimumCodeSize < 2 || minimumCodeSize > 8) return null; + offset += 1; + const nextOffset = skipGifSubBlocks(buffer, offset); + if (nextOffset === null) return null; + offset = nextOffset; + frameCount += 1; + continue; + } + if (marker !== 0x21 || offset + 2 > buffer.byteLength) return null; + const nextOffset = skipGifSubBlocks(buffer, offset + 2); + if (nextOffset === null) return null; + offset = nextOffset; + } + return null; +} + +interface JpegExifMetadata { + readonly complete: boolean; + readonly orientation: number | null; +} + +function jpegExifMetadata(segment: Buffer): JpegExifMetadata | null { + if (segment.byteLength <= 6 || segment.subarray(0, 5).toString("binary") !== "Exif\0") { + return null; + } + const metadataWithoutOrientation = (): JpegExifMetadata => ({ + complete: true, + orientation: null, + }); + if (segment.byteLength < 14) return metadataWithoutOrientation(); + const tiffOffset = 6; + const littleEndian = segment[tiffOffset] === 0x49 && segment[tiffOffset + 1] === 0x49; + const bigEndian = segment[tiffOffset] === 0x4d && segment[tiffOffset + 1] === 0x4d; + if (!littleEndian && !bigEndian) return metadataWithoutOrientation(); + const readUInt16 = (offset: number): number | null => { + if (offset < 0 || offset + 2 > segment.byteLength) return null; + return littleEndian ? segment.readUInt16LE(offset) : segment.readUInt16BE(offset); + }; + const readUInt32 = (offset: number): number | null => { + if (offset < 0 || offset + 4 > segment.byteLength) return null; + return littleEndian ? segment.readUInt32LE(offset) : segment.readUInt32BE(offset); + }; + const relativeIfdOffset = readUInt32(tiffOffset + 4); + if (relativeIfdOffset === null) return metadataWithoutOrientation(); + // Keep untrusted metadata parsing linear even when IFD pointers overlap. + let remainingIfdEntryVisits = Math.ceil(segment.byteLength / 12); + const budgetExhausted = Symbol("ifd-entry-budget-exhausted"); + type IfdOrientation = number | null | typeof budgetExhausted; + const subIfdOrientationByOffset = new Map(); + const readIfdOrientation = (ifdOffset: number, isRoot: boolean): IfdOrientation => { + if (!isRoot && subIfdOrientationByOffset.has(ifdOffset)) { + return subIfdOrientationByOffset.get(ifdOffset) ?? null; + } + const entryCount = readUInt16(ifdOffset); + if (entryCount === null) return null; + let result: IfdOrientation = null; + for (let index = 0; index < entryCount; index += 1) { + if (remainingIfdEntryVisits === 0) return budgetExhausted; + remainingIfdEntryVisits -= 1; + const entryOffset = ifdOffset + 2 + index * 12; + if (entryOffset + 12 > segment.byteLength) break; + const tag = readUInt16(entryOffset); + const type = readUInt16(entryOffset + 2); + const count = readUInt32(entryOffset + 4); + if (tag === 0x0112 && type === 3 && count === 1) { + const orientation = readUInt16(entryOffset + 8); + if (orientation !== null && orientation >= 1 && orientation <= 8) { + result = orientation; + break; + } + } else if (isRoot && tag === 0x8769 && type === 4 && count === 1) { + const relativeSubIfdOffset = readUInt32(entryOffset + 8); + if (relativeSubIfdOffset !== null) { + const orientation = readIfdOrientation(tiffOffset + relativeSubIfdOffset, false); + if (orientation === budgetExhausted) return budgetExhausted; + if (orientation !== null) { + result = orientation; + break; + } + } + } + } + if (!isRoot) subIfdOrientationByOffset.set(ifdOffset, result); + return result; + }; + const orientation = readIfdOrientation(tiffOffset + relativeIfdOffset, true); + return orientation === budgetExhausted + ? { complete: false, orientation: null } + : { complete: true, orientation }; +} + +function jpegDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.byteLength < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null; + const startOfFrameMarkers = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, + ]); + let offset = 2; + let dimensions: ImageDimensions | null = null; + let exifMetadata: JpegExifMetadata | null = null; + while (offset + 3 < buffer.byteLength) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + while (buffer[offset] === 0xff) offset += 1; + const marker = buffer[offset]; + offset += 1; + if (marker === undefined || marker === 0xd9 || marker === 0xda) break; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue; + if (offset + 1 >= buffer.byteLength) return null; + const length = buffer.readUInt16BE(offset); + if (length < 2 || offset + length > buffer.byteLength) return null; + if (marker === 0xe1 && exifMetadata === null) { + exifMetadata = jpegExifMetadata(buffer.subarray(offset + 2, offset + length)); + } + if (startOfFrameMarkers.has(marker)) { + if (length < 7) return null; + if (dimensions !== null) return null; + dimensions = { + height: buffer.readUInt16BE(offset + 3), + width: buffer.readUInt16BE(offset + 5), + }; + } + offset += length; + } + if (!dimensions) return null; + if (exifMetadata?.complete === false) return null; + const orientation = exifMetadata?.orientation; + return orientation !== undefined && orientation !== null && orientation >= 5 && orientation <= 8 + ? { width: dimensions.height, height: dimensions.width } + : dimensions; +} + +function webpDimensions(buffer: Buffer): ImageDimensions | null { + if ( + buffer.byteLength < 30 || + buffer.subarray(0, 4).toString("ascii") !== "RIFF" || + buffer.subarray(8, 12).toString("ascii") !== "WEBP" + ) { + return null; + } + const kind = buffer.subarray(12, 16).toString("ascii"); + if (kind === "VP8X") { + return { + width: 1 + buffer.readUIntLE(24, 3), + height: 1 + buffer.readUIntLE(27, 3), + }; + } + if (kind === "VP8 " && buffer.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) { + return { + width: buffer.readUInt16LE(26) & 0x3fff, + height: buffer.readUInt16LE(28) & 0x3fff, + }; + } + if (kind === "VP8L" && buffer[20] === 0x2f) { + return { + width: 1 + buffer[21]! + ((buffer[22]! & 0x3f) << 8), + height: 1 + (buffer[22]! >> 6) + (buffer[23]! << 2) + ((buffer[24]! & 0x0f) << 10), + }; + } + return null; +} + +function dibDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.byteLength < 12) return null; + const headerSize = buffer.readUInt32LE(0); + if (headerSize === 12) { + return { + width: buffer.readUInt16LE(4), + height: buffer.readUInt16LE(6), + }; + } + if (headerSize < 40 || buffer.byteLength < 12) return null; + return { + width: Math.abs(buffer.readInt32LE(4)), + height: Math.abs(buffer.readInt32LE(8)), + }; +} + +function icoDimensions(buffer: Buffer): ImageDimensions | null { + if ( + buffer.byteLength < 22 || + buffer.readUInt16LE(0) !== 0 || + (buffer.readUInt16LE(2) !== 1 && buffer.readUInt16LE(2) !== 2) + ) { + return null; + } + const count = buffer.readUInt16LE(4); + if (count === 0 || count > 256 || buffer.byteLength < 6 + count * 16) return null; + let width = 0; + let height = 0; + for (let index = 0; index < count; index += 1) { + const offset = 6 + index * 16; + width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!); + height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!); + if (!safeDimensions({ width, height })) return null; + const byteLength = buffer.readUInt32LE(offset + 8); + const imageOffset = buffer.readUInt32LE(offset + 12); + if ( + byteLength === 0 || + imageOffset < 6 + count * 16 || + imageOffset > buffer.byteLength || + byteLength > buffer.byteLength - imageOffset + ) + return null; + const embedded = buffer.subarray(imageOffset, imageOffset + byteLength); + const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded); + if (!safeDimensions(embeddedDimensions)) return null; + } + return { width, height }; +} + +function sourceDimensions(buffer: Buffer): ImageDimensions | null { + return ( + pngDimensions(buffer) ?? + gifDimensions(buffer) ?? + jpegDimensions(buffer) ?? + webpDimensions(buffer) ?? + icoDimensions(buffer) + ); +} + +async function normalizeFaviconBuffer( + webContents: Electron.WebContents, + mime: string | null, + buffer: Buffer, + signal: AbortSignal, +): Promise { + const declaredMime = mime?.trim().toLowerCase() || null; + const normalizedMime = + declaredMime === "application/x-icon" + ? "image/x-icon" + : declaredMime === "application/octet-stream" || declaredMime === "binary/octet-stream" + ? null + : declaredMime; + const dimensions = sourceDimensions(buffer); + if ( + (normalizedMime !== null && !/^image\/[a-z0-9.+-]+$/i.test(normalizedMime)) || + normalizedMime === "image/svg+xml" || + buffer.byteLength > MAX_FAVICON_RESPONSE_BYTES || + !safeDimensions(dimensions) + ) { + return { kind: "none" }; + } + + const rasterized = await rasterizeFavicon( + webContents, + normalizedMime, + buffer, + dimensions, + signal, + ); + if (rasterized.kind === "timed-out") return rasterized; + return typeof rasterized.value === "string" && + rasterized.value.startsWith("data:image/png;base64,") && + rasterized.value.length <= FAVICON_DATA_URL_MAX_LENGTH + ? { kind: "captured", dataUrl: rasterized.value } + : { kind: "none" }; +} + +async function rasterizeFavicon( + webContents: Electron.WebContents, + mime: string | null, + buffer: Buffer, + dimensions: ImageDimensions, + signal: AbortSignal, +): Promise { + const gate = rasterizationGates.get(webContents) ?? { generation: 0 }; + rasterizationGates.set(webContents, gate); + const generation = ++gate.generation; + const previousLaunchAllowed = gate.launchAllowed; + if (previousLaunchAllowed) { + await waitForRasterLaunch(previousLaunchAllowed, signal); + } + if (signal.aborted || generation !== gate.generation) { + return { kind: "completed", value: null }; + } + + const payload = buffer.toString("base64"); + const blobType = mime ?? ""; + const scale = Math.min(32 / dimensions.width, 32 / dimensions.height); + const decodeWidth = Math.max(1, Math.round(dimensions.width * scale)); + const decodeHeight = Math.max(1, Math.round(dimensions.height * scale)); + const drawX = (32 - decodeWidth) / 2; + const drawY = (32 - decodeHeight) / 2; + const code = ` + (() => { + const rasterize = async () => { + try { + const source = Uint8Array.from(atob("${payload}"), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([source], { type: "${blobType}" }), { + resizeWidth: ${decodeWidth}, + resizeHeight: ${decodeHeight}, + resizeQuality: "high", + }); + try { + if (bitmap.width <= 0 || bitmap.height <= 0 || bitmap.width * bitmap.height > ${MAX_FAVICON_SOURCE_PIXELS}) { + return null; + } + const canvas = new OffscreenCanvas(32, 32); + const context = canvas.getContext("2d"); + if (!context) return null; + context.drawImage(bitmap, ${drawX}, ${drawY}, ${decodeWidth}, ${decodeHeight}); + const blob = await canvas.convertToBlob({ type: "image/png" }); + const output = new Uint8Array(await blob.arrayBuffer()); + let binary = ""; + for (const byte of output) binary += String.fromCharCode(byte); + return "data:image/png;base64," + btoa(binary); + } finally { + bitmap.close(); + } + } catch { + return null; + } + }; + return rasterize(); + })() + `; + + const execution = webContents.executeJavaScriptInIsolatedWorld(FAVICON_RASTER_WORLD_ID, [ + { code }, + ]); + + const result = new Promise((resolve, reject) => { + // Electron cannot cancel isolated-world execution. This timeout ends only + // the logical attempt; renderer work may finish after a newer attempt starts. + const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS); + let settled = false; + const finish = (complete: () => void) => { + if (settled) return; + settled = true; + timeout.removeEventListener("abort", onTimeout); + signal.removeEventListener("abort", onAbort); + complete(); + }; + const onTimeout = () => { + finish(() => resolve({ kind: "timed-out" })); + }; + const onAbort = () => { + finish(() => resolve({ kind: "completed", value: null })); + }; + timeout.addEventListener("abort", onTimeout, { once: true }); + signal.addEventListener("abort", onAbort, { once: true }); + void execution.then( + (value) => { + finish(() => resolve({ kind: "completed", value })); + }, + (cause: unknown) => { + finish(() => reject(cause)); + }, + ); + if (signal.aborted) onAbort(); + }); + // The logical timeout does not cancel Electron's renderer work. Keep the + // gate closed until that physical execution actually settles. + const launchAllowed = execution.then( + () => undefined, + () => undefined, + ); + gate.launchAllowed = launchAllowed; + void launchAllowed.then(() => { + if (gate.launchAllowed === launchAllowed) delete gate.launchAllowed; + }); + return await result; +} diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a6ef30c27..c24dca802 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -186,6 +186,102 @@ const makeTestPreviewWebContents = ( capturePage, }) as never; +const TEST_FAVICON = "data:image/png;base64,cG5n"; + +const makeSourcePng = (width = 1, height = 1): Buffer => { + const buffer = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(buffer); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +}; + +const makeFaviconWebContents = (options?: { + readonly fetch?: (url: string, init?: RequestInit) => Promise; + readonly id?: number; + readonly rasterize?: (code: string) => Promise; + readonly url?: string; +}) => { + const sourcePng = makeSourcePng(); + const listeners = new Map void>(); + let currentUrl = options?.url ?? "http://localhost:3200/"; + let destroyed = false; + let loading = false; + const fetch = vi.fn( + options?.fetch ?? + (async () => + new Response(new Uint8Array(sourcePng), { + headers: { "content-type": "image/png" }, + })), + ); + const executeJavaScriptInIsolatedWorld = vi.fn( + async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => + options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON, + ); + const reload = vi.fn(); + const loadURL = vi.fn(async (url: string) => { + currentUrl = url; + }); + const off = vi.fn(); + const debuggerOff = vi.fn(); + const webContents = { + id: options?.id ?? 42, + isDestroyed: () => destroyed, + getType: () => "webview", + getURL: () => currentUrl, + getTitle: () => "Preview", + isLoading: () => loading, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + reload, + reloadIgnoringCache: vi.fn(), + loadURL, + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off, + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + session: { fetch }, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + executeJavaScriptInIsolatedWorld, + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: debuggerOff, + }, + }; + return { + executeJavaScriptInIsolatedWorld, + fetch, + debuggerOff, + listeners, + loadURL, + off, + reload, + setDestroyed: (value: boolean) => { + destroyed = value; + }, + setLoading: (value: boolean) => { + loading = value; + }, + setUrl: (url: string) => { + currentUrl = url; + }, + webContents: webContents as never, + }; +}; + +const settle = function* (until: () => boolean) { + for (let attempt = 0; attempt < 30 && !until(); attempt++) { + yield* Effect.promise(() => Promise.resolve()); + } +}; + const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => { const listeners = new Map void>(); const send = vi.fn(); @@ -257,6 +353,32 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("rejects a destroyed webview during registration", () => + withManager((manager) => + Effect.gen(function* () { + const getType = vi.fn(() => "webview" as const); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => true, + getType, + } as never); + yield* manager.createTab("tab_destroyed_registration"); + + const exit = yield* Effect.exit(manager.registerWebview("tab_destroyed_registration", 42)); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewWebContentsNotFoundError", + tabId: "tab_destroyed_registration", + webContentsId: 42, + }); + } + expect(getType).not.toHaveBeenCalled(); + }), + ), + ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { const loggedErrors: Array = []; const logger = Logger.make(({ message }) => { @@ -375,6 +497,488 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("detaches a destroyed webview instead of navigating it", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_destroyed_navigation"); + yield* manager.registerWebview("tab_destroyed_navigation", 42); + yield* manager.setColorScheme("tab_destroyed_navigation", "dark"); + preview.setDestroyed(true); + + yield* manager.navigate("tab_destroyed_navigation", "https://example.com/"); + + expect(preview.loadURL).not.toHaveBeenCalled(); + expect(preview.reload).not.toHaveBeenCalled(); + expect(preview.off).toHaveBeenCalled(); + expect(preview.debuggerOff).toHaveBeenCalled(); + expect(states.at(-1)).toMatchObject({ + webContentsId: null, + navStatus: { kind: "Loading", url: "https://example.com/" }, + }); + }), + ), + ); + + effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () => + withManager((manager) => + Effect.gen(function* () { + const previous = makeFaviconWebContents(); + const replacement = makeFaviconWebContents({ url: "https://example.com/" }); + let current = previous.webContents; + let startReplacementRegistration: () => void = () => void 0; + const replacementReady = new Promise((resolve) => { + startReplacementRegistration = resolve; + }); + fromId.mockImplementation(() => current); + yield* manager.createTab("tab_destroyed_replacement_race"); + yield* manager.registerWebview("tab_destroyed_replacement_race", 42); + yield* manager.setColorScheme("tab_destroyed_replacement_race", "dark"); + const replacementRegistration = yield* Effect.promise(() => replacementReady).pipe( + Effect.flatMap(() => manager.registerWebview("tab_destroyed_replacement_race", 42)), + Effect.forkChild({ startImmediately: true }), + ); + previous.setDestroyed(true); + previous.debuggerOff.mockImplementationOnce(() => { + current = replacement.webContents; + startReplacementRegistration(); + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.navigate("tab_destroyed_replacement_race", "https://example.com/"); + const registrationExit = yield* Fiber.await(replacementRegistration); + + expect(Exit.isSuccess(registrationExit)).toBe(true); + expect(previous.off).toHaveBeenCalled(); + expect(replacement.off).not.toHaveBeenCalled(); + expect(states.at(-1)).toMatchObject({ + webContentsId: 42, + navStatus: { kind: "Loading", url: "https://example.com/" }, + }); + }), + ), + ); + + effectIt.effect("publishes a canonical favicon origin while the page is loading", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents({ + url: `http://localhost:3200/${"x".repeat(3_000)}`, + }); + preview.setLoading(true); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_loading"); + yield* manager.registerWebview("tab_favicon_loading", 42); + + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(states.at(-1)?.favicon).toMatchObject({ + dataUrl: TEST_FAVICON, + pageUrl: "http://localhost:3200", + }); + expect(states.at(-1)?.favicon?.capturedAt).toEqual(expect.any(Number)); + }), + ), + ); + + effectIt.effect("shares an identical in-flight event and lets a changed event win", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFirst!: (response: Response) => void; + const firstResponse = new Promise((resolve) => { + resolveFirst = resolve; + }); + const preview = makeFaviconWebContents({ + fetch: (url) => + url.endsWith("first.png") + ? firstResponse + : Promise.resolve( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ), + }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_latest"); + yield* manager.registerWebview("tab_favicon_latest", 42); + + const faviconUpdated = preview.listeners.get("page-favicon-updated")!; + faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); + faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); + yield* settle(() => preview.fetch.mock.calls.length === 1); + faviconUpdated({} as never, ["http://localhost:3200/second.png"] as never); + yield* settle(() => states.at(-1)?.favicon !== undefined); + resolveFirst( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ); + yield* settle(() => false); + + expect(preview.fetch).toHaveBeenCalledTimes(2); + expect(states.filter((state) => state.favicon !== undefined)).toHaveLength(1); + }), + ), + ); + + effectIt.effect("allows an identical retry after an undecodable capture", () => + withManager((manager) => + Effect.gen(function* () { + let rasterizations = 0; + const preview = makeFaviconWebContents({ + rasterize: async () => (++rasterizations === 1 ? null : TEST_FAVICON), + }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_retry"); + yield* manager.registerWebview("tab_favicon_retry", 42); + const faviconUpdated = preview.listeners.get("page-favicon-updated")!; + + faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); + yield* settle(() => rasterizations === 1); + yield* settle(() => false); + faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + expect(rasterizations).toBe(2); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + + effectIt.effect("does not publish a capture invalidated by navigation", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFetch!: (response: Response) => void; + const preview = makeFaviconWebContents({ + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_navigation"); + yield* manager.registerWebview("tab_favicon_navigation", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => preview.fetch.mock.calls.length === 1); + preview.listeners.get("did-start-navigation")?.({ + isMainFrame: true, + isSameDocument: false, + } as never); + preview.setUrl("https://example.com/"); + resolveFetch( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ); + yield* settle(() => false); + + expect(states.some((state) => state.favicon !== undefined)).toBe(false); + }), + ), + ); + + effectIt.effect("retains a favicon when reloading the current URL without a new event", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reload"); + yield* manager.registerWebview("tab_favicon_reload", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.navigate("tab_favicon_reload", "http://localhost:3200/"); + + expect(preview.reload).toHaveBeenCalledOnce(); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + + effectIt.effect("clears a published favicon after a confirmed cross-origin navigation", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_origin"); + yield* manager.registerWebview("tab_favicon_origin", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + preview.setUrl("https://example.com/"); + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect( + "retains the previous document icon across a failed cross-origin navigation", + () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_failed_origin"); + yield* manager.registerWebview("tab_favicon_failed_origin", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + preview.listeners.get("did-fail-load")?.( + {} as never, + -105 as never, + "Name not resolved" as never, + "https://unreachable.example/" as never, + true as never, + ); + yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed"); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + + effectIt.effect("does not resurrect an icon after a confirmed about:blank document", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_blank"); + yield* manager.registerWebview("tab_favicon_blank", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + preview.setUrl("about:blank"); + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Idle"); + expect(states.at(-1)?.favicon).toBeUndefined(); + + preview.setUrl("http://localhost:3200/"); + preview.listeners.get("did-navigate")?.({} as never); + yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect("clears a published favicon when a replacement webview attaches", () => + withManager((manager) => + Effect.gen(function* () { + const initial = makeFaviconWebContents({ id: 42 }); + const replacement = makeFaviconWebContents({ id: 43 }); + fromId.mockImplementation((id?: number) => { + if (id === 42) return initial.webContents; + if (id === 43) return replacement.webContents; + return null; + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_replace"); + yield* manager.registerWebview("tab_favicon_replace", 42); + initial.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.registerWebview("tab_favicon_replace", 43); + + expect(states.at(-1)?.webContentsId).toBe(43); + expect(states.at(-1)?.favicon).toBeUndefined(); + }), + ), + ); + + effectIt.effect("ignores an old capture that completes after webview replacement", () => + withManager((manager) => + Effect.gen(function* () { + let resolveFetch!: (response: Response) => void; + const initial = makeFaviconWebContents({ + id: 42, + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + const replacement = makeFaviconWebContents({ id: 43 }); + fromId.mockImplementation((id?: number) => + id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null, + ); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_late_replace"); + yield* manager.registerWebview("tab_favicon_late_replace", 42); + initial.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => initial.fetch.mock.calls.length === 1); + + yield* manager.registerWebview("tab_favicon_late_replace", 43); + resolveFetch( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, + }), + ); + yield* settle(() => false); + + expect(states.at(-1)?.webContentsId).toBe(43); + expect( + states.some((state) => state.webContentsId === 43 && state.favicon !== undefined), + ).toBe(false); + }), + ), + ); + + effectIt.effect("treats a reused WebContents id as a new attachment", () => + withManager((manager) => + Effect.gen(function* () { + const initial = makeFaviconWebContents({ id: 42 }); + const replacement = makeFaviconWebContents({ id: 42 }); + let active = initial.webContents; + fromId.mockImplementation(() => active); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reused_id"); + yield* manager.registerWebview("tab_favicon_reused_id", 42); + initial.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + active = replacement.webContents; + yield* manager.registerWebview("tab_favicon_reused_id", 42); + + expect(states.at(-1)?.favicon).toBeUndefined(); + expect(initial.off).toHaveBeenCalled(); + expect(replacement.listeners.has("page-favicon-updated")).toBe(true); + }), + ), + ); + + effectIt.effect("preserves a favicon when the active attachment registers again", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_favicon_reregister"); + yield* manager.registerWebview("tab_favicon_reregister", 42); + preview.listeners.get("page-favicon-updated")?.( + {} as never, + ["http://localhost:3200/favicon.png"] as never, + ); + yield* settle(() => states.at(-1)?.favicon !== undefined); + + yield* manager.registerWebview("tab_favicon_reregister", 42); + + expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + }), + ), + ); + effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 169fe2992..4799a7dfa 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -8,6 +8,7 @@ import type { DesktopPreviewAnnotationTheme, DesktopPreviewColorScheme, + DesktopPreviewFavicon, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -62,6 +63,7 @@ import { import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; +import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -85,6 +87,7 @@ export interface PreviewTabState { pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; + favicon?: DesktopPreviewFavicon; updatedAt: string; } @@ -346,7 +349,10 @@ type PreviewInputSignal = | { readonly kind: "key"; readonly key: string; readonly code: string }; interface ManagedListeners { + readonly attachmentId: symbol; + readonly cancelFaviconCapture: () => void; readonly scope: Scope.Closeable; + readonly webContents: Electron.WebContents; } type FrameCaptureConsumer = "picture-in-picture" | "recording"; @@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* ( + tabId: string, + state: PreviewTabState, + ) { + if ((yield* SynchronizedRef.get(tabsRef)).get(tabId) === state) { + yield* emit(tabId, state); + } + }); + const update = Effect.fn("PreviewManager.update")(function* ( tabId: string, patch: Partial, @@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.delete(webContentsId); }), ]); - if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); + if (managed) { + managed.cancelFaviconCapture(); + yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); + } }); const isAppShortcut = (input: Electron.Input): boolean => @@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc: Electron.WebContents, ) { const scope = yield* Scope.fork(parentScope, "sequential"); + const attachmentId = Symbol(); + let documentId = 0; + let nextRequestId = 0; + let activeCapture: { + readonly controller: AbortController; + readonly documentId: number; + readonly eventKey: string; + readonly requestId: number; + } | null = null; + const cancelFaviconCapture = () => { + documentId += 1; + activeCapture?.controller.abort(); + activeCapture = null; + }; const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* ( preserveLoadFailure: boolean, + confirmedNavigation = false, ) { if (wc.isDestroyed()) return; const zoomFactor = yield* attempt( @@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); - if (!current) return [Option.none(), tabs] as const; + if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) { + return [Option.none(), tabs] as const; + } // Electron emits did-stop-loading after did-fail-load. At that point the // failed guest is no longer "loading", but it has not successfully // navigated anywhere. Keep the failure until a new load actually starts. @@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function computedNavStatus.kind === "Success" ? current.navStatus : computedNavStatus; + const clearFavicon = + confirmedNavigation && + current.favicon !== undefined && + safeHttpOrigin(current.favicon.pageUrl) !== + safeHttpOrigin(navStatus.kind === "Idle" ? wc.getURL() : navStatus.url); + const { favicon: _favicon, ...currentWithoutFavicon } = current; const state: PreviewTabState = { - ...current, + ...(clearFavicon ? currentWithoutFavicon : current), navStatus, canGoBack, canGoForward, @@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - if (Option.isSome(next)) yield* emit(tabId, next.value); + if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); }); const sync = () => runFork(syncState(true)); - const syncNavigation = () => runFork(syncState(false)); + const syncNavigation = () => runFork(syncState(false, true)); + const syncInPageNavigation = () => runFork(syncState(false)); + const navigationStarted = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture(); + }; + const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: { + readonly captureDocumentId: number; + readonly dataUrl: string; + readonly pageUrl: string; + readonly requestId: number; + }) { + const pageOrigin = safeHttpOrigin(input.pageUrl); + const managed = (yield* Ref.get(attachedRef)).get(wc.id); + if ( + !pageOrigin || + wc.isDestroyed() || + webContents.fromId(wc.id) !== wc || + managed?.attachmentId !== attachmentId || + activeCapture?.documentId !== input.captureDocumentId || + activeCapture.requestId !== input.requestId || + safeHttpOrigin(wc.getURL()) !== pageOrigin + ) { + return; + } + const capturedAt = yield* currentMillis; + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if ( + !current || + current.webContentsId !== wc.id || + webContents.fromId(wc.id) !== wc || + activeCapture?.documentId !== input.captureDocumentId || + activeCapture.requestId !== input.requestId + ) { + return [Option.none(), tabs] as const; + } + const state: PreviewTabState = { + ...current, + favicon: { dataUrl: input.dataUrl, pageUrl: pageOrigin, capturedAt }, + updatedAt, + }; + return [ + Option.some(state), + replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }), + ] as const; + }); + if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); + }); + const faviconUpdated = (_event: Event, rawCandidates: ReadonlyArray): void => { + const pageUrl = wc.getURL(); + if (!safeHttpOrigin(pageUrl)) return; + const candidates = selectFaviconCandidates(rawCandidates); + if (candidates.length === 0) return; + const eventKey = JSON.stringify([pageUrl, ...candidates]); + if (activeCapture?.eventKey === eventKey) return; + activeCapture?.controller.abort(); + const captureDocumentId = documentId; + const requestId = ++nextRequestId; + const controller = new AbortController(); + activeCapture = { controller, documentId: captureDocumentId, eventKey, requestId }; + runFork( + Effect.tryPromise({ + try: () => + captureFavicon({ webContents: wc, pageUrl, candidates, signal: controller.signal }), + catch: (cause) => + new PreviewOperationError({ + operation: "captureFavicon", + tabId, + webContentsId: wc.id, + cause, + }), + }).pipe( + Effect.flatMap((result) => + result.kind === "captured" + ? publishFavicon({ + captureDocumentId, + dataUrl: result.dataUrl, + pageUrl, + requestId, + }) + : Effect.void, + ), + Effect.catch((error) => + controller.signal.aborted + ? Effect.void + : Effect.logDebug("Favicon capture failed.", { error, tabId, webContentsId: wc.id }), + ), + Effect.ensuring( + Effect.sync(() => { + if (activeCapture?.requestId === requestId) activeCapture = null; + }), + ), + ), + ); + }; const failed = ( _event: Event, code: number, @@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { + cancelFaviconCapture(); + wc.off("did-start-navigation", navigationStarted); wc.off("did-navigate", syncNavigation); - wc.off("did-navigate-in-page", syncNavigation); + wc.off("did-navigate-in-page", syncInPageNavigation); wc.off("page-title-updated", sync); + wc.off("page-favicon-updated", faviconUpdated as never); wc.off("did-start-loading", sync); wc.off("did-stop-loading", sync); wc.off("did-fail-load", failed as never); @@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { + wc.on("did-start-navigation", navigationStarted); wc.on("did-navigate", syncNavigation); - wc.on("did-navigate-in-page", syncNavigation); + wc.on("did-navigate-in-page", syncInPageNavigation); wc.on("page-title-updated", sync); + wc.on("page-favicon-updated", faviconUpdated as never); wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); @@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); yield* Ref.update(attachedRef, (attached) => replaceMap(attached, (copy) => { - copy.set(wc.id, { scope }); + copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc }); }), ); }); @@ -1561,6 +1706,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const mainWindow = yield* Ref.get(mainWindowRef); if ( !wc || + wc.isDestroyed() || wc.getType() !== "webview" || (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) ) { @@ -1568,7 +1714,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); - if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { + const currentAttachment = attached.get(webContentsId); + if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { const zoomFactor = yield* attempt( { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => wc.getZoomFactor(), @@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } const replacedWebContentsId = - tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null; + tab.webContentsId != null && + (tab.webContentsId !== webContentsId || currentAttachment?.webContents !== wc) + ? tab.webContentsId + : null; if (replacedWebContentsId !== null) { yield* Effect.all( [ @@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; } const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; + const { favicon: _favicon, ...currentWithoutFavicon } = current; const next: PreviewTabState = { - ...current, + ...currentWithoutFavicon, webContentsId, navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), @@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", controller: current?.controller ?? "none", + ...(current?.favicon ? { favicon: current.favicon } : {}), updatedAt, }; return [ @@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); yield* emit(tabId, pending); if (pending.webContentsId == null) return; - const wc = webContents.fromId(pending.webContentsId); - if (!wc) { - const detached = { ...pending, webContentsId: null }; - yield* SynchronizedRef.update(tabsRef, (tabs) => - tabs.get(tabId)?.webContentsId !== pending.webContentsId - ? tabs - : replaceMap(tabs, (copy) => { - copy.set(tabId, detached); - }), + const webContentsId = pending.webContentsId; + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) { + const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId); + yield* withTabLifecycleLock( + tabId, + Effect.gen(function* () { + const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + const currentAttachment = (yield* Ref.get(attachedRef)).get(webContentsId); + const currentWebContents = webContents.fromId(webContentsId); + if ( + currentTab?.webContentsId !== webContentsId || + currentAttachment !== expectedAttachment || + (currentWebContents && !currentWebContents.isDestroyed()) + ) { + return; + } + yield* Effect.all( + [ + detachControlSession(webContentsId), + detachListeners(webContentsId), + cancelPickElement(tabId), + ], + { concurrency: 3, discard: true }, + ); + const detached = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (current?.webContentsId !== webContentsId) { + return [Option.none(), tabs] as const; + } + const { favicon: _favicon, ...currentWithoutFavicon } = current; + const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId: null }; + return [ + Option.some(next), + replaceMap(tabs, (copy) => { + copy.set(tabId, next); + }), + ] as const; + }); + if (Option.isSome(detached)) yield* emitIfCurrent(tabId, detached.value); + }), ); - yield* emit(tabId, detached); return; } if (wc.getURL() === url) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index dcdabbf44..8cfb44a79 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,6 +34,7 @@ const clientSettings: ClientSettings = { planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, + sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index ec5b9e68c..dfd0c8eb4 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -229,15 +229,18 @@ const NODE_PTY_PROBE_SCRIPT = ( printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" printf 'resolvedPath:%s\\n' "$PATH" cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 -// The server bundle externalizes its deps to node_modules, and the WSL Node -// can't read inside app.asar, so confirm those deps are unpacked on the real -// filesystem before reporting the backend healthy. "effect" is the framework -// every server module imports; resolving it validates the whole node_modules -// tree. Exit 3 marks this distinct from a node-pty problem so the caller can -// report it accurately instead of letting the server crash on -// ERR_MODULE_NOT_FOUND at launch (which, in wsl-only mode, would just fail to -// launch with no fallback). -try { require.resolve("effect"); } catch (_e) { process.exit(3); } +// The WSL Node can't read inside app.asar, so confirm what the server needs is +// unpacked on the real filesystem before reporting the backend healthy. Exit 3 +// marks this distinct from a node-pty prebuild problem so the caller can report +// it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at +// launch (which, in wsl-only mode, would just fail to launch with no fallback). +// +// The sentinel must be a package the CLI bundle leaves external. It used to be +// "effect", back when the bundle externalized its runtime deps and the whole +// node_modules tree was unpacked. The bundle now inlines its JS dependencies, +// so "effect" no longer exists on disk and only the native packages do — +// resolving node-pty is what actually validates the unpacked tree. +try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); const path = require("node:path"); const pkgDir = path.dirname(require.resolve("node-pty/package.json")); @@ -462,16 +465,17 @@ const ensureNodePtyImpl = ( } as const; } - // Server dependencies (e.g. "effect") couldn't be resolved on the WSL - // filesystem — a packaging regression, since the server bundle needs its - // node_modules unpacked from the asar. Fatal so wsl-only mode falls back to - // Windows and dual mode surfaces the reason inline, instead of the server - // crash-looping on ERR_MODULE_NOT_FOUND once it actually launches. + // The packages the server bundle leaves external (node-pty and the other + // native addons) couldn't be resolved on the WSL filesystem — a packaging + // regression, since those must be unpacked from the asar. Fatal so wsl-only + // mode falls back to Windows and dual mode surfaces the reason inline, + // instead of the server crash-looping on ERR_MODULE_NOT_FOUND once it + // actually launches. if (probe.exitCode === 3) { return { ok: false, reason: - "WSL server dependencies could not be loaded (for example \"effect\"). The server's bundled node_modules is not readable by the WSL distro's Node — this is a packaging problem with this build. Please report it.", + 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.', fatal: true, } as const; } diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 3813a10fa..9a5172547 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -161,7 +161,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.0.3", + version: "1.0.4", runtimeVersion: { // Fingerprint (not appVersion) so an OTA only reaches binaries whose native // project — native deps, config plugins, AND patches/ — matches the update. diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index e13c0a521..3010b5240 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -252,6 +252,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( val textLength = editor.text?.length ?: 0 val safeStart = start.coerceIn(0, textLength) val safeEnd = end.coerceIn(0, textLength) + // Re-applying an unchanged selection resets the keyboard's suggestion + // state, so a no-op assignment must be skipped. + if (editor.selectionStart == safeStart && editor.selectionEnd == safeEnd) return editor.setSelection(safeStart, safeEnd) } @@ -281,6 +284,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( ) private fun emitSelectionChange(start: Int, end: Int) { + // Caret moves advance the revision counter like text edits do: a + // controlled payload computed before this move is stale and must fail the + // revision guard instead of yanking the caret back mid-typing. + nativeEventCount += 1 onComposerSelectionChange( mapOf( "value" to editor.text.toString(), diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index ec5b54aa8..2a8fb8c4e 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -489,6 +489,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return } restoreBaseTypingAttributes() + // UIKit moves the selection before textViewDidChange runs. Emitting here + // would pair the post-edit text with a pre-edit revision counter, so let + // the change event that follows carry both; only pure caret moves emit. + guard self.textView.serializedText() == value else { + return + } emitSelection() } @@ -774,8 +780,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func emitSelection() { + // Caret moves advance the revision counter like text edits do: a + // controlled payload computed before this move is stale and must fail the + // revision guard instead of yanking the caret back mid-typing. let currentValue = textView.serializedText() let selection = sourceSelection() + nativeEventCount += 1 onComposerSelectionChange([ "value": currentValue, "selection": ["start": selection.start, "end": selection.end], @@ -817,10 +827,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro NSMaxRange(nextRange) <= textView.attributedText.length else { return } + self.requestedSelection = nil + // Programmatically assigning selectedRange resets the keyboard's + // autocorrect and predictive-text context even when the range is + // unchanged, so a no-op assignment must be skipped. + guard !NSEqualRanges(nextRange, textView.selectedRange) else { + return + } isApplyingControlledValue = true textView.selectedRange = nextRange isApplyingControlledValue = false - self.requestedSelection = nil } private func updatePlaceholderVisibility() { diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index e6a045b3c..5fbe6d4df 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -4,16 +4,13 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { - nativeMarkdownDocumentRuns, - nativeMarkdownListItemBlocks, - nativeMarkdownTextRuns, -} from "./nativeMarkdownText"; +import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; import type { MarkdownCodeHighlighter, MarkdownHighlightedToken, NativeMarkdownTextStyle, + SelectableMarkdownSkill, } from "./SelectableMarkdownText.types"; type HighlightedCode = ReadonlyArray>; @@ -48,12 +45,13 @@ function documentFor(node: MarkdownNode): MarkdownNode { function SelectableNode(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { return ( @@ -322,6 +320,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] { function NativeTable(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { @@ -359,7 +358,7 @@ function NativeTable(props: { }} > + runs={nativeMarkdownDocumentRuns(documentFor(cell), props.skills).map((run) => rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, )} textStyle={props.textStyle} @@ -376,6 +375,7 @@ function NativeTable(props: { function NativeMarkdownImage(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { @@ -384,6 +384,7 @@ function NativeMarkdownImage(props: { return ( @@ -445,6 +446,7 @@ function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { function NativeMixedParagraph(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { @@ -455,6 +457,7 @@ function NativeMixedParagraph(props: { @@ -462,6 +465,7 @@ function NativeMixedParagraph(props: { @@ -473,6 +477,7 @@ function NativeMixedParagraph(props: { function NativeList(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; @@ -534,6 +539,7 @@ function NativeList(props: { ; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; @@ -566,6 +573,7 @@ export function NativeMarkdownBlock(props: { @@ -595,6 +604,7 @@ export function NativeMarkdownBlock(props: { return ( @@ -624,6 +634,7 @@ export function NativeMarkdownBlock(props: { child.type === "image") ? ( ) : ( @@ -673,6 +687,7 @@ export function NativeMarkdownBlock(props: { > @@ -690,6 +705,7 @@ export function NativeMarkdownBlock(props: { diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 56321ba01..7860ff592 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -69,6 +69,7 @@ export function SelectableMarkdownText({ chunk.kind === "rich" ? ( ]*)?>/gi; +function decodeCodePoint(codePoint: number, entity: string): string { + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return entity; + } + return String.fromCodePoint(codePoint); +} + function decodeHtmlEntitiesOnce(value: string): string { return value.replace( /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, (entity, decimal: string | undefined, hexadecimal: string | undefined) => { if (decimal) { - return String.fromCodePoint(Number.parseInt(decimal, 10)); + return decodeCodePoint(Number.parseInt(decimal, 10), entity); } if (hexadecimal) { - return String.fromCodePoint(Number.parseInt(hexadecimal, 16)); + return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity); } switch (entity.toLowerCase()) { case "&": @@ -661,6 +668,7 @@ function appendDocumentBlock( function containsRichBlock(node: MarkdownNode): boolean { if ( node.type === "code_block" || + node.type === "blockquote" || node.type === "table" || node.type === "image" || node.type === "horizontal_rule" || diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt deleted file mode 100644 index 47db92d92..000000000 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt +++ /dev/null @@ -1,97 +0,0 @@ -package expo.modules.t3nativecontrols - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.view.View -import expo.modules.kotlin.AppContext -import expo.modules.kotlin.viewevent.EventDispatcher -import expo.modules.kotlin.views.ExpoView - -class T3HeaderButtonView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { - private val iconView = HeaderIconView(context) - private val onTriggered by EventDispatcher() - - init { - isClickable = true - isFocusable = true - setOnClickListener { - onTriggered(emptyMap()) - } - addView(iconView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) - } - - fun setLabel(label: String) { - contentDescription = label - } - - fun setSystemImage(systemImage: String) { - iconView.systemImage = systemImage - } -} - -private class HeaderIconView(context: Context) : View(context) { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.parseColor("#6B7280") - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - strokeWidth = 3f * resources.displayMetrics.density - style = Paint.Style.STROKE - } - - var systemImage: String = "gearshape" - set(value) { - field = value - invalidate() - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val cx = width / 2f - val cy = height / 2f - val size = minOf(width, height).toFloat() - if (systemImage == "square.and.pencil") { - drawNewTask(canvas, cx, cy, size) - } else { - drawSettings(canvas, cx, cy, size) - } - } - - private fun drawSettings(canvas: Canvas, cx: Float, cy: Float, size: Float) { - val radius = size * 0.12f - canvas.drawCircle(cx, cy, radius, paint) - for (index in 0 until 8) { - val angle = Math.PI * index / 4.0 - val inner = size * 0.19f - val outer = size * 0.27f - val sx = cx + kotlin.math.cos(angle).toFloat() * inner - val sy = cy + kotlin.math.sin(angle).toFloat() * inner - val ex = cx + kotlin.math.cos(angle).toFloat() * outer - val ey = cy + kotlin.math.sin(angle).toFloat() * outer - canvas.drawLine(sx, sy, ex, ey, paint) - } - } - - private fun drawNewTask(canvas: Canvas, cx: Float, cy: Float, size: Float) { - val left = cx - size * 0.2f - val top = cy - size * 0.16f - val right = cx + size * 0.14f - val bottom = cy + size * 0.2f - canvas.drawRoundRect(left, top, right, bottom, size * 0.04f, size * 0.04f, paint) - canvas.drawLine( - cx - size * 0.02f, - cy + size * 0.13f, - cx + size * 0.24f, - cy - size * 0.13f, - paint - ) - canvas.drawLine( - cx + size * 0.17f, - cy - size * 0.2f, - cx + size * 0.24f, - cy - size * 0.13f, - paint - ) - } -} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index b15a1cd44..f08ca9afb 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -32,16 +32,5 @@ class T3NativeControlsModule : Module() { ?.resolve("t3-showcase-ready") ?.writeText(scene) } - - View(T3HeaderButtonView::class) { - Prop("label") { view: T3HeaderButtonView, label: String -> - view.setLabel(label) - } - Prop("systemImage") { view: T3HeaderButtonView, systemImage: String -> - view.setSystemImage(systemImage) - } - - Events("onTriggered") - } } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift b/apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift deleted file mode 100644 index 7b7f9db67..000000000 --- a/apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift +++ /dev/null @@ -1,62 +0,0 @@ -import ExpoModulesCore -import UIKit - -public final class T3HeaderButtonView: ExpoView { - private static let size: CGFloat = 44 - private static let symbolSize: CGFloat = 18 - - private let button = UIButton(type: .system) - private var systemImage = "circle" - - let onTriggered = EventDispatcher() - - public required init(appContext: AppContext? = nil) { - super.init(appContext: appContext) - - isAccessibilityElement = false - button.frame = bounds - button.autoresizingMask = [.flexibleWidth, .flexibleHeight] - button.addTarget(self, action: #selector(handlePress), for: .primaryActionTriggered) - addSubview(button) - applyConfiguration() - } - - public override var intrinsicContentSize: CGSize { - CGSize(width: Self.size, height: Self.size) - } - - public func setLabel(_ label: String) { - button.accessibilityLabel = label - } - - public func setSystemImage(_ systemImage: String) { - guard self.systemImage != systemImage else { - return - } - self.systemImage = systemImage - applyConfiguration() - } - - private func applyConfiguration() { - var configuration: UIButton.Configuration - if #available(iOS 26.0, *) { - configuration = .glass() - configuration.cornerStyle = .capsule - } else { - configuration = .plain() - } - - configuration.baseForegroundColor = .label - configuration.contentInsets = .zero - configuration.image = UIImage(systemName: systemImage) - configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( - pointSize: Self.symbolSize, - weight: .regular - ) - button.configuration = configuration - } - - @objc private func handlePress() { - onTriggered() - } -} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 23cf4720d..f3125c3ce 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -87,16 +87,5 @@ public final class T3NativeControlsModule: Module { let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) } - - View(T3HeaderButtonView.self) { - Prop("label") { (view: T3HeaderButtonView, label: String) in - view.setLabel(label) - } - Prop("systemImage") { (view: T3HeaderButtonView, systemImage: String) in - view.setSystemImage(systemImage) - } - - Events("onTriggered") - } } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 93bb61655..20bba1f60 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -11,7 +11,7 @@ import { type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; import { useEffect, useRef } from "react"; -import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; +import { Platform, Pressable, ScrollView, StyleSheet } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; @@ -39,6 +39,15 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; +import { + NewTaskBranchPickerRouteScreen, + NewTaskEnvironmentPickerRouteScreen, +} from "./features/threads/NewTaskContextPickerScreens"; +import { + ExistingThreadSettingsRouteProvider, + ExistingThreadSettingsRouteScreen, + NewTaskThreadSettingsRouteScreen, +} from "./features/threads/ThreadSettingsSheet"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; @@ -62,17 +71,15 @@ import { } from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; +import { + FORM_SHEET_PRESENTATION_OPTIONS, + NATIVE_SHEET_SURFACE_COLOR, + NATIVE_SHEET_SURFACE_CONTENT_STYLE, +} from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); -// Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header -// background stay STATIC config while still adapting to appearance changes. -const SHEET_BACKGROUND_COLOR = - Platform.OS === "ios" - ? DynamicColorIOS({ light: "rgba(242, 242, 247, 0.98)", dark: "rgba(14, 14, 14, 0.98)" }) - : undefined; - type AppScreenOptions = NativeStackNavigationOptions & { readonly unstable_navigationItemStyle?: "editor"; }; @@ -91,8 +98,8 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { headerShown: true, headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } - : SHEET_BACKGROUND_COLOR !== undefined - ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } + : NATIVE_SHEET_SURFACE_COLOR !== undefined + ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, @@ -109,10 +116,10 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerShadowVisible: false, headerShown: true, headerStyle: - SHEET_BACKGROUND_COLOR !== undefined + NATIVE_SHEET_SURFACE_COLOR !== undefined ? // native-stack types this as `string`, but the native side accepts any // ColorValue including DynamicColorIOS. - { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } + { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: false, @@ -125,6 +132,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { unstable_navigationItemStyle: undefined, }; +// A native glass header for a sheet screen whose primary child is a scroll +// view. The centered sheet title stays stable while UIKit supplies scroll-edge +// fading from that child. +const SHEET_GLASS_HEADER_OPTIONS: AppScreenOptions = { + ...GLASS_HEADER_OPTIONS, + unstable_navigationItemStyle: undefined, +}; + const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { ...SHEET_SOLID_HEADER_OPTIONS, headerBackVisible: false, @@ -238,9 +253,16 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; const NewTaskSheetStack = createNativeStackNavigator({ initialRouteName: "NewTask", screenOptions: { - ...GLASS_HEADER_OPTIONS, - // Sheets read better with the iOS-default centered title (no editor style). - unstable_navigationItemStyle: undefined, + ...SHEET_GLASS_HEADER_OPTIONS, + // The form-sheet host owns the one opaque adaptive surface. Child screens + // and the navigation bar stay transparent over it, avoiding visible color + // slabs as view controllers move horizontally. + contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, + // UIKit's default push adds a dimming shadow and independently transitions + // the navigation bar. Both read as mismatched sheet backgrounds here. + // simple_push retains native push/pop gestures without either artifact. + animation: Platform.OS === "ios" ? "simple_push" : undefined, + animationDuration: Platform.OS === "ios" ? 350 : undefined, }, screens: { NewTask: createNativeStackScreen({ @@ -253,9 +275,39 @@ const NewTaskSheetStack = createNativeStackNavigator({ NewTaskDraft: createNativeStackScreen({ screen: NewTaskDraftRouteScreen, linking: "draft", - // The draft composer has no scroll view for glass to sample; a solid - // header also lays the content out below the bar (no manual inset). - options: SHEET_SOLID_HEADER_OPTIONS, + options: { + headerBackVisible: false, + title: "", + }, + }), + NewTaskEnvironment: createNativeStackScreen({ + screen: NewTaskEnvironmentPickerRouteScreen, + linking: "draft/environment", + options: { + title: "Environment", + }, + }), + NewTaskBranch: createNativeStackScreen({ + screen: NewTaskBranchPickerRouteScreen, + linking: "draft/branch", + options: { + title: "Branch", + }, + }), + ThreadSettings: createNativeStackScreen({ + screen: NewTaskThreadSettingsRouteScreen, + linking: "draft/settings", + options: { + gestureEnabled: true, + headerShown: false, + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : { + ...FORM_SHEET_PRESENTATION_OPTIONS, + sheetAllowedDetents: [1], + sheetGrabberVisible: true, + }), + }, }), AddProject: createNativeStackScreen({ screen: AddProjectSourceRoute, @@ -294,6 +346,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "SettingsLegal", "SettingsSheet", "ThreadReviewComment", + "ThreadSettingsSheet", ]); /** @@ -356,9 +409,11 @@ function RootStackLayout(props: { - - {props.children} - + + + {props.children} + + ); } @@ -440,7 +495,9 @@ export const RootStack = createNativeStackNavigator({ options: { // Android cannot host the keyboard-driven comment composer inside a // formSheet; use a full-screen modal there instead. - presentation: Platform.OS === "android" ? "fullScreenModal" : "formSheet", + ...(Platform.OS === "android" + ? { presentation: "fullScreenModal" as const } + : FORM_SHEET_PRESENTATION_OPTIONS), sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], sheetGrabberVisible: Platform.OS !== "android", }, @@ -450,10 +507,7 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files`, options: { ...GLASS_HEADER_OPTIONS, - contentStyle: - SHEET_BACKGROUND_COLOR !== undefined - ? { backgroundColor: SHEET_BACKGROUND_COLOR } - : undefined, + contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE, title: "Files", }, }), @@ -462,11 +516,25 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files/:path*`, options: SOLID_HEADER_OPTIONS, }), + ThreadSettingsSheet: createNativeStackScreen({ + screen: ExistingThreadSettingsRouteScreen, + options: { + gestureEnabled: true, + headerShown: false, + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : { + ...FORM_SHEET_PRESENTATION_OPTIONS, + sheetAllowedDetents: [1], + sheetGrabberVisible: true, + }), + }, + }), GitOverview: createNativeStackScreen({ screen: GitOverviewSheet, linking: `${THREAD_LINKING_PREFIX}/git`, options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.92], sheetGrabberVisible: true, }, @@ -475,7 +543,7 @@ export const RootStack = createNativeStackNavigator({ screen: GitCommitSheet, linking: `${THREAD_LINKING_PREFIX}/git/commit`, options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.92], sheetGrabberVisible: true, }, @@ -484,7 +552,7 @@ export const RootStack = createNativeStackNavigator({ screen: GitBranchesSheet, linking: `${THREAD_LINKING_PREFIX}/git/branches`, options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.92], sheetGrabberVisible: true, }, @@ -493,7 +561,7 @@ export const RootStack = createNativeStackNavigator({ screen: GitConfirmSheet, linking: `${THREAD_LINKING_PREFIX}/git-confirm`, options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.45, 0.7], sheetGrabberVisible: true, }, @@ -509,7 +577,7 @@ export const RootStack = createNativeStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - presentation: "formSheet" as const, + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.7, 0.92], sheetGrabberVisible: true, }), @@ -532,7 +600,7 @@ export const RootStack = createNativeStackNavigator({ ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), title: "Set up T3 Connect", gestureEnabled: true, - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.6, 0.95], sheetGrabberVisible: true, }, @@ -547,7 +615,7 @@ export const RootStack = createNativeStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const, headerShown: false } : { - presentation: "formSheet" as const, + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }), @@ -557,7 +625,7 @@ export const RootStack = createNativeStackNavigator({ screen: ConnectionsNewRouteScreen, linking: "connections/new", options: { - presentation: "formSheet", + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, @@ -577,7 +645,7 @@ export const RootStack = createNativeStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - presentation: "formSheet" as const, + ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.92], sheetGrabberVisible: true, }), diff --git a/apps/mobile/src/components/ComposerToolbarTrigger.tsx b/apps/mobile/src/components/ComposerToolbar.tsx similarity index 77% rename from apps/mobile/src/components/ComposerToolbarTrigger.tsx rename to apps/mobile/src/components/ComposerToolbar.tsx index 201876249..de2cca1f6 100644 --- a/apps/mobile/src/components/ComposerToolbarTrigger.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -17,11 +17,73 @@ import { cn } from "../lib/cn"; import { AppText as Text } from "./AppText"; import { SymbolView } from "./AppSymbol"; -export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44; -export const COMPOSER_TOOLBAR_GAP = 8; -export const COMPOSER_TOOLBAR_FADE_WIDTH = 18; +const COMPOSER_TOOLBAR_GAP = 8; +const COMPOSER_TOOLBAR_FADE_WIDTH = 18; const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; +/** + * Quiet inline composer control used inside cards and their context rows. + * Unlike ComposerToolbarButton, this does not draw another pill inside the + * composer surface, so model and workspace controls read as part of the card. + */ +export function ComposerInlineControl(props: { + readonly accessibilityHint?: string; + readonly accessibilityLabel?: string; + readonly disabled?: boolean; + readonly emphasized?: boolean; + readonly icon?: ComponentProps["name"]; + readonly iconNode?: ReactNode; + readonly label: string; + readonly maxWidth?: number; + readonly onPress?: () => void; + readonly selected?: boolean; + readonly static?: boolean; + readonly chevronDirection?: "down" | "right"; + readonly showChevron?: boolean; +}) { + const iconColor = useThemeColor( + props.emphasized || props.selected ? "--color-icon" : "--color-icon-muted", + ); + + return ( + + {props.iconNode ? ( + {props.iconNode} + ) : props.icon ? ( + + ) : null} + + {props.label} + + {props.showChevron === false ? null : ( + + )} + + ); +} + export function ComposerToolbarRow(props: { readonly children: ReactNode; readonly paddingBottom?: number; @@ -247,5 +309,3 @@ export function ComposerToolbarButton(props: { ); } - -export const ComposerToolbarTrigger = ComposerToolbarButton; diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index f34bd4e28..f0b1f863f 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -5,16 +5,19 @@ import { useColorScheme, View, type ColorValue, + type StyleProp, type ViewProps, type ViewStyle, } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; -export interface GlassSurfaceProps extends Omit { +interface GlassSurfaceProps extends Omit { readonly children: ReactNode; readonly glassEffectStyle?: "clear" | "regular" | "none"; readonly tintColor?: ColorValue; readonly chrome?: "default" | "none"; + /** Styling used only when native Liquid Glass is unavailable. */ + readonly fallbackStyle?: StyleProp; } export function GlassSurface({ @@ -22,6 +25,7 @@ export function GlassSurface({ glassEffectStyle = "regular", chrome = "default", tintColor, + fallbackStyle, style, ...props }: GlassSurfaceProps) { @@ -67,7 +71,7 @@ export function GlassSurface({ } return ( - + {children} ); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index b1a48a35a..582c58fb2 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -20,12 +20,15 @@ import { clearAgentAwarenessRegistrationRecord, loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, + loadPreferences, saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; +import type { Preferences } from "../../persistence/mobile-preferences"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; import { AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, + armAgentAwarenessLiveActivityForLocalWork, getAgentAwarenessRegistrationStatus, mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, @@ -43,6 +46,13 @@ import * as Notifications from "expo-notifications"; const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ getInstances: vi.fn(() => []), + start: vi.fn(() => ({})), +})); +const environmentConfigsMock = vi.hoisted(() => ({ + configs: new Map< + string, + { environment: { capabilities: { agentActivityPublishing?: boolean } } } + >(), })); const backgroundRuntime = vi.hoisted(() => ({ pending: [] as Array<{ @@ -77,9 +87,22 @@ vi.mock("expo-widgets", () => ({ vi.mock("../../widgets/AgentActivity", () => ({ default: { getInstances: widgetMocks.getInstances, + start: widgetMocks.start, }, })); +// The state modules pull the whole connection stack (and native expo modules) +// into the import graph; the arming gate only needs the configs map. +vi.mock("../../state/atom-registry", () => ({ + appAtomRegistry: { + get: () => environmentConfigsMock.configs, + }, +})); + +vi.mock("../../state/server", () => ({ + environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"), +})); + vi.mock("expo-notifications", () => ({ addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })), getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })), @@ -227,6 +250,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); + widgetMocks.start.mockClear(); + environmentConfigsMock.configs.clear(); }); it("preserves disabled Live Activity preferences in relay registrations", () => { @@ -856,4 +881,55 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relayTestLayer)); }, ); + + it("skips the Live Activity seed when the environment reports publishing disabled", async () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: false } }, + }); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + }); + + it("seeds the Live Activity for publishing and pre-capability environments", async () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + environmentConfigsMock.configs.set("env-publishing", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-publishing" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(widgetMocks.start).toHaveBeenCalledTimes(1); + + // An environment without the capability may run an older server that + // still publishes; only an explicit false skips the seed. + widgetMocks.start.mockClear(); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-pre-capability" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(widgetMocks.start).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 449f90886..b0f77d770 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -20,6 +20,8 @@ import { import type { SavedRemoteConnection } from "../../lib/connection"; import { runtime } from "../../lib/runtime"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentServerConfigsAtom } from "../../state/server"; import type { Preferences } from "../../persistence/mobile-preferences"; import { clearAgentAwarenessRegistrationRecord, @@ -448,18 +450,38 @@ function unregisterDeviceWithRelay(input: { }); } +// The environment descriptor advertises whether agent-activity publishes +// currently leave that server (`capabilities.agentActivityPublishing`). Only +// an explicit false skips the seed card: older servers omit the capability +// but may still publish. +function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .agentActivityPublishing !== false + ); +} + // Arms the lock-screen card the moment the user starts agent work from this // phone, while the app is still foregrounded and the fresh activity's token // can be registered immediately. The seeded row is a best-effort placeholder; // the relay's registration replay repaints it with the authoritative -// aggregate within seconds. No-ops when a card is already armed. +// aggregate within seconds. No-ops when a card is already armed, and skips +// environments that report publishing disabled — the seed would sit on +// "Connecting" forever with no update ever arriving to repaint or end it. export function armAgentAwarenessLiveActivityForLocalWork(input: { + readonly environmentId: EnvironmentId; readonly threadTitle: string; readonly projectTitle: string; }): void { if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { return; } + if (!environmentPublishesAgentActivity(input.environmentId)) { + logRegistrationDebug("live activity arming skipped; environment does not publish", { + environmentId: input.environmentId, + }); + return; + } void loadPreferences() .catch(() => null) .then((preferences) => { diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index de3799ac8..37d53cbd8 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -11,12 +11,13 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; -import { extractPairingUrlFromQrPayload } from "./pairing"; +import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; -import { buildPairingUrl, parsePairingUrl } from "./pairing"; type ConnectionsNewRouteParams = { readonly mode?: string; + readonly pairingUrl?: string; + readonly autoConnect?: string; }; export function ConnectionsNewRouteScreen({ @@ -30,6 +31,13 @@ export function ConnectionsNewRouteScreen({ } = useRemoteConnections(); const navigation = useNavigation(); const params = route.params ?? {}; + // Deep-link prefill exists for development automation only. A production + // link must not arrive with attacker-chosen host and token already filled. + const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : ""; + const shouldAutoConnect = + __DEV__ && + routePairingUrl.length > 0 && + (params.autoConnect === "1" || params.autoConnect === "true"); const insets = useSafeAreaInsets(); const [hostInput, setHostInput] = useState(""); const [codeInput, setCodeInput] = useState(""); @@ -37,6 +45,7 @@ export function ConnectionsNewRouteScreen({ const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [scannerLocked, setScannerLocked] = useState(false); + const attemptedAutoConnectRef = useRef(null); const headerIconColor = useThemeColor("--color-icon"); @@ -48,6 +57,16 @@ export function ConnectionsNewRouteScreen({ setCodeInput(code); }, [connectionPairingUrl]); + useEffect(() => { + if (routePairingUrl.length === 0) { + return; + } + + const { host, code } = parsePairingUrl(routePairingUrl); + setHostInput(host); + setCodeInput(code); + }, [routePairingUrl]); + useEffect(() => { if (pairingConnectionError) { setIsSubmitting(false); @@ -116,22 +135,38 @@ export function ConnectionsNewRouteScreen({ [onChangeConnectionPairingUrl, scannerLocked], ); + const connectAndClose = useCallback( + async (pairingUrl: string, replaceWithHome: boolean) => { + setIsSubmitting(true); + onChangeConnectionPairingUrl(pairingUrl); + try { + const result = await onConnectPress(pairingUrl); + if (AsyncResult.isSuccess(result)) { + if (replaceWithHome || !navigation.canGoBack()) { + navigation.dispatch(StackActions.replace("Home")); + } else { + navigation.goBack(); + } + } + } finally { + setIsSubmitting(false); + } + }, + [navigation, onChangeConnectionPairingUrl, onConnectPress], + ); + const handleSubmit = useCallback(async () => { - setIsSubmitting(true); + await connectAndClose(buildPairingUrl(hostInput, codeInput), false); + }, [codeInput, connectAndClose, hostInput]); - const pairingUrl = buildPairingUrl(hostInput, codeInput); - onChangeConnectionPairingUrl(pairingUrl); - const result = await onConnectPress(pairingUrl); - if (AsyncResult.isSuccess(result)) { - if (navigation.canGoBack()) { - navigation.goBack(); - } else { - navigation.dispatch(StackActions.replace("Home")); - } - } else { - setIsSubmitting(false); + useEffect(() => { + if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) { + return; } - }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]); + + attemptedAutoConnectRef.current = routePairingUrl; + void connectAndClose(routePairingUrl, true); + }, [connectAndClose, routePairingUrl, shouldAutoConnect]); return ( diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index dd7a12711..f89bea133 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -8,6 +8,7 @@ import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; +import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, @@ -123,7 +124,7 @@ export function FileTreeBrowser(props: { const insets = useSafeAreaInsets(); // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 44 : 0; + const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; const iconColor = String(useThemeColor("--color-icon-muted")); const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index f3d33934a..e7ce41cb4 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -356,6 +356,7 @@ function IosHomeHeader(props: HomeHeaderProps) { onSearchTextChange: props.onSearchQueryChange, placeholder: "Search", searchTextChangeId: "home-search-text", + showsSearchDismissButton: true, }), ], } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 331347867..8061b1d1e 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -11,7 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; -import { checkForAppUpdateOnLaunch } from "../updates/app-updates"; +import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; @@ -34,6 +34,7 @@ export function HomeRouteScreen() { useEffect(() => { void checkForAppUpdateOnLaunch(); + startAppUpdateForegroundRecheck(); }, []); const { diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6c3647873..60cb1b475 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -207,6 +207,9 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); + const autoSettleOnMerge = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -483,8 +486,8 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition (mirrors web). + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule, matching web. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); @@ -665,6 +668,7 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery: props.searchQuery, matchedThreadKeys, changeRequestStateByKey, + autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -676,6 +680,7 @@ export function HomeScreen(props: HomeScreenProps) { }); }, [ changeRequestStateByKey, + autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts index 8770d96b1..34d5570e6 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -11,6 +11,9 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; */ export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; +/** Clearance for scroll content that must come to rest above the floating toolbar. */ +export const NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET = 56; + type NativeMailSearchToolbarInput = Omit< HeaderBarButtonMailSearchToolbarItem, "type" | "useFallbackSearchField" diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 39e6bda3c..747a919a0 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -27,7 +27,7 @@ import { inferProjectTitleFromPath, } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { StackActions, useNavigation } from "@react-navigation/native"; +import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; @@ -402,13 +402,12 @@ function SourceControlRow(props: { icon={icon} isFirst={props.isFirst} onPress={() => - navigation.navigate("NewTaskSheet", { - screen: "AddProjectRepository", - params: { + navigation.dispatch( + StackActions.push("AddProjectRepository", { environmentId: props.selectedEnvironmentId, source: props.source, - }, - }) + }), + ) } /> ); @@ -498,12 +497,11 @@ export function AddProjectSourceScreen() { } isFirst onPress={() => - navigation.navigate("NewTaskSheet", { - screen: "AddProjectLocal", - params: { + navigation.dispatch( + StackActions.push("AddProjectLocal", { environmentId: selectedEnvironment.environmentId, - }, - }) + }), + ) } /> {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( @@ -547,10 +545,18 @@ function useCreateProject(environment: EnvironmentOption | null) { if (existing) { Alert.alert("Project already exists", existing.title); navigation.dispatch( - StackActions.replace("NewTaskDraft", { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, + CommonActions.reset({ + index: 0, + routes: [ + { + name: "NewTaskDraft", + params: { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + }, + }, + ], }), ); return; @@ -571,10 +577,18 @@ function useCreateProject(environment: EnvironmentOption | null) { return result; } navigation.dispatch( - StackActions.replace("NewTaskDraft", { - environmentId: environment.environmentId, - projectId, - title: inferProjectTitleFromPath(workspaceRoot), + CommonActions.reset({ + index: 0, + routes: [ + { + name: "NewTaskDraft", + params: { + environmentId: environment.environmentId, + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + }, + }, + ], }), ); return result; @@ -612,15 +626,14 @@ export function AddProjectRepositoryScreen(props: { const provider = addProjectRemoteSourceProvider(source); if (!provider) { const remoteUrl = repositoryInput.trim(); - navigation.navigate("NewTaskSheet", { - screen: "AddProjectDestination", - params: { + navigation.dispatch( + StackActions.push("AddProjectDestination", { environmentId: environment.environmentId, source, remoteUrl, repositoryTitle: remoteUrl, - }, - }); + }), + ); setIsSubmitting(false); return; } @@ -636,15 +649,14 @@ export function AddProjectRepositoryScreen(props: { setError(errorMessage(Cause.squash(result.cause))); } else { const repository = result.value; - navigation.navigate("NewTaskSheet", { - screen: "AddProjectDestination", - params: { + navigation.dispatch( + StackActions.push("AddProjectDestination", { environmentId: environment.environmentId, source, remoteUrl: repository.sshUrl, repositoryTitle: repository.nameWithOwner, - }, - }); + }), + ); } setIsSubmitting(false); }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 1ebb3aaf7..93ecc109d 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -39,6 +39,7 @@ import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; import { useThemeColor } from "../../lib/useThemeColor"; +import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { @@ -277,9 +278,11 @@ function ReviewFileNavigator({ // The nested native header is translucent; start the list below it so // the scroll-edge effect can sample the content (same treatment as // FileTreeBrowser in the Files pane). - paddingTop: Platform.OS === "ios" ? insets.top + 44 + 8 : 8, + paddingTop: Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT + 8 : 8, }} - scrollIndicatorInsets={Platform.OS === "ios" ? { top: insets.top + 44 } : undefined} + scrollIndicatorInsets={ + Platform.OS === "ios" ? { top: insets.top + IOS_NAV_BAR_HEIGHT } : undefined + } renderItem={renderFile} /> ); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index dfc3eb125..2407cb9f1 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -522,9 +522,21 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const autoSettleOnMerge = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.autoSettleOnMerge !== false; + return ( + savePreferences({ autoSettleOnMerge: value })} + /> ); @@ -542,6 +554,8 @@ function LegacySettingsSection() { const progressiveThreadHistoryEnabled = AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.progressiveThreadHistoryEnabled === true; + const planModeEnabled = + AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.planModeEnabled === true; return ( @@ -552,6 +566,12 @@ function LegacySettingsSection() { value={!threadListV2Enabled} onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} /> + savePreferences({ planModeEnabled: value })} + /> - Brings back the original grouped thread list. The default list is flat, in creation order: - active work renders as cards; settled threads collapse to compact rows. Progressive Thread - History loads large conversations in pages instead of downloading the full thread. + Opt into retired interfaces kept for compatibility. Legacy Thread List restores the original + grouped list. Plan Mode restores the Build/Plan control. Progressive Thread History loads + large conversations in pages. ); @@ -598,7 +618,10 @@ function AppSettingsSection() { if (updateInFlight.current) return; updateInFlight.current = true; try { + // The user asked for this restart by tapping the version row, so it may + // apply immediately instead of prompting. await runAppUpdateCheck({ + applyMode: "immediate", onFailure: (message) => Alert.alert("Update failed", message), onStateChange: setUpdateState, }); @@ -621,11 +644,15 @@ function AppSettingsSection() { ? "Checking…" : updateState === "downloading" ? "Downloading…" - : updateState === "restarting" - ? "Restarting…" - : updateState === "current" - ? "Up to date" - : null; + : // "ready" appears only when this check joined an in-flight background-mode + // check; that download installs at the next backgrounding. + updateState === "ready" + ? "Update ready" + : updateState === "restarting" + ? "Restarting…" + : updateState === "current" + ? "Up to date" + : null; const versionRow = ( diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index d1a8f3739..65bb896d0 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -18,7 +18,7 @@ import { ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, -} from "../../components/ComposerToolbarTrigger"; +} from "../../components/ComposerToolbar"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 2b257ec17..bc4157035 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -7,11 +7,13 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useThemeColor } from "../../lib/useThemeColor"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220); +const OVERLAY_TOP_GAP = 8; const AnimatedLiquidGlassView = Animated.createAnimatedComponent(LiquidGlassView); export function GitActionProgressOverlay(props: { @@ -52,7 +54,7 @@ export function GitActionProgressOverlay(props: { entering={isLiquidGlassSupported ? undefined : FadeIn.duration(200)} exiting={FadeOut.duration(150)} className="absolute inset-x-3 z-[100]" - style={{ top: insets.top + 48 }} + style={{ top: insets.top + APP_BAR_HEIGHT + OVERLAY_TOP_GAP }} pointerEvents="box-none" > diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx new file mode 100644 index 000000000..68bf0d05c --- /dev/null +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -0,0 +1,473 @@ +import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { LegendList } from "@legendapp/list/react-native"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import * as Haptics from "expo-haptics"; +import { useNavigation } from "@react-navigation/native"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + ActivityIndicator, + Alert, + Platform, + Pressable, + ScrollView, + Switch, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { useFontFamily } from "../../lib/useFontFamily"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { vcsEnvironment } from "../../state/vcs"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../layout/native-mail-search-toolbar"; +import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; +import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; + +function SelectionRow(props: { + readonly icon?: "arrow.triangle.branch" | "desktopcomputer"; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly selected: boolean; + readonly isLast?: boolean; + readonly subtitle?: string; + readonly title: string; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + const checkmarkColor = useThemeColor("--color-icon"); + + return ( + + {props.icon ? ( + + ) : null} + + + {props.title} + + {props.subtitle ? ( + + {props.subtitle} + + ) : null} + + {props.selected ? ( + + ) : null} + + ); +} + +function ToggleRow(props: { + readonly title: string; + readonly value: boolean; + readonly onValueChange: (value: boolean) => void; +}) { + return ( + + + {props.title} + + + + ); +} + +function BranchSelectionRow(props: { + readonly badge: string | null; + readonly branch: VcsRef; + readonly disabled: boolean; + readonly isFirst: boolean; + readonly isLast: boolean; + readonly onSelect: (branch: VcsRef) => void; + readonly selected: boolean; +}) { + const onPress = useCallback(() => props.onSelect(props.branch), [props.branch, props.onSelect]); + + return ( + + + + ); +} + +function PickerSurface(props: { readonly children: ReactNode }) { + return {props.children}; +} + +export function NewTaskEnvironmentPickerRouteScreen() { + const flow = useNewTaskFlow(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + + return ( + + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + {flow.environments.map((environment, index) => ( + { + void Haptics.selectionAsync(); + flow.selectEnvironment(environment.environmentId); + navigation.goBack(); + }} + selected={flow.selectedEnvironmentId === environment.environmentId} + title={environment.environmentLabel} + /> + ))} + + + + ); +} + +export function NewTaskBranchPickerRouteScreen() { + const flow = useNewTaskFlow(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const placeholderColor = useThemeColor("--color-placeholder"); + const foregroundColor = useThemeColor("--color-foreground"); + const fontFamily = useFontFamily("regular"); + const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); + const [switchingBranchName, setSwitchingBranchName] = useState(null); + const selectingBranchNameRef = useRef(null); + const allowSelectionNavigationRef = useRef(false); + const mountedRef = useRef(true); + const screenTitle = flow.workspaceMode === "worktree" ? "Base branch" : "Branch"; + const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const selectedBranchName = + flow.selectedBranchName ?? + flow.availableBranches.find((branch) => branch.current)?.name ?? + flow.availableBranches.find((branch) => branch.isDefault)?.name ?? + null; + const branchListContentStyle = useMemo( + () => ({ + paddingBottom: usesNativeMailSearchToolbar + ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16 + : Platform.OS === "ios" + ? 16 + : Math.max(insets.bottom, 16) + 16, + paddingHorizontal: 16, + paddingTop: 12, + }), + [insets.bottom, usesNativeMailSearchToolbar], + ); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + flow.setBranchQuery(""); + }; + }, [flow.setBranchQuery]); + + useEffect( + () => + navigation.addListener("beforeRemove", (event) => { + if (selectingBranchNameRef.current !== null && !allowSelectionNavigationRef.current) { + event.preventDefault(); + } + }), + [navigation], + ); + + const selectBranch = useCallback( + async (branch: VcsRef) => { + if (selectingBranchNameRef.current !== null) { + return; + } + selectingBranchNameRef.current = branch.name; + void Haptics.selectionAsync(); + + try { + let selectedBranch = branch; + const needsCheckout = shouldCheckoutNewTaskBranch({ + branchIsCurrent: branch.current, + branchWorktreePath: branch.worktreePath, + workspaceMode: flow.workspaceMode, + }); + if (needsCheckout && flow.selectedProject) { + setSwitchingBranchName(branch.name); + const result = await switchRef({ + environmentId: flow.selectedProject.environmentId, + input: { + cwd: flow.selectedProject.workspaceRoot, + refName: branch.name, + }, + }); + if (result._tag === "Failure") { + if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not switch branch", + error instanceof Error ? error.message : "The branch could not be checked out.", + ); + } + return; + } + selectedBranch = { + ...branch, + current: true, + isRemote: false, + name: result.value.refName ?? branch.name, + }; + } + + // The checkout has already changed the repository. Persist the matching + // draft selection even if the native sheet was dismissed while the + // command was in flight; only visible-screen work is focus-gated below. + flow.selectBranch(selectedBranch); + if (!mountedRef.current || !navigation.isFocused()) { + return; + } + flow.setBranchQuery(""); + allowSelectionNavigationRef.current = true; + navigation.goBack(); + } finally { + selectingBranchNameRef.current = null; + allowSelectionNavigationRef.current = false; + if (mountedRef.current) { + setSwitchingBranchName(null); + } + } + }, + [ + flow.selectBranch, + flow.selectedProject, + flow.setBranchQuery, + flow.workspaceMode, + navigation, + switchRef, + ], + ); + + const renderBranch = useCallback( + ({ item, index }: { readonly item: VcsRef; readonly index: number }) => ( + + ), + [ + flow.filteredBranches.length, + flow.selectedProject, + selectBranch, + selectedBranchName, + switchingBranchName, + ], + ); + + const branchListHeader = + flow.workspaceMode === "worktree" ? ( + + + + ) : null; + + const branchContent = + flow.filteredBranches.length === 0 ? ( + + {branchListHeader} + + {flow.branchesLoading ? : null} + + {flow.branchesLoading + ? "Loading branches…" + : flow.branchesError + ? flow.branchesError + : flow.branchQuery + ? "No matching branches" + : "No branches available"} + + {!flow.branchesLoading && flow.branchesError ? ( + + Try again + + ) : null} + + + ) : ( + + `${branch.remoteName ?? "local"}:${branch.name}:${branch.worktreePath ?? ""}` + } + ListHeaderComponent={branchListHeader} + ListFooterComponent={ + flow.branchesFetchingNextPage ? ( + + + + ) : null + } + onEndReached={flow.hasMoreBranches ? flow.loadMoreBranches : undefined} + onEndReachedThreshold={0.35} + renderItem={renderBranch} + showsVerticalScrollIndicator={false} + /> + ); + + if (Platform.OS === "android") { + return ( + + + navigation.goBack()} /> + + + + {branchContent} + + ); + } + + return ( + <> + [ + createNativeMailSearchToolbarItem({ + onSearchTextChange: flow.setBranchQuery, + placeholder: "Find a branch", + searchTextChangeId: "new-task-branch-search-text", + showsSearchDismissButton: true, + }), + ] + : undefined, + headerSearchBarOptions: usesNativeMailSearchToolbar + ? undefined + : { + allowToolbarIntegration: true, + autoCapitalize: "none", + hideNavigationBar: false, + obscureBackground: false, + placeholder: "Find a branch", + onChangeText: (event) => { + flow.setBranchQuery(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + flow.setBranchQuery(""); + }, + }, + }} + /> + {usesNativeMailSearchToolbar ? null : ( + + + + )} + {branchContent} + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 1ece23ca0..baa28d7b4 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,9 +1,14 @@ -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { - KeyboardAvoidingView, + StackActions, + useFocusEffect, + useNavigation, + usePreventRemove, +} from "@react-navigation/native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native"; +import { + KeyboardController, KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; @@ -11,7 +16,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; -import { EnvironmentId } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -19,22 +23,24 @@ import { import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { + ComposerInlineControl, ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, - ComposerToolbarTrigger, -} from "../../components/ComposerToolbarTrigger"; +} from "../../components/ComposerToolbar"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; -import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; import { ComposerSurface } from "./ThreadComposer"; -import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; -import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; +import { + useThreadSettingsSheetPresentation, + type NavigationWithFinishTransitioning, +} from "./use-thread-settings-sheet-presentation"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; -import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, @@ -49,21 +55,33 @@ import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; -import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; +import { useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; import { resolveDraftProjectSelection } from "./new-task-project-selection"; +import { + resolveNewTaskBranchLabel, + resolveNewTaskWorkspaceLabel, +} from "./new-task-context-presentation"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; -function formatWorkspaceLabel(input: { - readonly workspaceMode: string; - readonly currentBranchName: string | null; - readonly selectedBranchName: string | null; -}): string { - const branchName = input.selectedBranchName ?? input.currentBranchName; - if (input.workspaceMode === "worktree") { - return branchName ? `New worktree · ${branchName}` : "New worktree"; +function NewTaskWorkspaceIcon(props: { + readonly workspaceMode: "local" | "worktree"; + readonly worktreePath: string | null; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + + if (props.workspaceMode === "local" && props.worktreePath === null) { + return ; } - return branchName ? `Current · ${branchName}` : "Current checkout"; + + return ( + + + + + + + ); } export function NewTaskDraftScreen(props: { @@ -90,7 +108,8 @@ export function NewTaskDraftScreen(props: { const insets = useSafeAreaInsets(); const colorScheme = useColorScheme(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); - const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); + const controlsBottomPadding = Math.max(insets.bottom, 10); + const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); const selectedEnvironmentServerConfig = useEnvironmentServerConfig( @@ -108,6 +127,49 @@ export function NewTaskDraftScreen(props: { editorRef: promptInputRef, isEditorFocused: isComposerFocused, }); + useEffect(() => { + if (Platform.OS !== "ios") { + return; + } + + navigation.getParent()?.setOptions({ gestureEnabled: !isKeyboardVisible }); + }, [isKeyboardVisible, navigation]); + useEffect(() => { + return () => { + if (Platform.OS === "ios") { + navigation.getParent()?.setOptions({ gestureEnabled: true }); + } + }; + }, [navigation]); + const settingsRoutePresentedRef = useRef(false); + useEffect(() => { + if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { + return; + } + + settingsRoutePresentedRef.current = true; + navigation.dispatch(StackActions.push("ThreadSettings")); + }, [navigation, settingsSheetPresentation.isVisible]); + useFocusEffect( + useCallback(() => { + if (!settingsRoutePresentedRef.current) { + return; + } + + settingsRoutePresentedRef.current = false; + settingsSheetPresentation.onDismissed(); + }, [settingsSheetPresentation.onDismissed]), + ); + useEffect( + () => + // UIKit's completion callback for the sheet dismissal, surfaced by the + // native-stack patch. This is when the queued keyboard restore runs. + (navigation as unknown as NavigationWithFinishTransitioning).addListener( + "finishTransitioning", + settingsSheetPresentation.onStackTransitionsFinished, + ), + [navigation, settingsSheetPresentation.onStackTransitionsFinished], + ); const [importingShareKey, setImportingShareKey] = useState(null); const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); @@ -227,9 +289,9 @@ export function NewTaskDraftScreen(props: { }, [props.pendingTaskId, cancelEditingPendingTask]); const foregroundColor = useThemeColor("--color-foreground"); + const projectUnderlineColor = useThemeColor("--color-foreground-muted"); const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); - const headlineText = useScaledTextRole("headline"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; @@ -315,7 +377,7 @@ export function NewTaskDraftScreen(props: { return; } loadedBranchesProjectKeyRef.current = projectKey; - void flow.loadBranches(); + flow.loadBranches(); }, [flow.loadBranches, selectedProject]); useEffect(() => { @@ -517,121 +579,6 @@ export function NewTaskDraftScreen(props: { shareImportAttempt, ]); - useEffect(() => { - // Android starts with the collapsed composer pill (like an open thread) - // and only expands/focuses when tapped. - if (!selectedProject || Platform.OS === "android") { - return; - } - - let focusFrame: ReturnType | null = null; - const interaction = InteractionManager.runAfterInteractions(() => { - focusFrame = requestAnimationFrame(() => { - // The delayed focus can land after the settings sheet opened, which - // would pop the keyboard underneath its modal. - if (!settingsSheetPresentation.isActiveRef.current) { - promptInputRef.current?.focus(); - } else { - settingsSheetPresentation.restoreFocusAfterSave(); - } - }); - }); - - return () => { - interaction.cancel(); - if (focusFrame !== null) { - cancelAnimationFrame(focusFrame); - } - }; - }, [ - selectedProject, - settingsSheetPresentation.isActiveRef, - settingsSheetPresentation.restoreFocusAfterSave, - ]); - - const environmentMenuActions = useMemo( - () => - flow.environments.map((environment) => ({ - id: `environment:${environment.environmentId}`, - title: environment.environmentLabel, - attributes: isIncomingShareTransferPending ? { disabled: true } : undefined, - state: - flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined, - })), - [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], - ); - - const providerOptionDescriptors = useMemo( - () => - resolveProviderOptionDescriptors({ - capabilities: flow.selectedModelOption?.capabilities, - selections: flow.selectedModel?.options, - }), - [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], - ); - - const workspaceMenuActions = useMemo(() => { - const branchActions = - flow.availableBranches.length === 0 - ? [ - { - id: "workspace:branch:none", - title: flow.branchesLoading ? "Loading branches…" : "No branches available", - attributes: { disabled: true }, - }, - ] - : flow.availableBranches.slice(0, 12).map((branch) => { - const badge = branchBadgeLabel({ - branch, - project: flow.selectedProject, - }); - - return { - id: `workspace:branch:${branch.name}`, - title: branch.name, - subtitle: badge ? badge.toUpperCase() : undefined, - state: flow.selectedBranchName === branch.name ? ("on" as const) : undefined, - }; - }); - - return [ - { - id: "workspace:mode", - title: "Mode", - subtitle: flow.workspaceMode === "local" ? "Current checkout" : "New worktree", - subactions: (["local", "worktree"] as const).map((value) => ({ - id: `workspace:mode:${value}`, - title: value === "local" ? "Current checkout" : "New worktree", - state: flow.workspaceMode === value ? ("on" as const) : undefined, - })), - }, - { - id: "workspace:branch", - title: "Branch", - subtitle: flow.selectedBranchName ?? "Choose branch", - subactions: branchActions, - }, - ...(flow.workspaceMode === "worktree" - ? [ - { - id: "workspace:start-from-origin", - title: "Start from origin", - subtitle: "Base the worktree on the latest origin branch", - image: "arrow.triangle.pull", - state: flow.startFromOrigin ? ("on" as const) : undefined, - }, - ] - : []), - ]; - }, [ - flow.availableBranches, - flow.branchesLoading, - flow.selectedBranchName, - flow.selectedProject, - flow.startFromOrigin, - flow.workspaceMode, - ]); - const selectedEnvironmentLabel = flow.environments.find( (environment) => environment.environmentId === flow.selectedEnvironmentId, @@ -640,50 +587,17 @@ export function NewTaskDraftScreen(props: { flow.availableBranches.find((branch) => branch.current)?.name ?? flow.availableBranches.find((branch) => branch.isDefault)?.name ?? null; - const settingsSummaryLabel = threadSettingsSummaryLabel({ - modelLabel: flow.selectedModelOption?.label ?? "Model", - optionDescriptors: providerOptionDescriptors, - runtimeMode: flow.runtimeMode, - interactionMode: flow.interactionMode, + const selectedBranchName = flow.selectedBranchName ?? currentBranchName; + const selectedBranchLabel = resolveNewTaskBranchLabel({ + branchName: selectedBranchName, + startFromOrigin: flow.startFromOrigin, + workspaceMode: flow.workspaceMode, }); - const workspaceLabel = useMemo( - () => - formatWorkspaceLabel({ - currentBranchName, - selectedBranchName: flow.selectedBranchName, - workspaceMode: flow.workspaceMode, - }), - [currentBranchName, flow.selectedBranchName, flow.workspaceMode], - ); - function handleEnvironmentMenuAction(event: string) { - if (isIncomingShareTransferPending || !event.startsWith("environment:")) { - return; - } - flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); - } - - function handleWorkspaceMenuAction(event: string) { - if (isIncomingShareTransferPending) { - return; - } - if (event.startsWith("workspace:mode:")) { - flow.setWorkspaceMode( - event.slice("workspace:mode:".length) as Parameters[0], - ); - return; - } - if (event === "workspace:start-from-origin") { - flow.setStartFromOrigin(!flow.startFromOrigin); - return; - } - if (event.startsWith("workspace:branch:")) { - const branchName = event.slice("workspace:branch:".length); - const branch = flow.availableBranches.find((candidate) => candidate.name === branchName); - if (branch) { - flow.selectBranch(branch); - } - } - } + const workspaceLabel = resolveNewTaskWorkspaceLabel({ + workspaceMode: flow.workspaceMode, + worktreePath: flow.selectedWorktreePath, + }); + const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; async function handlePickImages(): Promise { if (isIncomingShareTransferPending) { @@ -733,7 +647,9 @@ export function NewTaskDraftScreen(props: { draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; - const interactionMode = draft.interactionMode ?? flow.interactionMode; + const interactionMode = flow.planModeEnabled + ? (draft.interactionMode ?? flow.interactionMode) + : "default"; const initialMessageText = draft.text.trim(); if ( @@ -793,6 +709,7 @@ export function NewTaskDraftScreen(props: { // -only Activity start. If creation fails, the token registration's replay // finds no work and ends the card within seconds. armAgentAwarenessLiveActivityForLocalWork({ + environmentId: selectedProject.environmentId, threadTitle: deriveThreadTitleFromPrompt(initialMessageText), projectTitle: selectedProject.title, }); @@ -851,7 +768,7 @@ export function NewTaskDraftScreen(props: { if (!selectedProject) { return ( - + {Platform.OS === "android" ? ( <> @@ -866,11 +783,6 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const isDarkMode = colorScheme === "dark"; - // Android expansion follows native editor focus so relayout cannot race - // the touch gesture that opens the keyboard. - // The settings sheet dismisses the keyboard, so its flag keeps the Android - // draft composer expanded through the blur (mirrors ThreadComposer). - const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive; const canStart = Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && @@ -882,235 +794,279 @@ export function NewTaskDraftScreen(props: { const promptEditor = ( setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} - placeholder={`Describe a coding task in ${selectedProject.title}`} - // Same collapsed centering as ThreadComposer: native vertical gravity - // in a pill-height box. - singleLineCentered={!isExpanded} - contentInsetVertical={isAndroid ? 0 : undefined} - style={ - isAndroid - ? isExpanded - ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 } - : { height: 36 } - : { flex: 1, minHeight: 0 } - } - textStyle={ - isAndroid - ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily } - : headlineText - } + placeholder="Ask anything…" + singleLineCentered={false} + contentInsetVertical={0} + style={{ + minHeight: 72, + maxHeight: 160, + paddingHorizontal: 4, + paddingVertical: 4, + }} + textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }} /> ); - const toolbarPills = ( - <> - void handlePickImages()} - showChevron={false} - disabled={isIncomingShareTransferPending} - /> - { + void KeyboardController.dismiss({ animated: true }); + const parentNavigation = navigation.getParent(); + if (parentNavigation) { + parentNavigation.goBack(); + return; + } + navigation.goBack(); + }; + const chooseProject = () => { + if (isIncomingShareTransferPending) { + return; + } + promptInputRef.current?.blur(); + void KeyboardController.dismiss({ animated: true }); + navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId })); + }; + const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => { + if (isIncomingShareTransferPending) { + return; + } + promptInputRef.current?.blur(); + void KeyboardController.dismiss({ animated: true }); + navigation.dispatch(StackActions.push(routeName)); + }; + + const hero = ( + + + + What should we build + + + in + + + {selectedProject.title} + + + ? + + + + } - label={settingsSummaryLabel} - maxWidth={320} - onPress={settingsSheetPresentation.open} + icon="desktopcomputer" + label={`on ${selectedEnvironmentLabel}`} + maxWidth={260} + onPress={ + flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined + } + showChevron={flow.environments.length > 1} + static={flow.environments.length <= 1} /> - handleEnvironmentMenuAction(nativeEvent.event)} - > - - - handleWorkspaceMenuAction(nativeEvent.event)} + + ); + const heroViewport = ( + + - - - + {hero} + + ); - const settingsSheet = ( - flow.setSelectedModelKey(option.key, option.selection.options)} - optionDescriptors={providerOptionDescriptors} - onUpdateOptionSelections={flow.setSelectedModelOptions} - runtimeMode={flow.runtimeMode} - onUpdateRuntimeMode={flow.setRuntimeMode} - /> + const workspaceControls = ( + + + } + label={workspaceLabel} + maxWidth={flow.workspaceMode === "local" ? 220 : 148} + onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} + showChevron={false} + /> + + openContextPicker("NewTaskBranch")} + /> + ); - const startButton = ( - void handleStart()} - variant="primary" - showChevron={false} - disabled={!canStart} - /> + const composerDock = ( + + {workspaceControls} + + + {flow.attachments.length > 0 ? ( + + undefined : flow.removeAttachment} + /> + + ) : null} + + {promptEditor} + + + + void handlePickImages()} + showChevron={false} + /> + + } + label={flow.selectedModelOption?.label ?? "Choose model"} + maxWidth={152} + onPress={settingsSheetPresentation.open} + /> + {flow.planModeEnabled ? ( + + flow.setInteractionMode(flow.interactionMode === "plan" ? "default" : "plan") + } + showChevron={false} + /> + ) : null} + + void handleStart()} + showChevron={false} + variant="primary" + /> + + + ); if (isAndroid) { - // The draft is a thread that doesn't exist yet, so it mirrors the thread - // page: in-screen header, empty feed canvas above, and the same floating - // composer chrome as ThreadComposer (collapsed pill → expanded card). - // - // Composer positioning mirrors ThreadDetailScreen's floating overlay - // (KeyboardStickyView, absolute bottom overlay) rather than - // KeyboardAvoidingView's automaticOffset+padding: automaticOffset - // resolves the composer's on-screen frame via a native - // viewPositionInWindow measurement, which this app's Android - // edge-to-edge setup (KeyboardProvider's native content-view margin - // handling neutralizes windowSoftInputMode="adjustResize" while active) - // makes unreliable — the composer stayed under the keyboard instead of - // translating above it. KeyboardStickyView sticks directly to the - // animated keyboard height instead, sidestepping that measurement. return ( - + - navigation.goBack()} /> - - + + {heroViewport} - - - {isExpanded && flow.attachments.length > 0 ? ( - - undefined : flow.removeAttachment - } - /> - - ) : null} - {promptEditor} - {!isExpanded ? ( - void handleStart()} - /> - ) : null} - - - {isExpanded ? ( - - - {toolbarPills} - - {startButton} - - ) : null} - + {composerDock} - {settingsSheet} ); } return ( - - - - - {promptEditor} + + + + + - - {flow.attachments.length > 0 ? ( - - undefined : flow.removeAttachment} - imageSize={88} - imageBorderRadius={20} - /> - - ) : null} - - - {toolbarPills} - - {startButton} - - - - {settingsSheet} + {heroViewport} + + {composerDock} + ); } diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 7f4a68c08..94304448e 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,8 +1,13 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { + StackActions, + useIsFocused, + useNavigation, + type StaticScreenProps, +} from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -14,10 +19,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useProjects } from "../../state/entities"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; -import { scopedProjectKey } from "../../lib/scopedEntities"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { useNewTaskFlow } from "./new-task-flow-provider"; +import { getProjectScopeSelectionTarget } from "./new-task-project-selection"; type NewTaskRouteParams = { readonly incomingShareId?: string | string[]; @@ -80,7 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); - const { projectScopes } = useNewTaskFlow(); + const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow(); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); const isFocused = useIsFocused(); @@ -88,7 +93,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps>(() => new Set()); const { getShare, releaseShareReservation } = useIncomingShare(); const routeShareId = Array.isArray(route.params?.incomingShareId) ? route.params.incomingShareId[0] @@ -126,27 +130,22 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { - const next = new Set(current); - if (next.has(groupKey)) { - next.delete(groupKey); - } else { - next.add(groupKey); - } - return next; - }); + }), + ); } useEffect(() => { @@ -169,15 +168,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.navigate("NewTaskSheet", { screen: "AddProject" }), + onPress: () => navigation.dispatch(StackActions.push("AddProject")), }, ] : [] @@ -223,7 +221,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.navigate("NewTaskSheet", { screen: "AddProject" })} + onPress={() => navigation.dispatch(StackActions.push("AddProject"))} separateBackground /> ) : null} @@ -263,7 +261,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.navigate("NewTaskSheet", { screen: "AddProject" })} + onPress={() => navigation.dispatch(StackActions.push("AddProject"))} > Add new project @@ -275,22 +273,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {projectScopes.map((scope, scopeIndex) => { const hasMultipleProjects = scope.projects.length > 1; - const expanded = expandedGroupKeys.has(scope.key); - const singleProject = hasMultipleProjects ? null : scope.projects[0]; + const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId); return ( 0 && "border-t border-border-subtle")} > { - if (singleProject) { - void selectProject(singleProject); - } else { - toggleGroup(scope.key); - } - }} + disabled={reservedDestinationProject !== null} + onPress={() => void selectProject(selectionTarget)} className="flex-row items-center gap-3 bg-card px-4 py-3.5" > @@ -311,52 +302,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {hasMultipleProjects ? `${scope.projects.length} workspaces` - : singleProject?.workspaceRoot} + : selectionTarget.workspaceRoot} - {hasMultipleProjects && expanded - ? scope.projects.map((project) => ( - void selectProject(project)} - className="flex-row items-center gap-3 border-t border-border-subtle bg-card py-3 pr-4 pl-10" - > - - - - {project.title} - - - {project.workspaceRoot} - - - - - )) - : null} ); })} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6ce42aeb1..55a4eed56 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,4 +1,3 @@ -import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentId, MessageId, @@ -14,7 +13,7 @@ import { serializeComposerFileLink, type ComposerTrigger, } from "@t3tools/shared/composerTrigger"; -import * as Haptics from "expo-haptics"; +import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -41,18 +40,19 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { GlassSurface } from "../../components/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle, type ComposerEditorSelection, } from "../../components/ComposerEditor"; import { + ComposerInlineControl, ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, - ComposerToolbarTrigger, -} from "../../components/ComposerToolbarTrigger"; -import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; +} from "../../components/ComposerToolbar"; +import { ControlPill } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; @@ -63,15 +63,17 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; -import { - applyProviderOptionSelection, - resolveProviderOptionDescriptors, -} from "../../lib/providerOptions"; +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; -import { buildThreadSettingsMenu } from "./thread-settings-menu"; -import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; -import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; +import { + type ExistingThreadSettingsRouteSession, + useExistingThreadSettingsRoutePresentation, +} from "./ThreadSettingsSheet"; +import { + useThreadSettingsSheetPresentation, + type NavigationWithFinishTransitioning, +} from "./use-thread-settings-sheet-presentation"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -83,7 +85,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60; * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). * Used by the parent to compute the larger feed bottom inset when the composer is focused. */ -export const COMPOSER_EXPANDED_CHROME = 174; +export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { readonly draftMessage: string; @@ -103,7 +105,6 @@ export interface ThreadComposerProps { readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; - readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; readonly editorRef?: RefObject; @@ -123,8 +124,8 @@ export interface ThreadComposerProps { } /** - * The pill / card container — renders as LiquidGlassView on supported - * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. + * The pill / card container — renders with Expo's native GlassView on supported + * iOS 26+ devices and keeps the existing opaque fallback elsewhere. * Exported so NewTaskDraftScreen can render the same composer chrome. */ // One timing for every piece of the expanded↔compact morph so the surface, @@ -140,6 +141,8 @@ export function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; readonly isDarkMode: boolean; + /** Existing thread composers morph between pill and card layouts. */ + readonly animateLayout?: boolean; }) { // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself // (needed to clip content to the pill shape) would clip the shadow on iOS. @@ -152,35 +155,26 @@ export function ComposerSurface(props: { elevation: 10, }; - if (isLiquidGlassSupported) { - return ( - - - {props.children} - - - ); - } - return ( - - + {props.children} - + ); } @@ -271,6 +265,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( }); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { + const navigation = useNavigation(); const isDarkMode = useColorScheme() === "dark"; const foregroundColor = useThemeColor("--color-foreground"); const bodyText = useScaledTextRole("body"); @@ -281,14 +276,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer editorRef: inputRef, isEditorFocused: isFocused, }); + const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation(); + const settingsRoutePresentedRef = useRef(false); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - // Opening and closing count as active so the composer stays expanded while - // focus moves between its native editor and the settings modal. + // Opening and presentation count as active so the composer stays expanded + // while focus moves between its native editor and the settings picker. const isExpanded = isFocused || settingsSheetPresentation.isActive; const canSend = hasContent; @@ -329,12 +326,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.session?.status === "starting"; const sendLabel = - props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 - ? "Queue" - : "Send"; + props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; - const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, @@ -544,6 +538,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // after the send so its preference read and native Activity start don't // contend with the queued-message feedback on the tap frame. armAgentAwarenessLiveActivityForLocalWork({ + environmentId: props.environmentId, threadTitle: props.selectedThread.title, projectTitle: props.environmentLabel ?? "T3 Code", }); @@ -626,67 +621,71 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [currentModelOption?.capabilities, currentModelSelection.options], ); - const settingsSummaryLabel = threadSettingsSummaryLabel({ - modelLabel: currentModelOption?.label ?? currentModelSelection.model, - optionDescriptors: providerOptionDescriptors, - runtimeMode: currentRuntimeMode, - interactionMode: currentInteractionMode, - }); - - // iOS gets a native menu on the trigger pill: the everyday adjustments - // apply without resigning the keyboard, while "All Settings…" (and the - // Android trigger) still route through the sheet, which must dismiss it. - const settingsMenu = useMemo( - () => - Platform.OS === "ios" - ? buildThreadSettingsMenu({ - providerGroups: threadProviderGroups, - selectedModel: currentModelSelection, - optionDescriptors: providerOptionDescriptors, - runtimeMode: currentRuntimeMode, - }) - : null, - [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode], - ); - - const onUpdateModelSelection = props.onUpdateModelSelection; - const onUpdateRuntimeMode = props.onUpdateRuntimeMode; - const handleSettingsMenuAction = useCallback( - (eventId: string) => { - const event = settingsMenu?.events.get(eventId); - if (!event) { - return; - } - switch (event.type) { - case "select-model": - void Haptics.selectionAsync(); - onUpdateModelSelection(event.option.selection); - return; - case "set-option": { - const options = applyProviderOptionSelection(providerOptionDescriptors, { - id: event.optionId, - value: event.value, - }); - if (options) { - void Haptics.selectionAsync(); - onUpdateModelSelection({ ...currentModelSelection, options }); - } - return; - } - case "set-runtime": - void Haptics.selectionAsync(); - onUpdateRuntimeMode(event.mode); - return; - } - }, + const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id); + const settingsRouteSession = useMemo( + () => ({ + ownerId: settingsOwnerId, + providerGroups: threadProviderGroups, + selectedModel: currentModelSelection, + onSelectModel: (option) => props.onUpdateModelSelection(option.selection), + optionDescriptors: providerOptionDescriptors, + onUpdateOptionSelections: (options) => + props.onUpdateModelSelection({ ...currentModelSelection, options }), + runtimeMode: currentRuntimeMode, + onUpdateRuntimeMode: props.onUpdateRuntimeMode, + }), [ currentModelSelection, - onUpdateModelSelection, - onUpdateRuntimeMode, + currentRuntimeMode, + props.onUpdateModelSelection, + props.onUpdateRuntimeMode, providerOptionDescriptors, - settingsMenu, + settingsOwnerId, + threadProviderGroups, ], ); + const openSettings = useCallback(() => { + settingsRoutePresentation.present(settingsRouteSession); + settingsSheetPresentation.open(); + }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.open]); + + useEffect(() => { + if (settingsSheetPresentation.isActive) { + settingsRoutePresentation.present(settingsRouteSession); + } + }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]); + + useEffect(() => { + if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { + return; + } + + settingsRoutePresentedRef.current = true; + navigation.dispatch(StackActions.push("ThreadSettingsSheet")); + }, [navigation, settingsSheetPresentation.isVisible]); + + useFocusEffect( + useCallback(() => { + if (!settingsRoutePresentedRef.current) { + return; + } + + settingsRoutePresentedRef.current = false; + settingsSheetPresentation.onDismissed(); + settingsRoutePresentation.clear(settingsOwnerId); + }, [settingsOwnerId, settingsRoutePresentation.clear, settingsSheetPresentation.onDismissed]), + ); + + useEffect( + () => + // UIKit's completion callback for the sheet dismissal, surfaced by the + // native-stack patch. This is when the queued keyboard restore runs. + (navigation as unknown as NavigationWithFinishTransitioning).addListener( + "finishTransitioning", + settingsSheetPresentation.onStackTransitionsFinished, + ), + [navigation, settingsSheetPresentation.onStackTransitionsFinished], + ); return ( ) : null} - - - {isExpanded ? ( - // Toolbar row — matches draft page layout (expanded only) - - + {isExpanded ? ( + void props.onPickDraftImages()} showChevron={false} /> - {settingsMenu ? ( - handleSettingsMenuAction(nativeEvent.event)} - > - - } - label={settingsSummaryLabel} - maxWidth={320} - /> - - ) : ( - - } - label={settingsSummaryLabel} - maxWidth={320} - onPress={settingsSheetPresentation.open} - /> - )} + + } + label={currentModelOption?.label ?? currentModelSelection.model} + maxWidth={152} + onPress={openSettings} + /> {showStopAction ? ( - - ) : null} + ) : null} + {/* Queue count */} {props.queueCount > 0 ? ( @@ -915,21 +898,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - props.onUpdateModelSelection(option.selection)} - optionDescriptors={providerOptionDescriptors} - onUpdateOptionSelections={(options) => - props.onUpdateModelSelection({ ...currentModelSelection, options }) - } - runtimeMode={currentRuntimeMode} - onUpdateRuntimeMode={props.onUpdateRuntimeMode} - /> - void } | null; - readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; @@ -255,7 +255,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } }, []); const windowHeight = useWindowDimensions().height; - const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; + const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + IOS_NAV_BAR_HEIGHT; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef(null); @@ -738,7 +738,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread selectedThread={props.selectedThread} serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} - activeThreadBusy={props.activeThreadBusy} environmentId={props.environmentId} projectCwd={props.projectWorkspaceRoot} bottomInset={composerBottomInset} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 66f75243c..85a3fd41e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -47,6 +47,7 @@ import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; +import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; @@ -1402,7 +1403,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const userBubbleMaxWidth = contentWidth * 0.85; const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); - const topContentInset = props.contentTopInset ?? insets.top + 44; + const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT; const bottomContentInset = props.contentBottomInset ?? 18; const usesNativeAutomaticInsets = props.usesAutomaticContentInsets === true && Platform.OS === "ios"; @@ -1415,7 +1416,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // header-providing screen) and fall back to the standard iOS bar height. const navigationHeaderHeight = useContext(HeaderHeightContext); const anchorTopInset = usesNativeAutomaticInsets - ? navigationHeaderHeight || insets.top + 44 + ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT : topContentInset; const iconSubtleColor = useThemeColor("--color-icon-subtle"); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index b03ba9468..12e974fe8 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -10,6 +10,7 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -29,6 +30,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -214,6 +216,10 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const autoSettleOnMerge = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.autoSettleOnMerge !== false; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -411,8 +417,8 @@ function ThreadNavigationSidebarPane( // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. - // PR states stream in per-row; merged/closed PRs auto-settle their thread - // on the next partition. + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); @@ -546,6 +552,7 @@ function ThreadNavigationSidebarPane( searchQuery: props.searchQuery, matchedThreadKeys, changeRequestStateByKey, + autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -557,6 +564,7 @@ function ThreadNavigationSidebarPane( }); }, [ changeRequestStateByKey, + autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index b57cf5374..d2af99e98 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -829,7 +829,6 @@ function ThreadRouteContent( selectedThreadDetailState.history.loading === "after" } loadEarlier={loadEarlierTurns} - activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index f87a41e0e..637b7b5ee 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,92 +1,113 @@ import type { ModelSelection, - ProviderInteractionMode, ProviderOptionDescriptor, ProviderOptionSelection, RuntimeMode, } from "@t3tools/contracts"; +import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; +import { AnimatedLegendList } from "@legendapp/list/reanimated"; +import { HeaderHeightContext } from "@react-navigation/elements"; import { getProviderOptionCurrentLabel, getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; +import { + createNativeStackNavigator, + type NativeStackNavigationProp, +} from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; -import { useCallback, useEffect, useRef, useState } from "react"; import { - Modal, - Platform, - Pressable, - ScrollView, - Switch, - useWindowDimensions, - View, -} from "react-native"; + createContext, + use, + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Platform, Pressable, ScrollView, Switch, TextInput, View } from "react-native"; +import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; -import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; +import { applyProviderOptionSelection } from "../../lib/providerOptions"; +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useThemeColor } from "../../lib/useThemeColor"; -import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu"; -import { pendingModelAfterPress } from "./thread-settings-sheet-state"; -import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; +import { + NativeHeaderToolbar, + NativeStackScreenOptions, + nativeHeaderScrollEdgeEffects, +} from "../../native/StackHeader"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { + NATIVE_SHEET_SURFACE_COLOR, + NATIVE_SHEET_SURFACE_CONTENT_STYLE, +} from "../../native/sheet-surface"; +import { useNewTaskFlow } from "./new-task-flow-provider"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../layout/native-mail-search-toolbar"; +import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; +import { + modelMatchesCatalogQuery, + pendingModelAfterPress, + providerSectionIsCollapsed, +} from "./thread-settings-sheet-state"; /** - * The everyday harnesses stay expanded; every other provider (OpenRouter - * catalogs and friends) folds behind its header so a 300-model catalog can't - * bury the list. + * Everyday harnesses start expanded; every other provider (OpenRouter catalogs + * and friends) starts folded so a 300-model catalog cannot bury the list. All + * provider headers remain user-collapsible. */ const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set(["claudeAgent", "codex"]); - /** - * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, - * covering model, provider options, runtime mode, and plan mode in one label. + * Keep measured row changes stable, but let catalog mutations use the list's + * native bounds so a filtered catalog that underflows returns to the top. */ -export function threadSettingsSummaryLabel(input: { - readonly modelLabel: string; - readonly optionDescriptors: ReadonlyArray; - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode; -}): string { - const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode); - return [ - input.modelLabel, - ...providerOptionValueLabels(input.optionDescriptors), - ...(runtime ? [runtime.shortLabel] : []), - ...(input.interactionMode === "plan" ? ["Plan"] : []), - ].join(" · "); -} - +const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = { + data: false, + size: true, +} as const; +const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180); +const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140); +const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120); +const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180); +const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140); +const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100); +const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects( + Platform.OS, + Platform.Version, +); function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; readonly onPress: () => void; + readonly isFirst: boolean; + readonly isLast: boolean; }) { - const primaryFg = useThemeColor("--color-primary-foreground"); + const checkmarkColor = useThemeColor("--color-icon"); return ( - + {props.option.label} {props.option.isDefault ? ( @@ -101,17 +122,19 @@ function ModelRow(props: { ) : null} {props.selected ? ( - + ) : null} ); } -/** - * Provider section header with the harness logo. Secondary providers render - * as a tappable fold (count + chevron while collapsed); primary providers - * and the group holding the current selection are static headers. - */ +/** Provider catalog header with its harness logo and disclosure state. */ function ProviderHeader(props: { readonly driver: string | undefined; readonly label: string; @@ -121,24 +144,10 @@ function ProviderHeader(props: { readonly onToggle: () => void; }) { const iconSubtle = useThemeColor("--color-icon-subtle"); - return ( - + const content = ( + <> - - {props.label} - + {props.label} {props.collapsible ? ( <> @@ -149,13 +158,33 @@ function ProviderHeader(props: { ) : null} ) : null} - + + ); + + if (props.collapsible) { + return ( + + {content} + + ); + } + + return ( + + {content} + ); } @@ -163,18 +192,17 @@ function ProviderHeader(props: { function DisclosureRow(props: { readonly label: string; readonly value: string | undefined; - readonly disabled?: boolean; readonly onPress: () => void; + readonly isLast?: boolean; }) { const iconSubtle = useThemeColor("--color-icon-subtle"); return ( {props.label} @@ -192,31 +220,37 @@ function DisclosureRow(props: { /** Single option inside a submenu panel. */ function ChoiceRow(props: { readonly label: string; + readonly description?: string; readonly selected: boolean; readonly onPress: () => void; + readonly isLast: boolean; }) { - const primaryFg = useThemeColor("--color-primary-foreground"); + const checkmarkColor = useThemeColor("--color-icon"); return ( - - {props.label} - - + + {props.label} + {props.description ? ( + {props.description} + ) : null} + {props.selected ? ( - + ) : null} ); @@ -225,60 +259,31 @@ function ChoiceRow(props: { function SwitchRow(props: { readonly label: string; readonly value: boolean; - readonly disabled?: boolean; readonly onValueChange: (value: boolean) => void; + readonly isLast?: boolean; }) { - const activeTrack = String(useThemeColor("--color-switch-active")); - const track = String(useThemeColor("--color-secondary-border")); return ( {props.label} ); } -type SubmenuPage = +type ThreadSettingsSubmenuPage = | { readonly kind: "descriptor"; readonly id: string } | { readonly kind: "runtime" }; -/** - * Unified thread settings: the sheet is the provider-grouped model list - * (primary harnesses expanded, other providers folded, legacy behind the - * top-right pill) with a Save button, plus compact disclosure rows whose - * single-choice submenus stack in a small panel over the sheet so it never - * changes size. Model changes stage until Save — while staged, the settings - * rows edit the staged model's options and Save applies everything together. - * - * Callers control which harnesses are offered via providerGroups: an - * existing thread must pass only its own provider's group, since a session - * can't switch harness mid-thread. - * - * Rendered through an RN Modal (not the root OverlayPortal) so it also - * presents above natively-presented form sheets like the new-task draft. - * Callers must dismiss the keyboard when opening — the iOS keyboard window - * would otherwise cover the lower half of the sheet. - */ -export function ThreadSettingsSheet(props: { - readonly visible: boolean; - /** - * "save" = the Save/Done button (the user is finished configuring); - * "dismiss" = backdrop, grabber, or system back. Hosts only restore the - * keyboard for "save" so a stray tap outside a control never pops it. - */ - readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void; - readonly onDismissed: () => void; +type ThreadSettingsSessionProps = { readonly providerGroups: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; @@ -286,367 +291,949 @@ export function ThreadSettingsSheet(props: { readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; -}) { - const insets = useSafeAreaInsets(); - const { height: windowHeight } = useWindowDimensions(); +}; + +export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & { + readonly ownerId: string; +}; + +type ExistingThreadSettingsRouteContextValue = { + readonly session: ExistingThreadSettingsRouteSession | null; + readonly present: (session: ExistingThreadSettingsRouteSession) => void; + readonly clear: (ownerId: string) => void; +}; + +const ExistingThreadSettingsRouteContext = + createContext(null); + +/** Bridges the active thread's settings state into the root native sheet route. */ +export function ExistingThreadSettingsRouteProvider(props: { readonly children: ReactNode }) { + const [session, setSession] = useState(null); + const present = useCallback((nextSession: ExistingThreadSettingsRouteSession) => { + setSession(nextSession); + }, []); + const clear = useCallback((ownerId: string) => { + setSession((current) => (current?.ownerId === ownerId ? null : current)); + }, []); + const value = useMemo(() => ({ session, present, clear }), [clear, present, session]); + + return ( + + {props.children} + + ); +} + +export function useExistingThreadSettingsRoutePresentation() { + const value = use(ExistingThreadSettingsRouteContext); + if (!value) { + throw new Error( + "useExistingThreadSettingsRoutePresentation must be used inside ExistingThreadSettingsRouteProvider.", + ); + } + return value; +} + +type ThreadSettingsSessionValue = { + readonly providerGroups: ReadonlyArray; + readonly runtimeMode: RuntimeMode; + readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; + readonly displayedDescriptors: ReadonlyArray; + readonly providerExpansionOverrides: ReadonlySet; + readonly hasLegacyModels: boolean; + readonly pendingModel: ModelOption | null; + readonly providerFilter: string | null; + readonly searchQuery: string; + readonly showLegacy: boolean; + readonly applyOptionChange: (id: string, value: string | boolean) => void; + readonly commitPendingModel: () => void; + readonly isApplied: (option: ModelOption) => boolean; + readonly isDisplayed: (option: ModelOption) => boolean; + readonly pressModel: (option: ModelOption) => void; + readonly setProviderFilter: (providerKey: string | null) => void; + readonly setSearchQuery: (query: string) => void; + readonly setShowLegacy: (showLegacy: boolean) => void; + readonly toggleProvider: (providerKey: string) => void; +}; + +const ThreadSettingsSessionContext = createContext(null); + +/** Owns the staged model and option state for one picker presentation. */ +function ThreadSettingsSessionProvider( + props: ThreadSettingsSessionProps & { readonly children: ReactNode }, +) { const [showLegacyToggle, setShowLegacyToggle] = useState(false); - const [expandedProviders, setExpandedProviders] = useState>(() => new Set()); + const [providerFilter, setProviderFilter] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [providerExpansionOverrides, setProviderExpansionOverrides] = useState>( + () => new Set(), + ); const [pendingModel, setPendingModel] = useState(null); - const [submenu, setSubmenu] = useState(null); - const wasPresentedRef = useRef(false); - const notifyDismissed = useCallback(() => { - if (!wasPresentedRef.current) { - return; - } - wasPresentedRef.current = false; - props.onDismissed(); - }, [props.onDismissed]); - // Every open starts fresh: no staged model, no submenu, legacy hidden, - // secondary providers folded. The sheet stays mounted between opens, so - // state would otherwise stick around. - useEffect(() => { - if (props.visible) { - wasPresentedRef.current = true; - setShowLegacyToggle(false); - setExpandedProviders(new Set()); - setPendingModel(null); - setSubmenu(null); - } else if (Platform.OS === "android" && wasPresentedRef.current) { - // React Native only emits Modal.onDismiss on iOS. Android uses no exit - // animation below, so the post-commit effect is its dismissal boundary. - notifyDismissed(); - } - }, [notifyDismissed, props.visible]); - - const isApplied = (option: ModelOption) => - option.selection.instanceId === props.selectedModel?.instanceId && - option.selection.model === props.selectedModel.model; + const isApplied = useCallback( + (option: ModelOption) => + option.selection.instanceId === props.selectedModel?.instanceId && + option.selection.model === props.selectedModel.model, + [props.selectedModel], + ); // The list highlights the staged pick; Save turns it into the applied one. - const isDisplayed = (option: ModelOption) => - pendingModel ? option.key === pendingModel.key : isApplied(option); + const isDisplayed = useCallback( + (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)), + [isApplied, pendingModel], + ); // While a model is staged, the settings rows describe and edit the staged // model's options (kept on its pending selection); Save applies model and // options together. Otherwise they edit the applied selection directly. - const displayedDescriptors = pendingModel - ? pendingModel.capabilities - ? getProviderOptionDescriptors({ - caps: pendingModel.capabilities, - selections: pendingModel.selection.options, - }) - : [] - : props.optionDescriptors; - - const hasLegacyModels = props.providerGroups.some((group) => - group.models.some((model) => model.isLegacy), - ); - // Legacy stays hidden unless the pill is toggled this open; a highlighted - // legacy model is exempted from the filter instead of forcing the whole - // legacy list visible. - const showLegacy = showLegacyToggle; - - // Stable settings rows: the union of descriptors across the primary - // harnesses' current models (plus whatever the displayed model advertises) - // always renders, with unsupported rows disabled instead of vanishing when - // the selection changes. Keyed by label, not id — Claude and Codex use - // different ids for the same "Reasoning" concept. - const descriptorTemplate = (() => { - const seen = new Map(); - for (const group of props.providerGroups) { - const driver = group.models[0]?.providerDriver; - if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) { - continue; - } - for (const model of group.models) { - if (model.isLegacy) { - continue; - } - for (const descriptor of model.capabilities?.optionDescriptors ?? []) { - if (!seen.has(descriptor.label)) { - seen.set(descriptor.label, { type: descriptor.type }); - } - } - } - } - for (const descriptor of displayedDescriptors) { - if (!seen.has(descriptor.label)) { - seen.set(descriptor.label, { type: descriptor.type }); - } - } - return [...seen.entries()].map(([label, entry]) => ({ label, ...entry })); - })(); + const displayedDescriptors = useMemo( + () => + pendingModel + ? pendingModel.capabilities + ? getProviderOptionDescriptors({ + caps: pendingModel.capabilities, + selections: pendingModel.selection.options, + }) + : [] + : props.optionDescriptors, + [pendingModel, props.optionDescriptors], + ); - const handleSave = () => { + const hasLegacyModels = useMemo( + () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)), + [props.providerGroups], + ); + const commitPendingModel = useCallback(() => { if (pendingModel) { void Haptics.selectionAsync(); props.onSelectModel(pendingModel); } - props.onClose("save"); - }; + }, [pendingModel, props.onSelectModel]); - const handleOptionChange = (id: string, value: string | boolean) => { - const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); - if (!next) { - return; - } - if (pendingModel) { - setPendingModel({ - ...pendingModel, - selection: { ...pendingModel.selection, options: next }, - }); - } else { - props.onUpdateOptionSelections(next); - } - }; + const applyOptionChange = useCallback( + (id: string, value: string | boolean) => { + const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); + if (!next) { + return; + } + if (pendingModel) { + setPendingModel({ + ...pendingModel, + selection: { ...pendingModel.selection, options: next }, + }); + } else { + props.onUpdateOptionSelections(next); + } + }, + [displayedDescriptors, pendingModel, props.onUpdateOptionSelections], + ); - const toggleProvider = (providerKey: string) => { - setExpandedProviders((current) => { + const toggleProvider = useCallback((providerKey: string) => { + setProviderExpansionOverrides((current) => { const next = new Set(current); if (!next.delete(providerKey)) { next.add(providerKey); } return next; }); - }; + }, []); + + const pressModel = useCallback( + (option: ModelOption) => { + void Haptics.selectionAsync(); + setPendingModel((current) => + pendingModelAfterPress({ + current, + pressed: option, + pressedIsApplied: isApplied(option), + }), + ); + }, + [isApplied], + ); + + const value = useMemo( + () => ({ + providerGroups: props.providerGroups, + runtimeMode: props.runtimeMode, + onUpdateRuntimeMode: props.onUpdateRuntimeMode, + displayedDescriptors, + providerExpansionOverrides, + hasLegacyModels, + pendingModel, + providerFilter, + searchQuery, + showLegacy: showLegacyToggle, + applyOptionChange, + commitPendingModel, + isApplied, + isDisplayed, + pressModel, + setProviderFilter, + setSearchQuery, + setShowLegacy: setShowLegacyToggle, + toggleProvider, + }), + [ + applyOptionChange, + commitPendingModel, + displayedDescriptors, + providerExpansionOverrides, + hasLegacyModels, + isApplied, + isDisplayed, + pendingModel, + pressModel, + providerFilter, + props.onUpdateRuntimeMode, + props.providerGroups, + props.runtimeMode, + searchQuery, + showLegacyToggle, + toggleProvider, + ], + ); + + return ( + + {props.children} + + ); +} + +function useThreadSettingsSession() { + const value = use(ThreadSettingsSessionContext); + if (!value) { + throw new Error("useThreadSettingsSession must be used inside ThreadSettingsSessionProvider."); + } + return value; +} + +type ThreadSettingsProviderCatalog = { + readonly key: string; + readonly driver: string | undefined; + readonly label: string; + readonly collapsible: boolean; + readonly collapsed: boolean; + readonly modelCount: number; + readonly models: ReadonlyArray; +}; + +type ThreadSettingsCatalogItem = + | { + readonly kind: "provider"; + readonly key: string; + readonly provider: ThreadSettingsProviderCatalog; + } + | { + readonly kind: "model"; + readonly key: string; + readonly option: ModelOption; + readonly isFirst: boolean; + readonly isLast: boolean; + } + | { + readonly kind: "empty"; + readonly key: "empty"; + } + | { + readonly kind: "options"; + readonly key: "options"; + }; + +function ThreadSettingsModelListRow(props: { + readonly option: ModelOption; + readonly isFirst: boolean; + readonly isLast: boolean; +}) { + const session = useThreadSettingsSession(); + const onPress = useCallback( + () => session.pressModel(props.option), + [props.option, session.pressModel], + ); + + return ( + + ); +} + +function ThreadSettingsProviderListHeader(props: { + readonly provider: ThreadSettingsProviderCatalog; +}) { + const session = useThreadSettingsSession(); + const onToggle = useCallback( + () => session.toggleProvider(props.provider.key), + [props.provider.key, session.toggleProvider], + ); + + return ( + + ); +} + +function useThreadSettingsCatalogItems( + session: ThreadSettingsSessionValue, +): ReadonlyArray { + return useMemo( + () => + session.providerGroups.flatMap((group) => { + if (session.providerFilter !== null && group.providerKey !== session.providerFilter) { + return []; + } + const driver = group.models[0]?.providerDriver; + const catalogModels = session.showLegacy + ? group.models + : group.models.filter((model) => !model.isLegacy || session.isDisplayed(model)); + const visibleModels = catalogModels.filter((model) => + modelMatchesCatalogQuery({ + model, + providerLabel: group.providerLabel, + query: session.searchQuery, + }), + ); + if (visibleModels.length === 0) { + return []; + } + const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); + // Staging a model must not change disclosure state. The applied model + // stays stable for the lifetime of this picker (Save closes it), so it + // is safe to use as the initial selected-provider default. + const containsAppliedSelection = group.models.some(session.isApplied); + const isNarrowed = session.providerFilter !== null || session.searchQuery.trim().length > 0; + const collapsible = !isNarrowed; + const collapsed = providerSectionIsCollapsed({ + defaultExpanded: isPrimary || containsAppliedSelection, + hasExpansionOverride: session.providerExpansionOverrides.has(group.providerKey), + isNarrowed, + }); + const provider: ThreadSettingsProviderCatalog = { + key: group.providerKey, + driver, + label: group.providerLabel, + collapsible, + collapsed, + modelCount: visibleModels.length, + models: collapsed ? [] : visibleModels, + }; + return [ + { + kind: "provider" as const, + key: `provider:${group.providerKey}`, + provider, + }, + ...provider.models.map((option, index) => ({ + kind: "model" as const, + key: `model:${option.key}`, + option, + isFirst: index === 0, + isLast: index === provider.models.length - 1, + })), + ]; + }), + [ + session.isApplied, + session.isDisplayed, + session.providerExpansionOverrides, + session.providerFilter, + session.providerGroups, + session.searchQuery, + session.showLegacy, + ], + ); +} + +function ThreadSettingsOptionsItem(props: { + readonly animationsReady: boolean; + readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; +}) { + const insets = useSafeAreaInsets(); + const session = useThreadSettingsSession(); + const bottomToolbarInset = + Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + : 0; + + return ( + + Options + + {session.displayedDescriptors.map((descriptor) => { + if (descriptor.type === "select") { + return ( + + props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })} + /> + + ); + } + return ( + + session.applyOptionChange(descriptor.id, value)} + /> + + ); + })} + + choice.mode === session.runtimeMode)?.label + } + onPress={() => props.onOpenSubmenu({ kind: "runtime" })} + /> + + + + {Platform.OS !== "ios" && session.hasLegacyModels ? ( + <> + + Catalog + + + + + + ) : null} + + ); +} + +/** One native scroll owner for the model catalog and its related settings. */ +function ThreadSettingsMainContent(props: { + readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; +}) { + const session = useThreadSettingsSession(); + const catalogItems = useThreadSettingsCatalogItems(session); + const [animationsReady, setAnimationsReady] = useState(false); + const nativeHeaderHeight = use(HeaderHeightContext) ?? 0; + const hasActiveCatalogFilter = + session.providerFilter !== null || session.searchQuery.trim().length > 0; + const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED; + const listItems = useMemo>( + () => [ + ...(catalogItems.length === 0 && hasActiveCatalogFilter + ? ([{ kind: "empty", key: "empty" }] as const) + : catalogItems), + { kind: "options", key: "options" }, + ], + [catalogItems, hasActiveCatalogFilter], + ); + const renderCatalogItem = useCallback( + (itemProps: LegendListRenderItemProps) => { + const item = itemProps.item; + let content: ReactNode; + + if (item.kind === "provider") { + content = ; + } else if (item.kind === "model") { + content = ( + + ); + } else if (item.kind === "empty") { + content = ( + + No matching models + + ); + } else { + content = ( + + ); + } + + return ( + + {content} + + ); + }, + [animationsReady, props.onOpenSubmenu], + ); + + return ( + item.kind} + itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} + keyExtractor={(item) => item.key} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION} + ListHeaderComponent={ + <> + {usesTransparentNativeHeader ? : null} + {Platform.OS === "android" ? ( + + + + ) : null} + + } + recycleItems + onLoad={() => setAnimationsReady(true)} + renderItem={renderCatalogItem} + showsVerticalScrollIndicator={false} + /> + ); +} + +/** Compact choice page pushed by the picker navigator. */ +function ThreadSettingsChoiceContent(props: { + readonly submenu: ThreadSettingsSubmenuPage; + readonly onSelected: () => void; +}) { + const insets = useSafeAreaInsets(); + const session = useThreadSettingsSession(); + const descriptorId = props.submenu.kind === "descriptor" ? props.submenu.id : null; const activeDescriptor = - submenu?.kind === "descriptor" - ? displayedDescriptors.find( - (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, + descriptorId !== null + ? session.displayedDescriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId, ) : undefined; const submenuContent = - submenu?.kind === "runtime" + props.submenu.kind === "runtime" ? { - title: "Runtime", rows: RUNTIME_MODE_CHOICES.map((choice) => ({ id: choice.mode, label: choice.label, - selected: choice.mode === props.runtimeMode, + description: choice.description, + selected: choice.mode === session.runtimeMode, onPress: () => { void Haptics.selectionAsync(); - props.onUpdateRuntimeMode(choice.mode); - setSubmenu(null); + session.onUpdateRuntimeMode(choice.mode); + props.onSelected(); }, })), } : activeDescriptor?.type === "select" ? { - title: activeDescriptor.label, rows: selectableChoices(activeDescriptor).map((choice) => ({ id: choice.id, label: choice.label, + description: undefined, selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), onPress: () => { void Haptics.selectionAsync(); - handleOptionChange(activeDescriptor.id, choice.id); - setSubmenu(null); + session.applyOptionChange(activeDescriptor.id, choice.id); + props.onSelected(); }, })), } : null; + if (!submenuContent) { + return ; + } + return ( - setSubmenu(null) : () => props.onClose("dismiss")} + - - props.onClose("dismiss")} + + {submenuContent.rows.map((row, index) => ( + + ))} + + + ); +} + +type ThreadSettingsPickerStackParams = { + ThreadSettingsModels: undefined; + ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; +}; + +type ThreadSettingsPickerPresentation = { + readonly onClose: () => void; +}; + +const ThreadSettingsPickerStack = createNativeStackNavigator(); +const ThreadSettingsPickerPresentationContext = + createContext(null); + +function useThreadSettingsPickerPresentation() { + const value = use(ThreadSettingsPickerPresentationContext); + if (!value) { + throw new Error( + "useThreadSettingsPickerPresentation must be used inside ThreadSettingsPickerNavigator.", + ); + } + return value; +} + +function ThreadSettingsModelsScreen() { + const session = useThreadSettingsSession(); + const presentation = useThreadSettingsPickerPresentation(); + const navigation = useNavigation>(); + const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; + const commitAndClose = useCallback(() => { + session.commitPendingModel(); + presentation.onClose(); + }, [presentation, session]); + const filterMenu = useMemo( + () => ({ + title: "Model filters", + items: [ + { + type: "submenu" as const, + title: "Provider", + items: [ + { + type: "action" as const, + title: "All providers", + state: session.providerFilter === null ? ("on" as const) : ("off" as const), + onPress: () => session.setProviderFilter(null), + }, + ...session.providerGroups.map((group) => ({ + type: "action" as const, + title: group.providerLabel, + state: + session.providerFilter === group.providerKey ? ("on" as const) : ("off" as const), + onPress: () => session.setProviderFilter(group.providerKey), + })), + ], + }, + ...(session.hasLegacyModels + ? [ + { + type: "action" as const, + title: "Show legacy models", + state: session.showLegacy ? ("on" as const) : ("off" as const), + onPress: () => session.setShowLegacy(!session.showLegacy), + }, + ] + : []), + ], + }), + [session], + ); + + return ( + <> + {Platform.OS === "android" ? ( + - - {/* The grabber doubles as the accessible close control: the dim - backdrop above a tall sheet is a sliver, and VoiceOver can't - reach it at all. */} - props.onClose("dismiss")} - className="items-center pb-1 pt-2.5" - > - - - {hasLegacyModels ? ( - - { - void Haptics.selectionAsync(); - setShowLegacyToggle(!showLegacy); - }} - className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70" - > - - {showLegacy ? "Hide legacy models" : "Show legacy models"} - - - - ) : null} - {/* Only the model list scrolls. Provider catalogs can run to - hundreds of models (OpenRouter), so the rows below stay pinned - and reachable instead of living at the end of that scroll. */} - group.providerKey), + session.showLegacy, + ]} + options={{ + unstable_headerToolbarItems: usesNativeMailSearchToolbar + ? () => [ + createNativeMailSearchToolbarItem({ + filterButtonId: "thread-settings-model-filter", + filterMenu, + filterSystemImageName: hasCustomCatalogFilter + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onSearchTextChange: session.setSearchQuery, + placeholder: "Find a model", + searchTextChangeId: "thread-settings-model-search-text", + showsSearchDismissButton: true, + }), + ] + : undefined, + headerShown: Platform.OS !== "android", + headerSearchBarOptions: + Platform.OS === "ios" && !usesNativeMailSearchToolbar + ? { + autoCapitalize: "none", + hideNavigationBar: false, + obscureBackground: false, + onCancelButtonPress: () => session.setSearchQuery(""), + onChangeText: (event) => session.setSearchQuery(event.nativeEvent.text), + placeholder: "Find a model", + } + : undefined, + }} + /> + { + const title = + submenu.kind === "runtime" + ? "Runtime" + : (session.displayedDescriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, + )?.label ?? "Option"); + navigation.navigate("ThreadSettingsChoice", { ...submenu, title }); + }} + /> + + + + + + + {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? ( + + - {props.providerGroups.map((group) => { - const driver = group.models[0]?.providerDriver; - const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); - const visibleModels = showLegacy - ? group.models - : group.models.filter((model) => !model.isLegacy || isDisplayed(model)); - if (visibleModels.length === 0) { - return null; - } - const containsSelection = group.models.some(isDisplayed); - const collapsible = !isPrimary && !containsSelection; - const collapsed = collapsible && !expandedProviders.has(group.providerKey); - return ( - - toggleProvider(group.providerKey)} - /> - {collapsed - ? null - : visibleModels.map((option) => ( - { - void Haptics.selectionAsync(); - // Re-tapping the applied model cancels staging. - setPendingModel((current) => - pendingModelAfterPress({ - current, - pressed: option, - pressedIsApplied: isApplied(option), - }), - ); - }} - /> - ))} - - ); - })} - - - - - - {descriptorTemplate.map((entry) => { - const live = displayedDescriptors.find( - (descriptor) => descriptor.label === entry.label, - ); - if ((live?.type ?? entry.type) === "select") { - return ( - { - if (live) { - setSubmenu({ kind: "descriptor", id: live.id }); - } - }} - /> - ); - } - return ( - { - if (live) { - handleOptionChange(live.id, value); - } - }} - /> - ); - })} - choice.mode === props.runtimeMode)?.label - } - onPress={() => setSubmenu({ kind: "runtime" })} - /> - - - {pendingModel ? "Save" : "Done"} - - - - - - {/* Submenus stack over the sheet instead of replacing its content, - so the main sheet keeps its size while drilling in and out. */} - {submenuContent ? ( - - setSubmenu(null)} - /> - - setSubmenu(null)} - className="items-center pb-1 pt-2.5" + + Provider + session.setProviderFilter(null)} > - - - - {submenuContent.title} - - + {session.providerGroups.map((group) => ( + session.setProviderFilter(group.providerKey)} + > + {group.providerLabel} + + ))} + + {session.hasLegacyModels ? ( + session.setShowLegacy(!session.showLegacy)} > - {submenuContent.rows.map((row) => ( - - ))} - - - - ) : null} - - + Show legacy models + + ) : null} + + + ) : null} + + ); +} + +function ThreadSettingsChoiceScreen() { + const navigation = useNavigation>(); + const route = useRoute>(); + + return ( + <> + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + navigation.goBack()} /> + + ); +} + +function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) { + const sheetBackground = String(useThemeColor("--color-sheet")); + const foreground = String(useThemeColor("--color-foreground")); + const nativeSheetBackground = NATIVE_SHEET_SURFACE_COLOR ?? sheetBackground; + const presentation = useMemo( + () => ({ + onClose: props.onClose, + }), + [props.onClose], + ); + + return ( + + + + ({ title: route.params.title })} + /> + + + ); +} + +/** Existing-thread model picker hosted by the root RNS form-sheet route. */ +export function ExistingThreadSettingsRouteScreen() { + const navigation = useNavigation>>(); + const presentation = useExistingThreadSettingsRoutePresentation(); + const session = presentation.session; + + useEffect(() => { + if (session) { + return; + } + + navigation.goBack(); + }, [navigation, session]); + + if (!session) { + return ; + } + + const { ownerId: _ownerId, ...settings } = session; + + return ( + + navigation.goBack()} /> + + ); +} + +/** + * Native stack hosted by the New Task navigator's form-sheet route. Keeping + * the sheet presentation in RNS gives UIKit ownership of nested dismissal, + * while Reasoning and Runtime remain regular pushes inside this navigator. + */ +export function NewTaskThreadSettingsRouteScreen() { + const flow = useNewTaskFlow(); + const navigation = useNavigation>>(); + const optionDescriptors = useMemo( + () => + resolveProviderOptionDescriptors({ + capabilities: flow.selectedModelOption?.capabilities, + selections: flow.selectedModel?.options, + }), + [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], + ); + + return ( + flow.setSelectedModelKey(option.key, option.selection.options)} + optionDescriptors={optionDescriptors} + onUpdateOptionSelections={flow.setSelectedModelOptions} + runtimeMode={flow.runtimeMode} + onUpdateRuntimeMode={flow.setRuntimeMode} + > + navigation.goBack()} /> + ); } diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts new file mode 100644 index 000000000..e55631885 --- /dev/null +++ b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; + +describe("resolvePendingTaskInteractionMode", () => { + it("preserves a queued plan task while the preference is still loading", () => { + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: false, + planModeEnabled: false, + draftInteractionMode: "plan", + queuedInteractionMode: "plan", + }), + ).toBe("plan"); + }); + + it("forces build mode once the disabled preference has loaded", () => { + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: true, + planModeEnabled: false, + draftInteractionMode: "plan", + queuedInteractionMode: "plan", + }), + ).toBe("default"); + }); + + it("keeps a fresh draft in build mode while the preference is loading", () => { + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: false, + planModeEnabled: false, + draftInteractionMode: "plan", + queuedInteractionMode: undefined, + }), + ).toBe("default"); + }); + + it("honors the draft's mode when the plan preference is enabled", () => { + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: true, + planModeEnabled: true, + draftInteractionMode: "plan", + queuedInteractionMode: undefined, + }), + ).toBe("plan"); + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded: true, + planModeEnabled: true, + draftInteractionMode: undefined, + queuedInteractionMode: "plan", + }), + ).toBe("default"); + }); +}); diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/features/threads/legacy-plan-mode.ts new file mode 100644 index 000000000..e7122125f --- /dev/null +++ b/apps/mobile/src/features/threads/legacy-plan-mode.ts @@ -0,0 +1,29 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + type ProviderInteractionMode, +} from "@t3tools/contracts"; + +export function resolveLegacyPlanModeEnabled(input: { + readonly loaded: boolean; + readonly preference: boolean | undefined; +}): boolean { + return input.loaded && input.preference === true; +} + +export function resolvePendingTaskInteractionMode(input: { + readonly preferenceLoaded: boolean; + readonly planModeEnabled: boolean; + readonly draftInteractionMode: ProviderInteractionMode | undefined; + readonly queuedInteractionMode: ProviderInteractionMode | undefined; +}): ProviderInteractionMode { + if (input.planModeEnabled) { + return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + } + if (!input.preferenceLoaded) { + // Only an existing queued task may retain its previous mode while the + // preference is unknown. A fresh draft still defaults to Build so a stale + // persisted Plan selection cannot bypass a disabled preference at launch. + return input.queuedInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + } + return DEFAULT_PROVIDER_INTERACTION_MODE; +} diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts new file mode 100644 index 000000000..3c81d8231 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveNewTaskBranchWorktreePath, + resolveNewTaskBranchLabel, + resolveNewTaskLocalWorkspaceSelection, +} from "./new-task-context-presentation"; + +describe("resolveNewTaskLocalWorkspaceSelection", () => { + it("waits for refs instead of carrying a worktree base into Current checkout", () => { + expect( + resolveNewTaskLocalWorkspaceSelection({ + branches: [], + projectCwd: "/repo", + }), + ).toEqual({ + branch: null, + worktreePath: null, + awaitsCurrentBranch: true, + }); + }); + + it("adopts the checkout's current branch once refs load", () => { + expect( + resolveNewTaskLocalWorkspaceSelection({ + branches: [ + { name: "feature/worktree-base", current: false, worktreePath: "/worktree" }, + { name: "main", current: true, worktreePath: "/repo" }, + ], + projectCwd: "/repo", + }), + ).toEqual({ + branch: "main", + worktreePath: null, + awaitsCurrentBranch: false, + }); + }); + + it("carries the worktree path when the current branch lives in another worktree", () => { + expect( + resolveNewTaskLocalWorkspaceSelection({ + branches: [ + { name: "feature/split", current: true, worktreePath: "/repo/.t3/worktrees/split" }, + { name: "main", current: false, worktreePath: "/repo" }, + ], + projectCwd: "/repo", + }), + ).toEqual({ + branch: "feature/split", + worktreePath: "/repo/.t3/worktrees/split", + awaitsCurrentBranch: false, + }); + }); +}); + +describe("resolveNewTaskBranchWorktreePath", () => { + it("moves Current checkout to the selected existing worktree", () => { + expect( + resolveNewTaskBranchWorktreePath({ + workspaceMode: "local", + projectCwd: "/repo", + branchWorktreePath: "/repo/.t3/worktrees/feature", + }), + ).toBe("/repo/.t3/worktrees/feature"); + }); + + it("keeps the project checkout represented by a null override", () => { + expect( + resolveNewTaskBranchWorktreePath({ + workspaceMode: "local", + projectCwd: "/repo", + branchWorktreePath: "/repo", + }), + ).toBeNull(); + }); + + it("does not reuse an existing worktree while creating a new one", () => { + expect( + resolveNewTaskBranchWorktreePath({ + workspaceMode: "worktree", + projectCwd: "/repo", + branchWorktreePath: "/repo/.t3/worktrees/feature", + }), + ).toBeNull(); + }); +}); + +describe("resolveNewTaskBranchLabel", () => { + it("shows the checked-out branch without a base-ref prefix", () => { + expect( + resolveNewTaskBranchLabel({ + branchName: "feature/mobile", + startFromOrigin: true, + workspaceMode: "local", + }), + ).toBe("feature/mobile"); + }); + + it("labels a local worktree base with From", () => { + expect( + resolveNewTaskBranchLabel({ + branchName: "main", + startFromOrigin: false, + workspaceMode: "worktree", + }), + ).toBe("From main"); + }); + + it("labels a remote worktree base with From origin", () => { + expect( + resolveNewTaskBranchLabel({ + branchName: "main", + startFromOrigin: true, + workspaceMode: "worktree", + }), + ).toBe("From origin/main"); + }); + + it("prompts when no branch is available", () => { + expect( + resolveNewTaskBranchLabel({ + branchName: null, + startFromOrigin: true, + workspaceMode: "worktree", + }), + ).toBe("Choose branch"); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts new file mode 100644 index 000000000..99eee3ea4 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -0,0 +1,83 @@ +type WorkspaceMode = "local" | "worktree"; + +export function resolveNewTaskWorkspaceLabel(input: { + readonly workspaceMode: WorkspaceMode; + readonly worktreePath: string | null; +}): "Current checkout" | "Current worktree" | "New worktree" { + if (input.workspaceMode === "worktree") { + return "New worktree"; + } + return input.worktreePath ? "Current worktree" : "Current checkout"; +} + +export function resolveNewTaskBranchWorktreePath(input: { + readonly workspaceMode: WorkspaceMode; + readonly projectCwd: string; + readonly branchWorktreePath: string | null | undefined; +}): string | null { + if ( + input.workspaceMode === "worktree" || + !input.branchWorktreePath || + input.branchWorktreePath === input.projectCwd + ) { + return null; + } + return input.branchWorktreePath; +} + +export function resolveNewTaskLocalWorkspaceSelection(input: { + readonly branches: ReadonlyArray<{ + readonly name: string; + readonly current: boolean; + readonly worktreePath?: string | null; + }>; + readonly projectCwd: string; +}): { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly awaitsCurrentBranch: boolean; +} { + const currentBranch = input.branches.find((branch) => branch.current) ?? null; + if (!currentBranch) { + return { + branch: null, + worktreePath: null, + awaitsCurrentBranch: true, + }; + } + + return { + branch: currentBranch.name, + worktreePath: resolveNewTaskBranchWorktreePath({ + workspaceMode: "local", + projectCwd: input.projectCwd, + branchWorktreePath: currentBranch.worktreePath, + }), + awaitsCurrentBranch: false, + }; +} + +export function resolveNewTaskBranchLabel(input: { + readonly branchName: string | null; + readonly startFromOrigin: boolean; + readonly workspaceMode: WorkspaceMode; +}): string { + if (!input.branchName) { + return "Choose branch"; + } + + if (input.workspaceMode === "local") { + return input.branchName; + } + + const baseRef = input.startFromOrigin ? `origin/${input.branchName}` : input.branchName; + return `From ${baseRef}`; +} + +export function shouldCheckoutNewTaskBranch(input: { + readonly branchIsCurrent: boolean; + readonly branchWorktreePath: string | null | undefined; + readonly workspaceMode: WorkspaceMode; +}): boolean { + return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath; +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 7d79e9ece..44056ead3 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -42,6 +42,7 @@ import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, clearComposerDraft, + copyComposerDraftContentIfEmpty, getComposerDraftSnapshot, isComposerDraftEmpty, removeComposerDraftAttachment, @@ -50,7 +51,7 @@ import { updateComposerDraftSettings, useComposerDraft, } from "../../state/use-composer-drafts"; -import { useBranches } from "../../state/queries"; +import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { flattenQueuedThreadMessages, threadOutboxManager, @@ -74,10 +75,16 @@ import { type HomeProjectScope, } from "../home/homeThreadList"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; +import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; +import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; +import { + resolveNewTaskBranchWorktreePath, + resolveNewTaskLocalWorkspaceSelection, +} from "./new-task-context-presentation"; type WorkspaceMode = "local" | "worktree"; -const EMPTY_BRANCH_REFS: ReadonlyArray = []; +const BRANCH_SEARCH_DEBOUNCE_MS = 150; function pendingTaskDraftKey(messageId: string): string { return `pending-task:${messageId}`; @@ -96,14 +103,6 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { return message?.creation !== undefined ? message : null; } -function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { - if (!branch.worktreePath) { - return null; - } - - return branch.worktreePath === project.workspaceRoot ? null : branch.worktreePath; -} - export function branchBadgeLabel(input: { readonly branch: VcsRef; readonly project: EnvironmentProject | null; @@ -117,9 +116,6 @@ export function branchBadgeLabel(input: { if (input.branch.isDefault) { return "default"; } - if (input.branch.isRemote) { - return "remote"; - } return null; } @@ -139,9 +135,13 @@ type NewTaskFlowContextValue = { readonly submitting: boolean; readonly branchQuery: string; readonly branchesLoading: boolean; + readonly branchesError: string | null; + readonly branchesFetchingNextPage: boolean; + readonly hasMoreBranches: boolean; readonly availableBranches: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; + readonly planModeEnabled: boolean; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; @@ -175,7 +175,8 @@ type NewTaskFlowContextValue = { readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; readonly setBranchQuery: (value: string) => void; - readonly loadBranches: () => Promise; + readonly loadBranches: () => void; + readonly loadMoreBranches: () => void; readonly setRuntimeMode: (value: RuntimeMode) => void; readonly setInteractionMode: (value: ProviderInteractionMode) => void; readonly setSelectedModelOptions: ( @@ -191,6 +192,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); + const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); const projectScopes = useMemo( () => sortHomeProjectScopes({ @@ -219,6 +221,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const [branchQuery, setBranchQuery] = useState(""); const [expandedProvider, setExpandedProvider] = useState(null); const [editingPendingTask, setEditingPendingTask] = useState(null); + const pendingLocalBranchSyncDraftKeysRef = useRef(new Set()); // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act // on a task whose editing session already ended this render. const editingPendingTaskRef = useRef(null); @@ -229,6 +232,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSubmitting(false); setBranchQuery(""); setExpandedProvider(null); + pendingLocalBranchSyncDraftKeysRef.current.clear(); const editing = editingPendingTaskRef.current; editingPendingTaskRef.current = null; setEditingPendingTask(null); @@ -395,7 +399,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + const interactionMode = planModeEnabled + ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) + : DEFAULT_PROVIDER_INTERACTION_MODE; // Stored selections only count while their provider is usable on the // server; otherwise the server's default model wins instead of silently @@ -521,18 +527,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } replaceComposerDraftAttachments(selectedProjectDraftKey, []); }, [selectedProjectDraftKey]); + const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS); const branchTarget = useMemo( () => ({ environmentId: selectedProject?.environmentId ?? null, // `|| null` also skips the stand-in project's empty workspaceRoot. cwd: selectedProject?.workspaceRoot || null, - query: null, + query: debouncedBranchQuery, }), - [selectedProject?.environmentId, selectedProject?.workspaceRoot], + [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot], ); - const branchState = useBranches(branchTarget); - const branchesLoading = branchState.isPending; - const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS; + const branchState = usePaginatedBranches(branchTarget); + const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim(); + const branchesLoading = + branchSearchIsDebouncing || (branchState.isPending && branchState.data === null); + const branchesFetchingNextPage = branchState.isFetchingNextPage; + const hasMoreBranches = + branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined; + const allBranchRefs = branchState.refs; const availableBranches = useMemo( () => pipe( @@ -554,11 +566,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); }, [availableBranches, branchQuery]); - const setProject = useCallback((project: EnvironmentProject) => { - const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - setSelectedEnvironmentId(project.environmentId); - setSelectedProjectKey(nextProjectKey); - }, []); + const setProject = useCallback( + (project: EnvironmentProject) => { + const nextProjectKey = scopedProjectKey(project.environmentId, project.id); + const nextDraftKey = `new-task:${nextProjectKey}`; + if ( + selectedProjectDraftKey?.startsWith("new-task:") && + selectedProjectDraftKey !== nextDraftKey + ) { + void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); + } + setSelectedEnvironmentId(project.environmentId); + setSelectedProjectKey(nextProjectKey); + }, + [selectedProjectDraftKey], + ); const selectEnvironment = useCallback( (environmentId: EnvironmentId) => { @@ -596,28 +618,86 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (!selectedProjectDraftKey) { return; } + if (!selectedProject) { + return; + } + const localSelection = resolveNewTaskLocalWorkspaceSelection({ + branches: availableBranches, + projectCwd: selectedProject.workspaceRoot, + }); + if (mode === "local" && localSelection.awaitsCurrentBranch) { + pendingLocalBranchSyncDraftKeysRef.current.add(selectedProjectDraftKey); + } else { + pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); + } updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode, - branch: selectedBranchName, - worktreePath: selectedWorktreePath, + branch: mode === "local" ? localSelection.branch : selectedBranchName, + worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); }, - [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], + [ + availableBranches, + draftStartFromOrigin, + selectedBranchName, + selectedProject, + selectedProjectDraftKey, + selectedWorktreePath, + ], ); + useEffect(() => { + if ( + workspaceMode !== "local" || + !selectedProject || + !selectedProjectDraftKey || + !pendingLocalBranchSyncDraftKeysRef.current.has(selectedProjectDraftKey) + ) { + return; + } + const localSelection = resolveNewTaskLocalWorkspaceSelection({ + branches: availableBranches, + projectCwd: selectedProject.workspaceRoot, + }); + if (localSelection.awaitsCurrentBranch) { + return; + } + + pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode: "local", + branch: localSelection.branch, + worktreePath: localSelection.worktreePath, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), + }, + }); + }, [ + availableBranches, + draftStartFromOrigin, + selectedProject, + selectedProjectDraftKey, + workspaceMode, + ]); + const selectBranch = useCallback( (branch: VcsRef) => { if (!selectedProject || !selectedProjectDraftKey) { return; } + pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode: workspaceMode, branch: branch.name, - worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), + worktreePath: resolveNewTaskBranchWorktreePath({ + workspaceMode, + projectCwd: selectedProject.workspaceRoot, + branchWorktreePath: branch.worktreePath, + }), ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); @@ -643,7 +723,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const refreshBranches = branchState.refresh; - const loadBranches = useCallback(async () => { + const loadMoreBranches = branchState.loadNext; + const loadBranches = useCallback(() => { if (!selectedProject) { return; } @@ -767,7 +848,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { attachments: draft.attachments, modelSelection: draftModelSelection, runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, - interactionMode: draft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + interactionMode: resolvePendingTaskInteractionMode({ + preferenceLoaded: planModePreferenceLoaded, + planModeEnabled, + draftInteractionMode: draft.interactionMode, + queuedInteractionMode: editingPendingTask?.interactionMode, + }), creation: { projectId: selectedProject.id, ...(projectTitle !== undefined ? { projectTitle } : {}), @@ -792,6 +878,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedProject, selectedProjectDraftKey, + planModeEnabled, + planModePreferenceLoaded, startFromOrigin, workspaceMode, ], @@ -904,9 +992,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { submitting, branchQuery, branchesLoading, + branchesError: branchState.error, + branchesFetchingNextPage, + hasMoreBranches, availableBranches, runtimeMode, interactionMode, + planModeEnabled, expandedProvider, environments, selectedProject, @@ -935,6 +1027,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSubmitting, setBranchQuery, loadBranches, + loadMoreBranches, setRuntimeMode, setInteractionMode, setSelectedModelOptions, @@ -946,6 +1039,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { beginEditingPendingTask, branchQuery, branchesLoading, + branchState.error, + branchesFetchingNextPage, buildPendingTaskMessage, cancelEditingPendingTask, editingPendingTask, @@ -954,7 +1049,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { filteredBranches, finishEditingPendingTask, interactionMode, + planModeEnabled, loadBranches, + loadMoreBranches, projectScopes, modelOptions, prompt, @@ -963,6 +1060,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { reset, runtimeMode, selectedBranchName, + hasMoreBranches, selectedEnvironmentId, selectedModel, selectedModelKey, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index d8ed12bcc..7068a95d5 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -5,12 +5,13 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { getOnlySelectableProject, + getProjectScopeSelectionTarget, resolveDraftProjectSelection, } from "./new-task-project-selection"; -function makeProject(id: string): EnvironmentProject { +function makeProject(id: string, environmentId = "environment"): EnvironmentProject { return { - environmentId: EnvironmentId.make("environment"), + environmentId: EnvironmentId.make(environmentId), id: ProjectId.make(id), title: id, workspaceRoot: `/work/${id}`, @@ -41,9 +42,25 @@ describe("getOnlySelectableProject", () => { expect(getOnlySelectableProject([makeScope([project])])).toBe(project); }); - it("does not auto-select a representative when one group has multiple clones", () => { + it("selects the representative when one logical project has multiple workspaces", () => { const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; - expect(getOnlySelectableProject([makeScope(projects)])).toBeNull(); + expect(getOnlySelectableProject([makeScope(projects)])).toBe(projects[0]); + }); +}); + +describe("getProjectScopeSelectionTarget", () => { + it("keeps the current environment when it hosts the selected logical project", () => { + const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; + expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("server"))).toBe( + projects[1], + ); + }); + + it("falls back to the representative when the current environment does not host the project", () => { + const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; + expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("other"))).toBe( + projects[0], + ); }); }); @@ -63,10 +80,11 @@ describe("resolveDraftProjectSelection", () => { }); }); - it("opens the picker for multiple physical projects in one logical group", () => { + it("selects one logical project even when it has multiple physical workspaces", () => { const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({ - kind: "pick", + kind: "select", + project: projects[0], }); }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 29ae3cf4f..7be899d62 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -1,18 +1,29 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { HomeProjectScope } from "../home/homeThreadList"; -export type DraftProjectSelectionResolution = +type DraftProjectSelectionResolution = | { readonly kind: "preserve" } | { readonly kind: "select"; readonly project: EnvironmentProject } | { readonly kind: "pick" }; +export function getProjectScopeSelectionTarget( + scope: HomeProjectScope, + preferredEnvironmentId: EnvironmentId | null, +): EnvironmentProject { + return ( + scope.projects.find((project) => project.environmentId === preferredEnvironmentId) ?? + scope.representative + ); +} + export function getOnlySelectableProject( projectScopes: ReadonlyArray, ): EnvironmentProject | null { const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; - return onlyScope?.projects.length === 1 ? (onlyScope.projects[0] ?? null) : null; + return onlyScope?.representative ?? null; } export function resolveDraftProjectSelection( diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx deleted file mode 100644 index 1321c82c0..000000000 --- a/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { View } from "react-native"; - -import { T3HeaderButton } from "../../native/T3HeaderButton.android"; -import type { SidebarHeaderActionsProps } from "./sidebar-header-actions"; - -export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { - return ( - - - - ); -} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index db2e805d0..1c25f949e 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -369,8 +369,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR state up so the partition can auto-settle - merged/closed work (mirrors web's onChangeRequestState). */ + /** Reports this row's live PR state for the partition's merge and close + rules. Mirrors web's onChangeRequestState. */ readonly onChangeRequestState?: ( threadKey: string, state: "open" | "closed" | "merged" | null, diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts deleted file mode 100644 index 078be2df1..000000000 --- a/apps/mobile/src/features/threads/thread-settings-menu.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; - -import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; -import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu"; - -function modelOption( - model: string, - overrides: Partial> = {}, -): ModelOption { - const providerKey = overrides.providerKey ?? "codex"; - return { - key: `${providerKey}:${model}`, - label: model, - subtitle: providerKey, - providerKey, - providerLabel: providerKey === "codex" ? "Codex" : "Claude", - providerDriver: providerKey === "codex" ? "codex" : "claudeAgent", - isDefault: overrides.isDefault ?? false, - isLegacy: overrides.isLegacy ?? false, - capabilities: null, - selection: { - instanceId: ProviderInstanceId.make(providerKey), - model, - options: [], - }, - }; -} - -function group(models: ReadonlyArray): ProviderGroup { - const first = models[0]; - if (!first) { - throw new Error("group requires at least one model"); - } - return { - providerKey: first.providerKey, - providerLabel: first.providerLabel, - models, - }; -} - -const effortDescriptor: ProviderOptionDescriptor = { - id: "effort", - label: "Reasoning", - type: "select", - options: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium", isDefault: true }, - { id: "high", label: "High" }, - { id: "ultrathink", label: "Ultrathink" }, - { id: "ultracode", label: "Ultracode" }, - ], - currentValue: "high", - promptInjectedValues: ["ultrathink"], -}; - -const fastModeDescriptor: ProviderOptionDescriptor = { - id: "fastMode", - label: "Fast mode", - type: "boolean", - currentValue: false, -}; - -function baseInput() { - const models = [ - modelOption("gpt-current", { isDefault: true }), - modelOption("gpt-next"), - modelOption("gpt-old", { isLegacy: true }), - ]; - return { - providerGroups: [group(models)], - selectedModel: models[0]?.selection ?? null, - optionDescriptors: [effortDescriptor, fastModeDescriptor], - runtimeMode: "auto", - } as const; -} - -function eventFor(menu: ReturnType, id: string | undefined) { - return id === undefined ? undefined : menu.events.get(id); -} - -describe("buildThreadSettingsMenu", () => { - it("orders the top level as model, options, runtime", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - expect(menu.actions.map((action) => action.title)).toEqual([ - "Model", - "Reasoning", - "Fast mode", - "Runtime", - ]); - }); - - it("summarizes the current choice on each submenu row", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - expect(menu.actions.find((action) => action.title === "Model")?.subtitle).toBe("gpt-current"); - expect(menu.actions.find((action) => action.title === "Reasoning")?.subtitle).toBe("High"); - expect(menu.actions.find((action) => action.title === "Runtime")?.subtitle).toBe("Auto"); - }); - - it("checkmarks the selected model and resolves selection events", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - const current = modelItems.find((action) => action.title === "gpt-current"); - expect(current?.state).toBe("on"); - expect(current?.subtitle).toBe("Default"); - expect(modelItems.find((action) => action.title === "gpt-next")?.state).toBe("off"); - - const event = eventFor(menu, modelItems.find((action) => action.title === "gpt-next")?.id); - expect(event?.type).toBe("select-model"); - expect(event?.type === "select-model" ? event.option.selection.model : null).toBe("gpt-next"); - }); - - it("folds unselected legacy models behind a nested submenu", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - expect(modelItems.map((action) => action.title)).toEqual([ - "gpt-current", - "gpt-next", - "Legacy Models", - ]); - expect( - modelItems - .find((action) => action.title === "Legacy Models") - ?.subactions?.map((action) => action.title), - ).toEqual(["gpt-old"]); - }); - - it("keeps a selected legacy model in the main list", () => { - const input = baseInput(); - const legacy = input.providerGroups[0]?.models.find((model) => model.isLegacy); - const menu = buildThreadSettingsMenu({ - ...input, - selectedModel: legacy?.selection ?? null, - }); - - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - expect(modelItems.map((action) => action.title)).toEqual([ - "gpt-current", - "gpt-next", - "gpt-old", - ]); - expect(modelItems.find((action) => action.title === "gpt-old")?.state).toBe("on"); - }); - - it("hides prompt-injected and workflow-trigger efforts but still summarizes them", () => { - const menu = buildThreadSettingsMenu({ - ...baseInput(), - optionDescriptors: [{ ...effortDescriptor, currentValue: "ultracode" }], - }); - - const reasoning = menu.actions.find((action) => action.title === "Reasoning"); - expect(reasoning?.subactions?.map((action) => action.title)).toEqual(["Low", "Medium", "High"]); - // The hidden value stays visible as the current summary; it just can't be - // picked from the phone. - expect(reasoning?.subtitle).toBe("Ultracode"); - expect(reasoning?.subactions?.every((action) => action.state === "off")).toBe(true); - }); - - it("resolves select-option and runtime events with checkmarked current values", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - const reasoningItems = - menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; - expect(reasoningItems.find((action) => action.title === "High")?.state).toBe("on"); - expect(eventFor(menu, reasoningItems.find((action) => action.title === "Low")?.id)).toEqual({ - type: "set-option", - optionId: "effort", - value: "low", - }); - - const runtimeItems = - menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; - expect(runtimeItems.find((action) => action.title === "Auto")?.state).toBe("on"); - expect( - eventFor(menu, runtimeItems.find((action) => action.title === "Full access")?.id), - ).toEqual({ type: "set-runtime", mode: "full-access" }); - }); - - it("toggles boolean options with the inverted current value", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - const fastMode = menu.actions.find((action) => action.title === "Fast mode"); - expect(fastMode?.state).toBe("off"); - expect(fastMode?.subactions).toBeUndefined(); - expect(eventFor(menu, fastMode?.id)).toEqual({ - type: "set-option", - optionId: "fastMode", - value: true, - }); - - const enabled = buildThreadSettingsMenu({ - ...baseInput(), - optionDescriptors: [{ ...fastModeDescriptor, currentValue: true }], - }); - const enabledRow = enabled.actions.find((action) => action.title === "Fast mode"); - expect(enabledRow?.state).toBe("on"); - expect(eventFor(enabled, enabledRow?.id)).toEqual({ - type: "set-option", - optionId: "fastMode", - value: false, - }); - }); - - it("keeps the menu presented only for top-level toggles", () => { - const menu = buildThreadSettingsMenu(baseInput()); - - // Root-level boolean toggles refresh in place with clean chrome, so they - // keep the menu presented. - expect( - menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, - ).toBe(true); - - // Picks inside nested submenus close the menu: staying presented leaves - // the submenu on screen with an expanded-submenu header, and the - // bottom-anchored collapse back out drops by the levels' height delta. - const expected = undefined; - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - const reasoningItems = - menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; - const runtimeItems = - menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; - const nestedPicks = [...modelItems, ...reasoningItems, ...runtimeItems].filter( - (action) => action.subactions === undefined, - ); - expect(nestedPicks.length).toBeGreaterThan(0); - expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe( - true, - ); - }); - - it("sections models by provider only when multiple groups are offered", () => { - const codexModels = [modelOption("gpt-current", { isDefault: true })]; - const claudeModels = [modelOption("fable-5", { providerKey: "claude" })]; - const menu = buildThreadSettingsMenu({ - providerGroups: [group(codexModels), group(claudeModels)], - selectedModel: codexModels[0]?.selection ?? null, - optionDescriptors: [], - runtimeMode: "auto", - }); - - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - expect( - modelItems.map((action) => ({ title: action.title, inline: action.displayInline ?? false })), - ).toEqual([ - { title: "Codex", inline: true }, - { title: "Claude", inline: true }, - ]); - const claudeSection = modelItems.find((action) => action.title === "Claude"); - expect(claudeSection?.subactions?.map((action) => action.title)).toEqual(["fable-5"]); - }); - - const eventTypes = (menu: ReturnType) => { - const types = new Set(); - for (const event of menu.events.values()) { - types.add(event.type); - } - return types; - }; - - it("registers an event for every leaf action id", () => { - const menu = buildThreadSettingsMenu(baseInput()); - const leafIds: string[] = []; - const collect = (items: ReadonlyArray<{ id?: string; subactions?: unknown[] }>) => { - for (const item of items) { - if (Array.isArray(item.subactions) && item.subactions.length > 0) { - collect(item.subactions as ReadonlyArray<{ id?: string; subactions?: unknown[] }>); - } else if (item.id !== undefined) { - leafIds.push(item.id); - } - } - }; - collect(menu.actions); - - for (const id of leafIds) { - expect(menu.events.get(id), `missing event for ${id}`).toBeDefined(); - } - expect(eventTypes(menu)).toEqual(new Set(["select-model", "set-option", "set-runtime"])); - }); -}); diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts deleted file mode 100644 index 31b1c021c..000000000 --- a/apps/mobile/src/features/threads/thread-settings-menu.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { MenuAction } from "@react-native-menu/menu"; -import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; -import { - getProviderOptionCurrentLabel, - getProviderOptionCurrentValue, -} from "@t3tools/shared/model"; - -import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; - -/** - * Desktop-oriented effort keywords that don't belong in the phone picker. - * Prompt-injected values (ultrathink and friends) are filtered from the - * descriptor metadata; ultracode is a real option but a workflow trigger, not - * a reasoning level. A value set elsewhere still displays, it just isn't - * offered. - */ -export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); - -export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ - readonly mode: RuntimeMode; - readonly label: string; - readonly shortLabel: string; -}> = [ - { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, - { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, - { mode: "auto", label: "Auto", shortLabel: "Auto" }, - { mode: "full-access", label: "Full access", shortLabel: "Full" }, -]; - -export function selectableChoices( - descriptor: Extract, -) { - const injected = new Set(descriptor.promptInjectedValues ?? []); - return descriptor.options.filter( - (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), - ); -} - -export type ThreadSettingsMenuEvent = - | { readonly type: "select-model"; readonly option: ModelOption } - | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } - | { readonly type: "set-runtime"; readonly mode: RuntimeMode }; - -export type ThreadSettingsMenu = { - readonly actions: MenuAction[]; - /** Menu action id → the change it applies, for the onPressAction dispatch. */ - readonly events: ReadonlyMap; -}; - -/** - * Native menu replacement for the thread settings sheet (model, select and - * boolean provider options, runtime mode). The menu presents from the - * composer pill without resigning the keyboard, so adjusting settings never - * bounces focus. A thread is bound to one harness, so the menu covers the - * sheet's full surface for existing threads; the sheet remains the Android - * and new-task-draft surface. - * - * Selections apply immediately — the sheet's stage-then-Save flow only exists - * because the sheet batches a model change with its option edits. - */ -export function buildThreadSettingsMenu(input: { - readonly providerGroups: ReadonlyArray; - readonly selectedModel: ModelSelection | null; - readonly optionDescriptors: ReadonlyArray; - readonly runtimeMode: RuntimeMode; -}): ThreadSettingsMenu { - const events = new Map(); - const actions: MenuAction[] = []; - - const isSelected = (option: ModelOption) => - option.selection.instanceId === input.selectedModel?.instanceId && - option.selection.model === input.selectedModel.model; - - // Only top-level leaves (boolean toggles) keep the menu presented (iOS - // 16+): the root refreshes in place with clean chrome. Picks inside nested - // submenus close the menu — keeping the submenu presented renders an - // expanded-submenu header with no way to pop back to the root, and the - // bottom-anchored collapse back out travels the levels' height difference. - const keepPresented = { keepsMenuPresented: true } as const; - - const modelAction = (option: ModelOption, id: string): MenuAction => { - events.set(id, { type: "select-model", option }); - return { - id, - title: option.label, - ...(option.isDefault ? { subtitle: "Default" } : {}), - state: isSelected(option) ? "on" : "off", - }; - }; - - const modelItems: MenuAction[] = []; - const legacyItems: MenuAction[] = []; - let selectedModelLabel: string | undefined; - input.providerGroups.forEach((group, groupIndex) => { - const groupItems: MenuAction[] = []; - group.models.forEach((option, modelIndex) => { - if (isSelected(option)) { - selectedModelLabel = option.label; - } - const id = `model:${groupIndex}:${modelIndex}`; - // A highlighted legacy model stays in the main list (mirroring the - // sheet) so the checkmark isn't hidden behind the Legacy fold. - if (option.isLegacy && !isSelected(option)) { - legacyItems.push(modelAction(option, id)); - } else { - groupItems.push(modelAction(option, id)); - } - }); - if (groupItems.length === 0) { - return; - } - // A thread is bound to one harness, so provider sections only appear for - // multi-group callers (the new-task draft, if it ever adopts the menu). - if (input.providerGroups.length > 1) { - modelItems.push({ - id: `model-group:${groupIndex}`, - title: group.providerLabel, - displayInline: true, - subactions: groupItems, - }); - } else { - modelItems.push(...groupItems); - } - }); - if (legacyItems.length > 0) { - modelItems.push({ - id: "legacy-models", - title: "Legacy Models", - subactions: legacyItems, - }); - } - if (modelItems.length > 0) { - actions.push({ - id: "model", - title: "Model", - ...(selectedModelLabel === undefined - ? input.selectedModel - ? { subtitle: input.selectedModel.model } - : {} - : { subtitle: selectedModelLabel }), - subactions: modelItems, - }); - } - - for (const descriptor of input.optionDescriptors) { - if (descriptor.type === "boolean") { - const id = `option:${descriptor.id}`; - events.set(id, { - type: "set-option", - optionId: descriptor.id, - value: !(descriptor.currentValue ?? false), - }); - actions.push({ - id, - title: descriptor.label, - state: descriptor.currentValue ? "on" : "off", - attributes: keepPresented, - }); - continue; - } - const currentValue = getProviderOptionCurrentValue(descriptor); - const choices = selectableChoices(descriptor).map((choice): MenuAction => { - const id = `option:${descriptor.id}:${choice.id}`; - events.set(id, { type: "set-option", optionId: descriptor.id, value: choice.id }); - return { - id, - title: choice.label, - state: choice.id === currentValue ? "on" : "off", - }; - }); - if (choices.length === 0) { - continue; - } - const currentLabel = getProviderOptionCurrentLabel(descriptor); - actions.push({ - id: `option:${descriptor.id}`, - title: descriptor.label, - ...(currentLabel === undefined ? {} : { subtitle: currentLabel }), - subactions: choices, - }); - } - - const runtimeLabel = RUNTIME_MODE_CHOICES.find( - (choice) => choice.mode === input.runtimeMode, - )?.label; - actions.push({ - id: "runtime", - title: "Runtime", - ...(runtimeLabel === undefined ? {} : { subtitle: runtimeLabel }), - subactions: RUNTIME_MODE_CHOICES.map((choice): MenuAction => { - const id = `runtime:${choice.mode}`; - events.set(id, { type: "set-runtime", mode: choice.mode }); - return { - id, - title: choice.label, - state: choice.mode === input.runtimeMode ? "on" : "off", - }; - }), - }); - - return { actions, events }; -} diff --git a/apps/mobile/src/features/threads/thread-settings-options.test.ts b/apps/mobile/src/features/threads/thread-settings-options.test.ts new file mode 100644 index 000000000..041f8b9de --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-options.test.ts @@ -0,0 +1,29 @@ +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { selectableChoices } from "./thread-settings-options"; + +const effortDescriptor: Extract = { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "ultrathink", label: "Ultrathink" }, + { id: "ultracode", label: "Ultracode" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], +}; + +describe("selectableChoices", () => { + it("hides prompt-injected and workflow-trigger choices, keeping declared order", () => { + expect(selectableChoices(effortDescriptor).map((choice) => choice.id)).toEqual([ + "low", + "medium", + "high", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts new file mode 100644 index 000000000..b678154f8 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-options.ts @@ -0,0 +1,46 @@ +import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; + +/** + * Desktop-oriented effort keywords that don't belong in the phone picker. + * Prompt-injected values (ultrathink and friends) are filtered from the + * descriptor metadata; ultracode is a real option but a workflow trigger, not + * a reasoning level. A value set elsewhere still displays, it just isn't + * offered. + */ +const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); + +export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ + readonly mode: RuntimeMode; + readonly label: string; + readonly description: string; +}> = [ + { + mode: "approval-required", + label: "Supervised", + description: "Ask before commands and file changes.", + }, + { + mode: "auto-accept-edits", + label: "Auto-accept edits", + description: "Auto-approve edits, ask before other actions.", + }, + { + mode: "auto", + label: "Auto", + description: "Supported providers approve routine actions; others still ask.", + }, + { + mode: "full-access", + label: "Full access", + description: "Allow commands and edits without prompts.", + }, +]; + +export function selectableChoices( + descriptor: Extract, +) { + const injected = new Set(descriptor.promptInjectedValues ?? []); + return descriptor.options.filter( + (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), + ); +} diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 1264c75cd..2e8fee985 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; import type { ModelOption } from "../../lib/modelOptions"; -import { pendingModelAfterPress } from "./thread-settings-sheet-state"; +import { modelMatchesCatalogQuery, pendingModelAfterPress } from "./thread-settings-sheet-state"; function modelOption( model: string, @@ -28,6 +28,26 @@ function modelOption( } describe("thread settings sheet state", () => { + it("matches visible model and provider terms", () => { + const model = modelOption("gpt-next"); + + expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "NEXT" })).toBe(true); + expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "codex" })).toBe(true); + expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "claude" })).toBe( + false, + ); + }); + + it("treats whitespace-only catalog searches as empty", () => { + expect( + modelMatchesCatalogQuery({ + model: modelOption("gpt-next"), + providerLabel: "Codex", + query: " ", + }), + ).toBe(true); + }); + it("clears staging when the applied model is pressed", () => { expect( pendingModelAfterPress({ diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index f0540dc5a..1e417b925 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,5 +1,24 @@ import type { ModelOption } from "../../lib/modelOptions"; +/** Match the terms a user can actually see or recognize in the model picker. */ +export function modelMatchesCatalogQuery(input: { + readonly model: ModelOption; + readonly providerLabel: string; + readonly query: string; +}): boolean { + const query = input.query.trim().toLocaleLowerCase(); + if (query.length === 0) { + return true; + } + + return [ + input.model.label, + input.model.subtitle, + input.model.selection.model, + input.providerLabel, + ].some((value) => value.toLocaleLowerCase().includes(query)); +} + /** Preserve staged provider options when the highlighted model is tapped again. */ export function pendingModelAfterPress(input: { readonly current: ModelOption | null; @@ -11,3 +30,18 @@ export function pendingModelAfterPress(input: { } return input.current?.key === input.pressed.key ? input.current : input.pressed; } + +/** + * Primary and selected providers start open; all other catalogs start closed. + * A user's disclosure tap inverts that default until the picker is dismissed. + */ +export function providerSectionIsCollapsed(input: { + readonly defaultExpanded: boolean; + readonly hasExpansionOverride: boolean; + readonly isNarrowed: boolean; +}): boolean { + if (input.isNarrowed) { + return false; + } + return input.defaultExpanded ? input.hasExpansionOverride : !input.hasExpansionOverride; +} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index adb163898..482037338 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -264,6 +264,21 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { + it("keeps a merged thread active when auto-settle on merge is off", () => { + const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); + const layout = buildThreadListV2Items({ + threads: [merged], + environmentId: null, + searchQuery: "", + changeRequestStateByKey: new Map([[`${environmentId}:${merged.id}`, "merged"]]), + autoSettleOnMerge: false, + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]); + expect(layout.settledCount).toBe(0); + }); + it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index eba56ac8d..53b80e52c 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -306,9 +306,8 @@ export function buildThreadListV2ListItems(input: { /** * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` - * mirrors the web default of 3 — mobile has no client-settings sync yet, so - * the default is fixed here rather than user-configurable. + * the settled recency tail, matching the web v2 list. Mobile stores these + * auto-settle preferences per device. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -329,6 +328,7 @@ export function buildThreadListV2Items(input: { contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; readonly autoSettleAfterDays?: number; + readonly autoSettleOnMerge?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ @@ -349,6 +349,7 @@ export function buildThreadListV2Items(input: { const now = input.now ?? new Date().toISOString(); const snoozeNow = input.snoozeNow ?? now; const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const autoSettleOnMerge = input.autoSettleOnMerge ?? true; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -405,7 +406,12 @@ export function buildThreadListV2Items(input: { } if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + autoSettleOnMerge, + changeRequestState, + }) ) { settled.push(thread); } else { diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts new file mode 100644 index 000000000..25ec4ff0e --- /dev/null +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -0,0 +1,26 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { mobilePreferencesAtom } from "../../state/preferences"; +import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; + +/** + * Mobile preferences are device-local, matching the desktop client setting. + * Keep the legacy composer mode hidden until the preference has loaded and is + * explicitly enabled. + */ +export function useLegacyPlanModeEnabled(): boolean { + return useLegacyPlanModeState().enabled; +} + +export function useLegacyPlanModeState(): { readonly enabled: boolean; readonly loaded: boolean } { + const preferences = useAtomValue(mobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferences); + return { + enabled: resolveLegacyPlanModeEnabled({ + loaded, + preference: loaded ? preferences.value.planModeEnabled : undefined, + }), + loaded, + }; +} diff --git a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts index 3cc2ed184..b5b4914ad 100644 --- a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts +++ b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts @@ -3,14 +3,46 @@ import { KeyboardController } from "react-native-keyboard-controller"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; -export type ThreadSettingsSheetCloseReason = "save" | "dismiss"; +type PresentationPhase = "closed" | "opening" | "visible"; -type PresentationPhase = "closed" | "opening" | "visible" | "closing"; +/** + * The navigator-level UIKit completion event added by the repo's + * `@react-navigation/native-stack` patch; absent from upstream event maps. + */ +export type NavigationWithFinishTransitioning = { + readonly addListener: (type: "finishTransitioning", callback: () => void) => () => void; +}; + +/** + * How long after the dismissal's state change the keyboard starts rising, so + * its ~250ms show overlaps the tail of the sheet's ~500ms travel the way + * UIKit apps choreograph it. This is aesthetics, not correctness: without + * keepFocus-style inputView overrides a show started mid-dismissal completes + * cleanly, so a slower device merely gets more overlap — no failure mode. + * The navigator's `finishTransitioning` event (UIKit's real completion + * callback, surfaced by the repo's native-stack patch) additionally bounds + * the restore at the true landing moment should this timer ever lag it. + */ +const SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS = 300; + +/** + * A JS-initiated dismissal pops state before its animation runs; a + * gesture-driven one animates natively first and pops afterwards, with the + * navigator's completion event landing a few dozen milliseconds before the + * pop. A completion this fresh at pop time therefore means the sheet is + * already gone and the keyboard should return immediately. The two orderings + * are separated by the sheet's full ~500ms travel, so this window is a + * classification with wide margin, not an animation race. + */ +const NATIVE_DISMISSAL_ECHO_WINDOW_MS = 150; /** - * Keeps the custom native composer and the settings modal from owning focus at - * the same time. Opening waits for the keyboard dismissal to finish, while - * focus restoration waits for the modal's dismissal callback. + * Keeps the custom native composer and the settings sheet from owning focus at + * the same time. Opening resigns the editor cleanly; a dismissal re-focuses it + * once the sheet has fully landed. A plain blur/focus pair costs one keyboard + * animation each way — keepFocus-style inputView overrides are avoided because + * removing them forces UIKit to reload input views, replaying the keyboard's + * show as a visible collapse/re-open. */ export function useThreadSettingsSheetPresentation(input: { readonly editorRef: RefObject; @@ -19,18 +51,36 @@ export function useThreadSettingsSheetPresentation(input: { const [phase, setPhase] = useState("closed"); const isActiveRef = useRef(false); const isMountedRef = useRef(true); + const isEditorFocusedRef = useRef(input.isEditorFocused); const openingIdRef = useRef(0); - const restoreFocusOnSaveRef = useRef(false); - const shouldRestoreAfterDismissRef = useRef(false); + const focusRestoreIdRef = useRef(0); + const restoreFocusAfterDismissRef = useRef(false); + const restorePendingRef = useRef(false); + const lastStackTransitionFinishedAtRef = useRef(0); + const dismissRestoreTimerRef = useRef | null>(null); + const clearDismissRestoreTimer = useCallback(() => { + if (dismissRestoreTimerRef.current !== null) { + clearTimeout(dismissRestoreTimerRef.current); + dismissRestoreTimerRef.current = null; + } + }, []); - useEffect( - () => () => { + useEffect(() => { + isEditorFocusedRef.current = input.isEditorFocused; + }, [input.isEditorFocused]); + + useEffect(() => { + // React Strict Mode and Fast Refresh both run an effect cleanup/setup + // cycle without recreating refs. Re-arm the mounted guard on every setup. + isMountedRef.current = true; + return () => { isMountedRef.current = false; isActiveRef.current = false; openingIdRef.current += 1; - }, - [], - ); + focusRestoreIdRef.current += 1; + clearDismissRestoreTimer(); + }; + }, [clearDismissRestoreTimer]); const open = useCallback(() => { if (isActiveRef.current) { @@ -38,61 +88,107 @@ export function useThreadSettingsSheetPresentation(input: { } isActiveRef.current = true; - restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible(); - shouldRestoreAfterDismissRef.current = false; + focusRestoreIdRef.current += 1; + clearDismissRestoreTimer(); + restorePendingRef.current = false; + restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible(); setPhase("opening"); const openingId = openingIdRef.current + 1; openingIdRef.current = openingId; - // Keyboard.dismiss() only tracks React Native TextInputs. The composer is - // a custom native text view, so explicitly resign its first responder too. + // Start the keyboard transition before the custom native editor resigns + // first responder, then present the sheet on the next frame. The sheet and + // keyboard animate together instead of serializing two native transitions. + void KeyboardController.dismiss({ animated: true }); input.editorRef.current?.blur(); - void KeyboardController.dismiss().then(() => { + + requestAnimationFrame(() => { if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { return; } setPhase("visible"); }); - }, [input.editorRef, input.isEditorFocused]); + }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]); + + const restoreEditorFocus = useCallback(() => { + const focusRestoreId = focusRestoreIdRef.current + 1; + focusRestoreIdRef.current = focusRestoreId; + let attemptsRemaining = 20; + + // Restoration runs after the dismissal transition, so the first attempt + // normally succeeds; the retries are insurance against UIKit briefly + // refusing first-responder status right at the transition boundary. + const restoreFocus = () => { + if ( + !isMountedRef.current || + focusRestoreIdRef.current !== focusRestoreId || + isEditorFocusedRef.current || + attemptsRemaining <= 0 + ) { + return; + } - const close = useCallback((reason: ThreadSettingsSheetCloseReason) => { - if (!isActiveRef.current) { + attemptsRemaining -= 1; + input.editorRef.current?.focus(); + setTimeout(restoreFocus, 50); + }; + requestAnimationFrame(restoreFocus); + }, [input.editorRef]); + + /** Runs the queued restore once — whichever completion signal arrives first. */ + const runPendingDismissalRestore = useCallback(() => { + if (!restorePendingRef.current) { return; } + restorePendingRef.current = false; + clearDismissRestoreTimer(); + // A reopened sheet owns focus again; drop the stale restore request. + if (!isMountedRef.current || isActiveRef.current) { + return; + } + restoreEditorFocus(); + }, [clearDismissRestoreTimer, restoreEditorFocus]); - openingIdRef.current += 1; - shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current; - setPhase("closing"); - }, []); - + /** + * Marks the sheet closed and queues the keyboard's return for the moment + * the dismissal transition actually completes: the sheet slides away over a + * resting composer, then the keyboard lifts it in one continuous motion. + */ const onDismissed = useCallback(() => { - const shouldRestoreFocus = shouldRestoreAfterDismissRef.current; - shouldRestoreAfterDismissRef.current = false; - restoreFocusOnSaveRef.current = false; isActiveRef.current = false; setPhase("closed"); - if (shouldRestoreFocus) { - input.editorRef.current?.focus(); + if (!restoreFocusAfterDismissRef.current) { + return; } - }, [input.editorRef]); - - // The new-task screen can have an autofocus queued before the sheet opens. - // Preserve that intent for Save without allowing it to focus under the modal. - const restoreFocusAfterSave = useCallback(() => { - if (isActiveRef.current) { - restoreFocusOnSaveRef.current = true; + restoreFocusAfterDismissRef.current = false; + restorePendingRef.current = true; + clearDismissRestoreTimer(); + if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) { + // A stack transition finished just before this pop reached JS: the pop + // is the state echo of a gesture-driven dismissal whose animation has + // already completed. The sheet is gone — bring the keyboard back now. + runPendingDismissalRestore(); + return; } - }, []); + dismissRestoreTimerRef.current = setTimeout(() => { + dismissRestoreTimerRef.current = null; + runPendingDismissalRestore(); + }, SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS); + }, [clearDismissRestoreTimer, runPendingDismissalRestore]); + + /** Wire to the navigator's `finishTransitioning` event. */ + const onStackTransitionsFinished = useCallback(() => { + lastStackTransitionFinishedAtRef.current = Date.now(); + runPendingDismissalRestore(); + }, [runPendingDismissalRestore]); return { isActive: phase !== "closed", - isActiveRef, isVisible: phase === "visible", open, - close, onDismissed, - restoreFocusAfterSave, + onStackTransitionsFinished, } as const; } diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index 4926ae65c..4ff344e63 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + createAppUpdateDeferral, createAppUpdateLaunchCheck, + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, registerHiddenUpdateTap, runAppUpdateCheck, + shouldRecheckAppUpdateOnForeground, type AppUpdateCheckState, type AppUpdateClient, + type AppUpdateEnvironment, } from "./app-updates"; vi.mock("expo-updates", () => ({ @@ -31,6 +35,41 @@ function makeUpdateClient(overrides: Partial = {}): AppUpdateCl }; } +function makeUpdateEnvironment(overrides: Partial = {}): { + readonly backgroundCallbacks: Array<() => void>; + readonly environment: AppUpdateEnvironment; + readonly foregroundStayCallbacks: Array<() => void>; +} { + const backgroundCallbacks: Array<() => void> = []; + const foregroundStayCallbacks: Array<() => void> = []; + return { + backgroundCallbacks, + foregroundStayCallbacks, + environment: { + confirmInstallNow: vi.fn(async () => true), + flushPendingWrites: vi.fn(async () => {}), + isSafeToRestartInBackground: vi.fn(async () => true), + onNextBackground: vi.fn((apply: () => void, _includeCurrent: boolean) => { + backgroundCallbacks.push(apply); + }), + onForegroundStay: vi.fn((apply: () => void) => { + foregroundStayCallbacks.push(apply); + }), + ...overrides, + }, + }; +} + +function makeAvailableUpdateClient(overrides: Partial = {}): AppUpdateClient { + return makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: true, + isRollBackToEmbedded: false, + })), + ...overrides, + }); +} + describe("runAppUpdateCheck", () => { it("does nothing while running from the Metro development server", async () => { vi.stubGlobal("__DEV__", true); @@ -45,23 +84,313 @@ describe("runAppUpdateCheck", () => { expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); }); - it("downloads and restarts when a new update is available", async () => { - const client = makeUpdateClient({ - checkForUpdateAsync: vi.fn(async () => ({ - isAvailable: true, - isRollBackToEmbedded: false, - })), - }); + it("downloads silently and installs at the next backgrounding", async () => { + const client = makeAvailableUpdateClient(); + const { backgroundCallbacks, environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); const states: AppUpdateCheckState[] = []; - await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); + await runAppUpdateCheck({ + client, + deferral, + environment, + onStateChange: (state) => states.push(state), + }); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(states).toEqual(["checking", "downloading", "ready"]); + expect(deferral.pendingInstall).toBe(true); + expect(backgroundCallbacks).toHaveLength(1); + + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + expect(environment.flushPendingWrites).toHaveBeenCalled(); + }); + + it("flushes pending writes before restarting", async () => { + const client = makeAvailableUpdateClient(); + const { environment } = makeUpdateEnvironment(); + + await runAppUpdateCheck({ + applyMode: "immediate", + client, + deferral: createAppUpdateDeferral(), + environment, + }); + + const flushOrder = vi.mocked(environment.flushPendingWrites).mock.invocationCallOrder[0]!; + const reloadOrder = vi.mocked(client.reloadAsync).mock.invocationCallOrder[0]!; + expect(flushOrder).toBeLessThan(reloadOrder); + }); + + it("prompts once the app has stayed foregrounded with the download waiting", async () => { + const client = makeAvailableUpdateClient(); + const { environment, foregroundStayCallbacks } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); + expect(foregroundStayCallbacks).toHaveLength(1); + + foregroundStayCallbacks[0]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + expect(environment.confirmInstallNow).toHaveBeenCalledOnce(); + expect(environment.flushPendingWrites).toHaveBeenCalled(); + }); + + it("keeps the background install armed when the foreground prompt is declined", async () => { + const client = makeAvailableUpdateClient(); + const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({ + confirmInstallNow: vi.fn(async () => false), + }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + + foregroundStayCallbacks[0]!(); + await vi.waitFor(() => expect(environment.confirmInstallNow).toHaveBeenCalledOnce()); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(deferral.pendingInstall).toBe(true); + + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + }); + + it("skips the foreground prompt once the install is no longer pending", async () => { + const client = makeAvailableUpdateClient(); + const { environment, foregroundStayCallbacks } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + + // A failed deferred reload resets the deferral before the stay fires. + deferral.pendingInstall = false; + foregroundStayCallbacks[0]!(); + + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); + expect(client.reloadAsync).not.toHaveBeenCalled(); + }); + + it("re-arms instead of restarting when the app is no longer safely backgrounded", async () => { + const client = makeAvailableUpdateClient(); + const safe = vi.fn(async () => false); + const { backgroundCallbacks, environment } = makeUpdateEnvironment({ + isSafeToRestartInBackground: safe, + }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + expect(backgroundCallbacks).toHaveLength(1); + // Arming may fire for an already-backgrounded app… + expect(vi.mocked(environment.onNextBackground).mock.calls[0]![1]).toBe(true); + + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2)); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(deferral.pendingInstall).toBe(true); + // …but a re-arm must wait for a fresh transition, or an unsafe attempt + // would retry in a tight loop within the same background session. + expect(vi.mocked(environment.onNextBackground).mock.calls[1]![1]).toBe(false); + + safe.mockResolvedValue(true); + backgroundCallbacks[1]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + }); + + it("resets the deferral when the deferred restart fails", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeAvailableUpdateClient({ + reloadAsync: vi.fn(async () => { + throw new Error("reload rejected"); + }), + }); + const { backgroundCallbacks, environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + backgroundCallbacks[0]!(); + + await vi.waitFor(() => expect(deferral.pendingInstall).toBe(false)); + reportError.mockRestore(); + }); + + it("arms the deferred install once across repeated checks", async () => { + const client = makeAvailableUpdateClient(); + const { environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + await runAppUpdateCheck({ client, deferral, environment }); + + expect(environment.onNextBackground).toHaveBeenCalledOnce(); + expect(environment.onForegroundStay).toHaveBeenCalledOnce(); + }); + + it("restarts into an already-downloaded update when the user asks to install", async () => { + const client = makeUpdateClient(); + const { environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + deferral.pendingInstall = true; + + await runAppUpdateCheck({ applyMode: "immediate", client, deferral, environment }); + + expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); + expect(client.reloadAsync).toHaveBeenCalledOnce(); + }); + + it("honors an immediate request that joined an in-flight background check", async () => { + let resolveCheck!: (result: { + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }) => void; + const checkResult = new Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>((resolve) => { + resolveCheck = resolve; + }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(() => checkResult), + }); + const { environment } = makeUpdateEnvironment(); + const deferral = createAppUpdateDeferral(); + + const backgroundCheck = runAppUpdateCheck({ client, deferral, environment }); + const manualCheck = runAppUpdateCheck({ + applyMode: "immediate", + client, + deferral, + environment, + }); + + resolveCheck({ isAvailable: true, isRollBackToEmbedded: false }); + await Promise.all([backgroundCheck, manualCheck]); + + // The coalesced background check deferred the download, but the manual + // caller explicitly asked to install, so the restart happens anyway. + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(client.reloadAsync).toHaveBeenCalledOnce(); + }); + + it("runs a single restart when the deferred install races the foreground prompt", async () => { + const client = makeAvailableUpdateClient(); + let releaseFlush!: () => void; + const blockedFlush = new Promise((resolve) => { + releaseFlush = resolve; + }); + const flushPendingWrites = vi.fn(async (): Promise => {}); + const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({ + flushPendingWrites, + }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + flushPendingWrites.mockReturnValue(blockedFlush); + + // The deferred install starts and blocks on its flush; the foreground + // prompt firing in that window must not begin a second restart. + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(flushPendingWrites).toHaveBeenCalledOnce()); + foregroundStayCallbacks[0]!(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); + + releaseFlush(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + }); + + it("holds the deferred restart and re-arms when the pre-restart flush fails", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeAvailableUpdateClient(); + const { backgroundCallbacks, environment } = makeUpdateEnvironment({ + flushPendingWrites: vi.fn(async () => { + throw new Error("disk full"); + }), + }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + backgroundCallbacks[0]!(); + + await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2)); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(deferral.pendingInstall).toBe(true); + reportError.mockRestore(); + }); + + it("restarts without prompting when the caller asked for an immediate install", async () => { + const client = makeAvailableUpdateClient(); + const { environment } = makeUpdateEnvironment(); + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ + applyMode: "immediate", + client, + deferral: createAppUpdateDeferral(), + environment, + onStateChange: (state) => states.push(state), + }); + + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); expect(client.reloadAsync).toHaveBeenCalledOnce(); expect(states).toEqual(["checking", "downloading", "restarting"]); }); + it("holds an automatic rollback restart when the flush fails and re-arms it", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: false, + isRollBackToEmbedded: true, + })), + fetchUpdateAsync: vi.fn(async () => ({ + isNew: false, + isRollBackToEmbedded: true, + })), + }); + const flushPendingWrites = vi.fn(async (): Promise => { + throw new Error("storage unavailable"); + }); + const { backgroundCallbacks, environment } = makeUpdateEnvironment({ flushPendingWrites }); + const deferral = createAppUpdateDeferral(); + + await runAppUpdateCheck({ client, deferral, environment }); + + // Nobody asked for this restart, so it must not discard the state it + // failed to land; the rollback waits armed for the next backgrounding. + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(deferral.pendingInstall).toBe(true); + expect(backgroundCallbacks).toHaveLength(1); + + flushPendingWrites.mockResolvedValue(undefined); + backgroundCallbacks[0]!(); + await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); + reportError.mockRestore(); + }); + + it("still restarts a user-requested install when the flush fails", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeAvailableUpdateClient(); + const { environment } = makeUpdateEnvironment({ + flushPendingWrites: vi.fn(async () => { + throw new Error("storage unavailable"); + }), + }); + + await runAppUpdateCheck({ + applyMode: "immediate", + client, + deferral: createAppUpdateDeferral(), + environment, + }); + + expect(client.reloadAsync).toHaveBeenCalledOnce(); + reportError.mockRestore(); + }); + it("restarts into the embedded bundle for a rollback directive", async () => { const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(async () => ({ @@ -73,10 +402,13 @@ describe("runAppUpdateCheck", () => { isRollBackToEmbedded: true, })), }); + const { environment } = makeUpdateEnvironment(); - await runAppUpdateCheck({ client }); + await runAppUpdateCheck({ client, deferral: createAppUpdateDeferral(), environment }); expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); + // A rollback pulls a broken bundle, so it never waits on the prompt. + expect(environment.confirmInstallNow).not.toHaveBeenCalled(); expect(client.reloadAsync).toHaveBeenCalledOnce(); }); @@ -276,6 +608,36 @@ describe("createAppUpdateLaunchCheck", () => { }); }); +describe("shouldRecheckAppUpdateOnForeground", () => { + it("requires a meaningful background gap", () => { + expect(shouldRecheckAppUpdateOnForeground(null, 100_000, false)).toBe(false); + expect( + shouldRecheckAppUpdateOnForeground( + 100_000, + 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS - 1, + false, + ), + ).toBe(false); + expect( + shouldRecheckAppUpdateOnForeground( + 100_000, + 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, + false, + ), + ).toBe(true); + }); + + it("stays quiet while a downloaded update waits for its install", () => { + expect( + shouldRecheckAppUpdateOnForeground( + 100_000, + 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, + true, + ), + ).toBe(false); + }); +}); + describe("registerHiddenUpdateTap", () => { it("unlocks the manual check on the fifth tap", () => { let count = 0; diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 66525d022..5f8a110be 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -8,7 +8,13 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -export type AppUpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; +export type AppUpdateCheckState = + | "idle" + | "checking" + | "downloading" + | "ready" + | "restarting" + | "current"; export interface AppUpdateClient { readonly isEnabled: boolean; @@ -23,8 +29,70 @@ export interface AppUpdateClient { readonly reloadAsync: () => Promise; } +/** + * The pieces of the app the update flow has to coordinate with before it may + * tear down the JavaScript runtime. Injectable so the flow stays unit-testable. + */ +export interface AppUpdateEnvironment { + /** Asks the user to install the waiting update now; `false` keeps it deferred. */ + readonly confirmInstallNow: () => Promise; + /** + * Lands persisted state (drafts, outbox) before the restart. Rejects when a + * write failed, so a silent restart can hold off instead of dropping the + * unsaved in-memory state. + */ + readonly flushPendingWrites: () => Promise; + /** + * Whether a deferred restart may fire right now: the app must still be + * backgrounded (flush latency or an iOS suspend can push the continuation + * into the next foreground session) and not merely paused behind an + * app-initiated handoff like the Android image picker. + */ + readonly isSafeToRestartInBackground: () => Promise; + /** + * Runs `apply` the next time the app enters the background. With + * `includeCurrent`, an app that is already backgrounded fires immediately + * (so a backgrounding that raced module load is not missed); without it, + * only a future transition fires, so an attempt that already failed in the + * current background session cannot retry in a tight loop. + */ + readonly onNextBackground: (apply: () => void, includeCurrent: boolean) => void; + /** + * Runs `apply` once the app has stayed foregrounded for the whole prompt + * window — the signal that a deferred install has had no backgrounding to + * ride on. + */ + readonly onForegroundStay: (apply: () => void) => void; +} + +/** Tracks a downloaded update waiting for a safe moment to install. */ +export interface AppUpdateDeferral { + pendingInstall: boolean; + /** + * Claimed by whichever restart sequence (deferred backgrounding, foreground + * prompt, manual install) starts first, so racing paths cannot tear down + * the runtime twice. + */ + installInProgress: boolean; +} + +export function createAppUpdateDeferral(): AppUpdateDeferral { + return { pendingInstall: false, installInProgress: false }; +} + +const appUpdateDeferral = createAppUpdateDeferral(); + interface AppUpdateCheckOptions { + /** + * "background" (default) installs silently at the next backgrounding, + * asking only if the app then stays foregrounded so long that the install + * never gets its chance. "immediate" restarts as soon as the download + * lands — reserved for flows where the user explicitly requested the update. + */ + readonly applyMode?: "background" | "immediate"; readonly client?: AppUpdateClient; + readonly deferral?: AppUpdateDeferral; + readonly environment?: AppUpdateEnvironment; readonly onFailure?: (message: string) => void; readonly onStateChange?: (state: AppUpdateCheckState) => void; } @@ -86,6 +154,15 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr if (appUpdateCheckInFlight) { await observeAppUpdateCheck(appUpdateCheckInFlight, options); + // A background-mode check in flight may have deferred the download this + // caller explicitly asked to install; honor the explicit request now. + if (options.applyMode === "immediate") { + const deferral = options.deferral ?? appUpdateDeferral; + if (deferral.pendingInstall) { + const environment = options.environment ?? defaultAppUpdateEnvironment; + await installPendingAppUpdate(client, environment, deferral, options); + } + } return; } @@ -109,6 +186,9 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr appUpdateCheckInFlight = inFlight; const execution = performAppUpdateCheck(client, { + applyMode: options.applyMode, + deferral: options.deferral, + environment: options.environment, onFailure: (message) => { progress.failure = message; notifyListeners(failureListeners, message); @@ -175,6 +255,15 @@ async function performAppUpdateCheck( options: AppUpdateCheckOptions, ): Promise { const setState = options.onStateChange ?? (() => {}); + const environment = options.environment ?? defaultAppUpdateEnvironment; + const deferral = options.deferral ?? appUpdateDeferral; + + // The user explicitly asked to install and a previous check has already + // downloaded the update; restart into it without another network round trip. + if (options.applyMode === "immediate" && deferral.pendingInstall) { + await installPendingAppUpdate(client, environment, deferral, options); + return; + } setState("checking"); const check = await settlePromise(() => client.checkForUpdateAsync()); @@ -203,14 +292,252 @@ async function performAppUpdateCheck( return; } + // A rollback directive exists to pull a broken bundle; never hold it + // behind a prompt or a deferred install. + if (options.applyMode === "immediate" || fetched.value.isRollBackToEmbedded) { + const outcome = await installAppUpdate( + client, + environment, + deferral, + options, + options.applyMode === "immediate", + ); + if (outcome === "flush-failed") { + // Only reachable for an automatic rollback: keep the state-bearing + // runtime alive and retry like a deferred install. The fetched rollback + // still applies at the next cold start regardless. + setState("ready"); + armDeferredAppUpdateInstall(client, environment, deferral); + } + return; + } + + setState("ready"); + armDeferredAppUpdateInstall(client, environment, deferral); +} + +type AppUpdateInstallOutcome = "installed" | "flush-failed" | "restart-failed"; + +/** + * Restarting mid-session while native surfaces are mounted is the crashiest + * moment expo-updates has, so the restart flushes persistence first and, by + * default, waits for a backgrounding — where nothing is rendering and the + * teardown is invisible. Only a restart the user explicitly asked for may + * proceed over a failed flush; an automatic one aborts with "flush-failed" + * so unsaved state is never silently discarded. + */ +async function installAppUpdate( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, + options: AppUpdateCheckOptions, + userRequested: boolean, +): Promise { + // A concurrent install sequence already owns the restart. + if (deferral.installInProgress) return "installed"; + deferral.installInProgress = true; + const setState = options.onStateChange ?? (() => {}); setState("restarting"); + const flushed = await settlePromise(() => environment.flushPendingWrites()); + if (flushed._tag === "Failure") { + reportUpdateFailure(flushed, "Could not save pending state.", undefined); + if (!userRequested) { + deferral.installInProgress = false; + return "flush-failed"; + } + } const reloaded = await settlePromise(() => client.reloadAsync()); if (reloaded._tag === "Failure") { reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure); setState("idle"); + deferral.installInProgress = false; + return "restart-failed"; + } + return "installed"; +} + +/** Restarts into an already-downloaded update at the user's request. */ +async function installPendingAppUpdate( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, + options: AppUpdateCheckOptions, +): Promise { + const outcome = await installAppUpdate(client, environment, deferral, options, true); + if (outcome === "restart-failed") { + // Let later checks re-arm the install; the downloaded update still + // applies at the next cold start regardless. + deferral.pendingInstall = false; + } +} + +function armDeferredAppUpdateInstall( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, +): void { + if (deferral.pendingInstall) return; + deferral.pendingInstall = true; + scheduleDeferredAppUpdateInstall(client, environment, deferral, true); + environment.onForegroundStay(() => { + void promptDeferredAppUpdateInstall(client, environment, deferral); + }); +} + +/** + * A deferred install normally rides the next backgrounding, but a session that + * never leaves the foreground would sit on the download forever. Only then is + * the user asked, and declining simply leaves the background install armed. + */ +async function promptDeferredAppUpdateInstall( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, +): Promise { + if (!deferral.pendingInstall || deferral.installInProgress) return; + const installNow = await settlePromise(() => environment.confirmInstallNow()); + if (installNow._tag !== "Success" || !installNow.value) return; + // A backgrounding while the alert was up may have started the deferred + // restart already; the stale accept must not start a second one. + if (!deferral.pendingInstall || deferral.installInProgress) return; + await installPendingAppUpdate(client, environment, deferral, {}); +} + +function scheduleDeferredAppUpdateInstall( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, + includeCurrent: boolean, +): void { + environment.onNextBackground(() => { + void applyDeferredAppUpdateInstall(client, environment, deferral); + }, includeCurrent); +} + +async function applyDeferredAppUpdateInstall( + client: AppUpdateClient, + environment: AppUpdateEnvironment, + deferral: AppUpdateDeferral, +): Promise { + if (!deferral.pendingInstall || deferral.installInProgress) return; + deferral.installInProgress = true; + const flushed = await settlePromise(() => environment.flushPendingWrites()); + const safe = await settlePromise(() => environment.isSafeToRestartInBackground()); + if (flushed._tag === "Failure" || safe._tag !== "Success" || !safe.value) { + if (flushed._tag === "Failure") { + // Nothing is lost yet: keep the state-bearing runtime alive and retry + // the flush at the next backgrounding instead of restarting over it. + reportUpdateFailure(flushed, "Could not save pending state.", undefined); + } + deferral.installInProgress = false; + // This attempt already ran in the current background session; retrying + // before a fresh transition would just loop over the same failure. + scheduleDeferredAppUpdateInstall(client, environment, deferral, false); + return; + } + const reloaded = await settlePromise(() => client.reloadAsync()); + if (reloaded._tag === "Failure") { + reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", undefined); + deferral.installInProgress = false; + // Let later checks re-arm the install; the downloaded update still + // applies at the next cold start regardless. + deferral.pendingInstall = false; } } +async function defaultConfirmInstallNow(): Promise { + const { Alert } = await import("react-native"); + return new Promise((resolve) => { + Alert.alert( + "Update ready", + "A new version has been downloaded and installs automatically the next time you leave the app. Install it now instead?", + [ + { onPress: () => resolve(false), style: "cancel", text: "Later" }, + { onPress: () => resolve(true), text: "Install Now" }, + ], + { cancelable: true, onDismiss: () => resolve(false) }, + ); + }); +} + +async function defaultFlushPendingWrites(): Promise { + // Attempt every flush before surfacing the first failure, so one broken + // store cannot keep the others from landing. + const results = await Promise.allSettled([ + import("../../state/use-composer-drafts").then((drafts) => drafts.flushComposerDrafts()), + import("../../state/thread-outbox").then((outbox) => outbox.flushThreadOutbox()), + ]); + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failed) throw failed.reason; +} + +async function defaultIsSafeToRestartInBackground(): Promise { + const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff"); + if (isForegroundHandoffActive()) return false; + const { AppState } = await import("react-native"); + return AppState.currentState === "background"; +} + +function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void { + void import("react-native").then(({ AppState }) => { + const subscription = AppState.addEventListener("change", (state) => { + if (state !== "background") return; + subscription.remove(); + apply(); + }); + // The app may already have backgrounded while this module was loading; + // the listener alone would then wait a whole extra foreground cycle. + if (includeCurrent && AppState.currentState === "background") { + subscription.remove(); + apply(); + } + }); +} + +/** + * How long the app may stay foregrounded with a downloaded update before the + * install prompt appears. Long enough that most sessions background naturally + * and install silently instead. + */ +export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000; + +/** + * The window resets on every backgrounding because that is exactly when the + * deferred install gets its chance. iOS "inactive" blips (app switcher, a + * pulled-down notification shade) leave the timer running. + */ +function defaultOnForegroundStay(apply: () => void): void { + void import("react-native").then(({ AppState }) => { + let timer: ReturnType | undefined; + const arm = () => { + timer ??= setTimeout(() => { + subscription.remove(); + apply(); + }, DEFERRED_INSTALL_PROMPT_AFTER_MS); + }; + const disarm = () => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") arm(); + else if (state === "background") disarm(); + }); + if (AppState.currentState === "active") arm(); + }); +} + +const defaultAppUpdateEnvironment: AppUpdateEnvironment = { + confirmInstallNow: defaultConfirmInstallNow, + flushPendingWrites: defaultFlushPendingWrites, + isSafeToRestartInBackground: defaultIsSafeToRestartInBackground, + onNextBackground: defaultOnNextBackground, + onForegroundStay: defaultOnForegroundStay, +}; + function reportUpdateFailure( result: AtomCommandResult, fallback: string, @@ -243,3 +570,53 @@ export function createAppUpdateLaunchCheck( } export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck(); + +/** + * The app can stay resident for days, so a launch-only check misses updates + * published while it was in memory. Anything shorter reads as noise: brief + * app switches should not trigger network checks or an install prompt. + */ +export const FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS = 15 * 60 * 1000; + +export function shouldRecheckAppUpdateOnForeground( + backgroundedAtMs: number | null, + activeAtMs: number, + pendingInstall: boolean, +): boolean { + if (pendingInstall) return false; + return ( + backgroundedAtMs !== null && + activeAtMs - backgroundedAtMs >= FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS + ); +} + +export function createAppUpdateForegroundRecheck( + client: AppUpdateClient = Updates, + deferral: AppUpdateDeferral = appUpdateDeferral, +): () => void { + let started = false; + + return () => { + if (started || !isAppUpdateCheckAvailable(client)) return; + started = true; + void import("react-native").then(({ AppState }) => { + let backgroundedAtMs: number | null = null; + AppState.addEventListener("change", (state) => { + if (state === "background") { + backgroundedAtMs = Date.now(); + return; + } + if (state !== "active") return; + const shouldCheck = shouldRecheckAppUpdateOnForeground( + backgroundedAtMs, + Date.now(), + deferral.pendingInstall, + ); + backgroundedAtMs = null; + if (shouldCheck) void runAppUpdateCheck({ client, deferral }); + }); + }); + }; +} + +export const startAppUpdateForegroundRecheck = createAppUpdateForegroundRecheck(); diff --git a/apps/mobile/src/lib/atomic-file.ts b/apps/mobile/src/lib/atomic-file.ts new file mode 100644 index 000000000..77a695967 --- /dev/null +++ b/apps/mobile/src/lib/atomic-file.ts @@ -0,0 +1,19 @@ +import type { File } from "expo-file-system"; + +let tempFileSequence = 0; + +/** + * Replaces a file's contents through a sibling temp file and an overwriting + * rename, so an interrupted write (app restart, process death) never leaves a + * truncated document at the final path. Each write stages through its own + * temp file so concurrent writers to the same destination cannot move or + * clobber each other's staging file mid-flight. + */ +export async function writeFileAtomically(file: File, contents: string): Promise { + const { File: FileConstructor } = await import("expo-file-system"); + tempFileSequence += 1; + const temp = new FileConstructor(file.parentDirectory, `${file.name}.${tempFileSequence}.tmp`); + temp.create({ intermediates: true, overwrite: true }); + temp.write(contents); + temp.moveSync(file, { overwrite: true }); +} diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index f559545c0..e92bb0c6e 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -4,6 +4,7 @@ import { type UploadChatImageAttachment, } from "@t3tools/contracts"; import { estimateBase64ByteSize } from "./base64"; +import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { @@ -65,13 +66,21 @@ export async function pickComposerImages(input: { readonly existingCount: number }; } - const result = await imagePicker.launchImageLibraryAsync({ - mediaTypes: ["images"], - allowsMultipleSelection: true, - selectionLimit: remainingSlots, - base64: true, - quality: 1, - }); + // The picker covers the Android activity, which reports the app as + // backgrounded; the guard keeps background-triggered restarts away mid-pick. + const endHandoff = beginForegroundHandoff(); + let result: Awaited>; + try { + result = await imagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: true, + selectionLimit: remainingSlots, + base64: true, + quality: 1, + }); + } finally { + endHandoff(); + } if (result.canceled) { return { diff --git a/apps/mobile/src/lib/foreground-handoff.test.ts b/apps/mobile/src/lib/foreground-handoff.test.ts new file mode 100644 index 000000000..06608e692 --- /dev/null +++ b/apps/mobile/src/lib/foreground-handoff.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { beginForegroundHandoff, isForegroundHandoffActive } from "./foreground-handoff"; + +describe("foreground handoff", () => { + it("is active only while a handoff is open", () => { + expect(isForegroundHandoffActive()).toBe(false); + const end = beginForegroundHandoff(); + expect(isForegroundHandoffActive()).toBe(true); + end(); + expect(isForegroundHandoffActive()).toBe(false); + }); + + it("stays active until every overlapping handoff ends", () => { + const endFirst = beginForegroundHandoff(); + const endSecond = beginForegroundHandoff(); + endFirst(); + expect(isForegroundHandoffActive()).toBe(true); + endSecond(); + expect(isForegroundHandoffActive()).toBe(false); + }); + + it("tolerates an end function called twice", () => { + const endFirst = beginForegroundHandoff(); + const endSecond = beginForegroundHandoff(); + endFirst(); + endFirst(); + expect(isForegroundHandoffActive()).toBe(true); + endSecond(); + expect(isForegroundHandoffActive()).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/foreground-handoff.ts b/apps/mobile/src/lib/foreground-handoff.ts new file mode 100644 index 000000000..768491317 --- /dev/null +++ b/apps/mobile/src/lib/foreground-handoff.ts @@ -0,0 +1,22 @@ +/** + * Tracks app-initiated OS-surface handoffs (image picker, auth tab, share + * sheet). Android reports the app as backgrounded while one of these covers + * the activity, so background-triggered work — like a deferred app update + * restart — has to wait them out instead of tearing down the mid-flow runtime. + */ +let activeHandoffs = 0; + +/** Returns an idempotent end function; call it when the handoff resolves. */ +export function beginForegroundHandoff(): () => void { + activeHandoffs += 1; + let ended = false; + return () => { + if (ended) return; + ended = true; + activeHandoffs -= 1; + }; +} + +export function isForegroundHandoffActive(): boolean { + return activeHandoffs > 0; +} diff --git a/apps/mobile/src/lib/layoutMetrics.ts b/apps/mobile/src/lib/layoutMetrics.ts index 139fcbb65..a661c70d4 100644 --- a/apps/mobile/src/lib/layoutMetrics.ts +++ b/apps/mobile/src/lib/layoutMetrics.ts @@ -3,3 +3,15 @@ export const HOME_HORIZONTAL_INSET = 20; /** Compensates for the tighter native sidebar title margin on iPad. */ export const IPAD_HOME_TITLE_OFFSET = 10; + +/** + * Height of the native iOS navigation bar below the safe-area inset, used as + * a fallback when the measured HeaderHeightContext is unavailable. + */ +export const IOS_NAV_BAR_HEIGHT = 44; + +/* Height of the app's own header chrome below the safe-area inset, on every + * platform (matches the `min-h-12` AndroidScreenHeader). Distinct from the + * 44pt native iOS navigation bar. + */ +export const APP_BAR_HEIGHT = 48; diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 6e41f2243..867d9e983 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -126,6 +126,22 @@ describe("nativeMarkdownTextRuns", () => { ]); }); + it.each([ + ["😀", "😀"], + ["🚀", "🚀"], + ["�", "�"], + ["�", "�"], + ["&#9999999999;", "�"], + ["&#x110000;", "�"], + ])("normalizes numeric entity %s without throwing", (content, expected) => { + const node: MarkdownNode = { + type: "paragraph", + children: [{ type: "text", content }], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: expected }]); + }); + it("reads inline content from nested text nodes", () => { const node: MarkdownNode = { type: "paragraph", @@ -173,6 +189,25 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); + it("decorates known skill references inside blockquotes", () => { + const node: MarkdownNode = { + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $ui for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toContainEqual({ + text: "$ui", + role: "body", + skillName: "ui", + skillLabel: "UI", + }); + }); + it("leaves unknown skill-like text unchanged", () => { const node: MarkdownNode = { type: "document", @@ -328,7 +363,7 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); - it("includes quotes and fenced code in the same selectable string", () => { + it("preserves quotes and fenced code in document runs", () => { const node: MarkdownNode = { type: "document", children: [ @@ -414,6 +449,39 @@ describe("nativeMarkdownListItemBlocks", () => { }); describe("nativeMarkdownDocumentChunks", () => { + it("renders plain blockquotes as rich blocks so their marker spans wrapped lines", () => { + const blockquote: MarkdownNode = { + type: "blockquote", + beg: 0, + end: 120, + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + content: + "Persistent random per-result keys are the strongest design, even when this text wraps.", + }, + ], + }, + ], + }; + + expect( + nativeMarkdownDocumentChunks({ + type: "document", + children: [blockquote], + }), + ).toEqual([ + { + kind: "rich", + key: "rich:blockquote:0:120", + node: blockquote, + }, + ]); + }); + it("keeps headings and plain lists in one selectable document", () => { const document: MarkdownNode = { type: "document", diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 4e9d62ad2..32094109b 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -19,6 +19,7 @@ import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { acknowledgeComposerNativeEvent, + assumeComposerControlledState, isComposerNativeEcho, pruneAcknowledgedComposerNativeEvents, resolveComposerControlledEventCount, @@ -102,11 +103,11 @@ export function ComposerEditor({ const nativeRef = useRef(null); const mostRecentEventCountRef = useRef(0); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); - const [nativeEventSequence, setNativeEventSequence] = useState(0); - const previousRenderedEventSequenceRef = useRef(0); - const nativeEventSnapshotsRef = useRef([ - { eventCount: 0, value: props.value, selection: selection ?? null }, - ]); + const [, forceNativeEventRender] = useState(0); + // The native editor mounts empty, so the snapshot history starts empty: the + // first controlled payload must be a non-echo so a restored draft (or a + // recycled native view) is applied rather than skipped. + const nativeEventSnapshotsRef = useRef([]); const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); const bodyText = useScaledTextRole("body"); const textColor = useThemeColor("--color-foreground"); @@ -154,15 +155,16 @@ export function ComposerEditor({ })), ); }, [props.value, skillLabels]); - const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current; - const controlledEventCount = includesNativeEvent - ? resolveComposerControlledEventCount( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ) - : mostRecentEventCount; + // Every render resolves against the snapshot history, so a render whose + // (value, selection) lags the acknowledged native state is stamped behind + // the native revision and rejected by the editor instead of re-applying a + // stale caret or stale text mid-typing. + const controlledEventCount = resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); const acknowledgesLatestNativeEvent = isComposerNativeEcho( props.value, selection ?? null, @@ -170,9 +172,7 @@ export function ComposerEditor({ nativeEventSnapshotsRef.current, ); const isNativeEcho = - includesNativeEvent && - controlledEventCount === mostRecentEventCount && - acknowledgesLatestNativeEvent; + controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; const controlledDocumentJson = JSON.stringify({ value: props.value, selection: isNativeEcho ? null : (selection ?? null), @@ -180,9 +180,6 @@ export function ComposerEditor({ mostRecentEventCount: controlledEventCount, isNativeEcho, }); - useEffect(() => { - previousRenderedEventSequenceRef.current = nativeEventSequence; - }, [nativeEventSequence]); useEffect(() => { if (!acknowledgesLatestNativeEvent) return; nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( @@ -190,6 +187,18 @@ export function ComposerEditor({ mostRecentEventCount, ); }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + const assumedValue = props.value; + useEffect(() => { + // A native event that arrived after this render was committed moves the + // acknowledged revision forward; the editor rejects this payload, so the + // snapshot history must not assume it applied. + if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return; + nativeEventSnapshotsRef.current = assumeComposerControlledState( + nativeEventSnapshotsRef.current, + controlledEventCount, + assumedValue, + ); + }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( @@ -257,7 +266,7 @@ export function ComposerEditor({ onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerSelectionChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -266,9 +275,16 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + // A selection change that raced a text mutation can carry post-edit + // text. It must reach the parent alongside the acknowledged revision, + // or the next render stamps the stale draft at that revision and can + // re-apply it over the newer native text. + if (event.nativeEvent.value !== props.value) { + onChangeText(event.nativeEvent.value); + } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index e78f90a7d..ff177abf1 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -21,6 +21,7 @@ import { useFontFamily } from "../lib/useFontFamily"; import { useThemeColor } from "../lib/useThemeColor"; import { acknowledgeComposerNativeEvent, + assumeComposerControlledState, isComposerNativeEcho, pruneAcknowledgedComposerNativeEvents, resolveComposerControlledEventCount, @@ -103,11 +104,11 @@ export function ComposerEditor({ const nativeRef = useRef(null); const mostRecentEventCountRef = useRef(0); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); - const [nativeEventSequence, setNativeEventSequence] = useState(0); - const previousRenderedEventSequenceRef = useRef(0); - const nativeEventSnapshotsRef = useRef([ - { eventCount: 0, value: props.value, selection: selection ?? null }, - ]); + const [, forceNativeEventRender] = useState(0); + // The native editor mounts empty, so the snapshot history starts empty: the + // first controlled payload must be a non-echo so a restored draft (or a + // recycled native view) is applied rather than skipped. + const nativeEventSnapshotsRef = useRef([]); const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); const confirmedTokensRef = useRef(initialConfirmedTokens); const textColor = useThemeColor("--color-foreground"); @@ -155,15 +156,16 @@ export function ComposerEditor({ })), ); }, [props.value, skillLabels]); - const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current; - const controlledEventCount = includesNativeEvent - ? resolveComposerControlledEventCount( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ) - : mostRecentEventCount; + // Every render resolves against the snapshot history, so a render whose + // (value, selection) lags the acknowledged native state is stamped behind + // the native revision and rejected by the editor instead of re-applying a + // stale caret or stale text mid-typing. + const controlledEventCount = resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); const acknowledgesLatestNativeEvent = isComposerNativeEcho( props.value, selection ?? null, @@ -171,9 +173,7 @@ export function ComposerEditor({ nativeEventSnapshotsRef.current, ); const isNativeEcho = - includesNativeEvent && - controlledEventCount === mostRecentEventCount && - acknowledgesLatestNativeEvent; + controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent; const controlledDocumentJson = JSON.stringify({ value: props.value, selection: isNativeEcho ? null : (selection ?? null), @@ -181,9 +181,6 @@ export function ComposerEditor({ mostRecentEventCount: controlledEventCount, isNativeEcho, }); - useEffect(() => { - previousRenderedEventSequenceRef.current = nativeEventSequence; - }, [nativeEventSequence]); useEffect(() => { if (!acknowledgesLatestNativeEvent) return; nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( @@ -191,6 +188,18 @@ export function ComposerEditor({ mostRecentEventCount, ); }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + const assumedValue = props.value; + useEffect(() => { + // A native event that arrived after this render was committed moves the + // acknowledged revision forward; the editor rejects this payload, so the + // snapshot history must not assume it applied. + if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return; + nativeEventSnapshotsRef.current = assumeComposerControlledState( + nativeEventSnapshotsRef.current, + controlledEventCount, + assumedValue, + ); + }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]); const acceptNativeEvent = useCallback( (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { const acknowledgedEventCount = acknowledgeComposerNativeEvent( @@ -263,7 +272,7 @@ export function ComposerEditor({ onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerSelectionChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -272,9 +281,17 @@ export function ComposerEditor({ event.nativeEvent.selection, ); if (acknowledgedEventCount === false) return; + // Android emits the selection change mid-mutation, before the change + // event, so the payload can carry post-edit text. It must reach the + // parent alongside the acknowledged revision, or the next render + // stamps the stale draft at that revision and can re-apply it over + // the newer native text. + if (event.nativeEvent.value !== props.value) { + onChangeText(event.nativeEvent.value); + } onSelectionChange?.(event.nativeEvent.selection); setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); + forceNativeEventRender((sequence) => sequence + 1); }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} diff --git a/apps/mobile/src/native/T3HeaderButton.android.tsx b/apps/mobile/src/native/T3HeaderButton.android.tsx deleted file mode 100644 index 74908abd1..000000000 --- a/apps/mobile/src/native/T3HeaderButton.android.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { requireNativeView } from "expo"; -import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; - -interface NativeHeaderButtonProps extends ViewProps { - readonly label: string; - readonly systemImage: "gearshape" | "square.and.pencil"; - readonly onTriggered: (event: NativeSyntheticEvent>) => void; -} - -const NativeHeaderButton = requireNativeView("T3NativeControls"); - -export function T3HeaderButton(props: { - readonly accessibilityLabel: string; - readonly icon: NativeHeaderButtonProps["systemImage"]; - readonly onPress: () => void; - readonly style?: StyleProp; -}) { - return ( - - ); -} diff --git a/apps/mobile/src/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts index 9b255a547..ccc2214e2 100644 --- a/apps/mobile/src/native/composerEditorRevision.test.ts +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { acknowledgeComposerNativeEvent, + assumeComposerControlledState, isComposerNativeEcho, pruneAcknowledgedComposerNativeEvents, resolveComposerControlledEventCount, @@ -42,6 +43,19 @@ describe("isComposerNativeEcho", () => { it("matches value and revision when selection is uncontrolled", () => { expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true); }); + + it("does not claim a controlled selection against an assumed state without one", () => { + // An echo payload serializes `selection: null`; classifying a controlled + // selection as an echo of an assumed state would drop a parent caret move. + const assumed = [{ eventCount: 3, value: "native", selection: null }]; + expect(isComposerNativeEcho("native", { start: 0, end: 0 }, 3, assumed)).toBe(false); + expect(isComposerNativeEcho("other", { start: 0, end: 0 }, 3, assumed)).toBe(false); + }); + + it("matches an assumed state when selection is uncontrolled", () => { + const assumed = [{ eventCount: 3, value: "native", selection: null }]; + expect(isComposerNativeEcho("native", null, 3, assumed)).toBe(true); + }); }); describe("resolveComposerControlledEventCount", () => { @@ -95,7 +109,7 @@ describe("pruneAcknowledgedComposerNativeEvents", () => { selection: { start: eventCount, end: eventCount }, })); - expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([]); + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([snapshots[999]]); }); it("retains native events that arrive after the acknowledged render", () => { @@ -104,6 +118,77 @@ describe("pruneAcknowledgedComposerNativeEvents", () => { { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, ]; - expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual([snapshots[1]]); + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual(snapshots); + }); + + it("retains the newest acknowledged snapshot so settled re-renders stay echoes", () => { + const snapshots = [ + { eventCount: 40, value: "a", selection: { start: 1, end: 1 } }, + { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, + { eventCount: 42, value: "abc", selection: { start: 3, end: 3 } }, + ]; + + const pruned = pruneAcknowledgedComposerNativeEvents(snapshots, 42); + expect(pruned).toEqual([snapshots[2]]); + expect(isComposerNativeEcho("abc", { start: 3, end: 3 }, 42, pruned)).toBe(true); + }); + + it("keeps the newest of several snapshots sharing the acknowledged revision", () => { + const snapshots = [ + { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, + { eventCount: 41, value: "ab", selection: { start: 1, end: 1 } }, + ]; + + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 41)).toEqual([snapshots[1]]); + }); +}); + +describe("assumeComposerControlledState", () => { + it("replaces the acknowledged history with the applied controlled state", () => { + const snapshots = [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }]; + + expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([ + { eventCount: 3, value: "", selection: null }, + ]); + }); + + it("keeps native events that raced past the controlled revision", () => { + const snapshots = [ + { eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }, + { eventCount: 4, value: "typed!", selection: { start: 6, end: 6 } }, + ]; + + expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([ + { eventCount: 3, value: "", selection: null }, + snapshots[1], + ]); + }); + + it("applies a parent caret move on the assumed value at the assumed revision", () => { + // Same value, new caret: not an echo (so the selection is serialized) but + // still stamped at the assumed revision so the editor accepts it. + const snapshots = assumeComposerControlledState([], 3, "typed"); + + expect(isComposerNativeEcho("typed", { start: 2, end: 2 }, 3, snapshots)).toBe(false); + expect(resolveComposerControlledEventCount("typed", { start: 2, end: 2 }, 3, snapshots)).toBe( + 3, + ); + }); + + it("re-applies a parent value that round-trips back to an acknowledged state", () => { + // Native acknowledged "typed", the parent then controlled the editor to "" + // (a send clearing the draft) and back to "typed" (the send failed and the + // draft was restored). The restore must be a fresh non-echo edit stamped at + // the current revision, not an echo the editor would drop. + const snapshots = assumeComposerControlledState( + [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }], + 3, + "", + ); + + expect(isComposerNativeEcho("typed", { start: 5, end: 5 }, 3, snapshots)).toBe(false); + expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 3, snapshots)).toBe( + 3, + ); }); }); diff --git a/apps/mobile/src/native/composerEditorRevision.ts b/apps/mobile/src/native/composerEditorRevision.ts index ea18d153d..45d68ac1b 100644 --- a/apps/mobile/src/native/composerEditorRevision.ts +++ b/apps/mobile/src/native/composerEditorRevision.ts @@ -31,10 +31,7 @@ export function resolveComposerControlledEventCount( if (snapshot?.value !== value) continue; newestValueEventCount ??= snapshot.eventCount; - if ( - selection === null || - (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end) - ) { + if (selection === null || snapshotSelectionMatches(snapshot, selection)) { return snapshot.eventCount; } } @@ -49,6 +46,21 @@ export function resolveComposerControlledEventCount( return mostRecentEventCount; } +// A snapshot without a selection describes a state the editor applied itself +// (an assumed controlled document, where the native side may have bounded the +// caret). Revision stamping treats it as matching any controlled selection so +// a parent caret move on the assumed value stays at the assumed revision and +// passes the editor's staleness guard. Echo detection must not reuse this +// wildcard: an echo payload serializes `selection: null`, which would drop +// that caret move instead of applying it. +function snapshotSelectionMatches( + snapshot: ComposerNativeEventSnapshot, + selection: ComposerEditorSelection, +): boolean { + if (snapshot.selection === null) return true; + return snapshot.selection.start === selection.start && snapshot.selection.end === selection.end; +} + export function isComposerNativeEcho( value: string, selection: ComposerEditorSelection | null, @@ -62,7 +74,9 @@ export function isComposerNativeEcho( snapshot.eventCount === eventCount && snapshot.value === value && (selection === null || - (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end)) + (snapshot.selection !== null && + snapshot.selection.start === selection.start && + snapshot.selection.end === selection.end)) ) { return true; } @@ -70,9 +84,43 @@ export function isComposerNativeEcho( return false; } +/** + * Records that a parent-driven controlled document was handed to the native + * editor. From that point the acknowledged snapshot history describes a + * superseded native state, so it is replaced with the assumed applied state; + * a later parent update back to a previously acknowledged value must classify + * as a fresh edit, not as a native echo the editor would drop. Native events + * that raced past the controlled revision stay authoritative and are kept. + */ +export function assumeComposerControlledState( + snapshots: ReadonlyArray, + eventCount: number, + value: string, +): ComposerNativeEventSnapshot[] { + return [ + { eventCount, value, selection: null }, + ...snapshots.filter((snapshot) => snapshot.eventCount > eventCount), + ]; +} + export function pruneAcknowledgedComposerNativeEvents( snapshots: ReadonlyArray, acknowledgedEventCount: number, ): ComposerNativeEventSnapshot[] { - return snapshots.filter((snapshot) => snapshot.eventCount > acknowledgedEventCount); + // The newest acknowledged snapshot must survive pruning: it is what lets a + // later, unrelated re-render classify the settled composer state as a native + // echo instead of a parent-driven edit that would re-control the caret (and + // reset the keyboard's autocorrect context on iOS). + let latestAcknowledgedIndex = -1; + for (let index = snapshots.length - 1; index >= 0; index -= 1) { + const snapshot = snapshots[index]; + if (snapshot !== undefined && snapshot.eventCount <= acknowledgedEventCount) { + latestAcknowledgedIndex = index; + break; + } + } + return snapshots.filter( + (snapshot, index) => + index === latestAcknowledgedIndex || snapshot.eventCount > acknowledgedEventCount, + ); } diff --git a/apps/mobile/src/native/native-glass.ts b/apps/mobile/src/native/native-glass.ts index 40b28076d..18f221940 100644 --- a/apps/mobile/src/native/native-glass.ts +++ b/apps/mobile/src/native/native-glass.ts @@ -1,9 +1,9 @@ -import { isLiquidGlassSupported } from "@callstack/liquid-glass"; +import { isGlassEffectAPIAvailable } from "expo-glass-effect"; import { Platform } from "react-native"; import { supportsNativeLiquidGlass } from "../lib/native-glass-capability"; export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass( Platform.OS, - isLiquidGlassSupported, + isGlassEffectAPIAvailable(), ); diff --git a/apps/mobile/src/native/sheet-surface.ts b/apps/mobile/src/native/sheet-surface.ts new file mode 100644 index 000000000..eb2e8a8d1 --- /dev/null +++ b/apps/mobile/src/native/sheet-surface.ts @@ -0,0 +1,28 @@ +import { DynamicColorIOS, Platform, type ColorValue, type ViewStyle } from "react-native"; + +/** + * One opaque surface for content rendered inside a native form sheet. + * + * UIKit owns the outer sheet material and rounded corners. The presented route + * owns this surface so nested navigators never expose a differently colored + * native container while their screens move. + */ +export const NATIVE_SHEET_SURFACE_COLOR: ColorValue | undefined = + Platform.OS === "ios" ? DynamicColorIOS({ light: "#f2f2f7", dark: "#0e0e0e" }) : undefined; + +export const NATIVE_SHEET_SURFACE_CONTENT_STYLE: ViewStyle | undefined = + NATIVE_SHEET_SURFACE_COLOR === undefined + ? undefined + : { backgroundColor: NATIVE_SHEET_SURFACE_COLOR }; + +/** + * Paint the adaptive background on the presented screen itself. Nested stacks + * can stay transparent over this single surface, so a push never exposes an + * unpainted form-sheet host behind the moving child view controllers. + */ +export const FORM_SHEET_PRESENTATION_OPTIONS = { + presentation: "formSheet" as const, + ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined + ? null + : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }), +}; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 3e085e9d1..1b4e8bb22 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -26,6 +26,7 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; + readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -35,6 +36,8 @@ export interface Preferences { */ readonly progressiveThreadHistoryEnabled?: boolean; readonly legacyThreadListEnabled?: boolean; + /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ + readonly planModeEnabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -75,7 +78,22 @@ export class MobilePreferencesStore extends Context.Service< >()("@t3tools/mobile/persistence/MobilePreferencesStore") {} function sanitizePreferences(parsed: Preferences): Preferences { - const preferences: { -readonly [Key in keyof Preferences]: Preferences[Key] } = {}; + const preferences: { + liveActivitiesEnabled?: boolean; + baseFontSize?: number; + terminalFontSize?: number | null; + markdownFontSize?: number; + codeFontSize?: number | null; + codeWordBreak?: boolean; + connectOnboardingOptOutAccounts?: ReadonlyArray; + collapsedProjectGroups?: readonly string[]; + projectGroupingEnabled?: boolean; + projectGroupingMode?: SidebarProjectGroupingMode; + autoSettleOnMerge?: boolean; + progressiveThreadHistoryEnabled?: boolean; + legacyThreadListEnabled?: boolean; + planModeEnabled?: boolean; + } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; @@ -111,15 +129,18 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } + if (typeof parsed.autoSettleOnMerge === "boolean") { + preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; + } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } - if ( - parsed.progressiveThreadHistoryEnabled === true || - parsed.progressiveThreadHistoryEnabled === false - ) { + if (typeof parsed.progressiveThreadHistoryEnabled === "boolean") { preferences.progressiveThreadHistoryEnabled = parsed.progressiveThreadHistoryEnabled; } + if (typeof parsed.planModeEnabled === "boolean") { + preferences.planModeEnabled = parsed.planModeEnabled; + } return preferences; } diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index b02b190db..0c0da1f84 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,14 +1,23 @@ -import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; +import type { + EnvironmentId, + OrchestrationThread, + ThreadId, + VcsListRefsResult, + VcsRef, +} from "@t3tools/contracts"; import { createThreadSearchResultsAtomFamily, makeThreadSearchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; -import { useEffect, useMemo, useState } from "react"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { appAtomRegistry } from "./atom-registry"; import { orchestrationEnvironment } from "./orchestration"; import { projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; @@ -24,6 +33,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; const COMPOSER_PATH_SEARCH_LIMIT = 20; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; +const EMPTY_REFS: ReadonlyArray = []; +const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ matches: EMPTY_THREAD_SEARCH_MATCHES, @@ -52,7 +63,7 @@ export interface ComposerPathSearchTarget { readonly query: string | null; } -function useDebouncedValue(value: A, delayMs: number): A { +export function useDebouncedValue(value: A, delayMs: number): A { const [debounced, setDebounced] = useState(value); useEffect(() => { @@ -125,6 +136,113 @@ export function useBranches(input: { ); } +export function usePaginatedBranches(target: VcsRefTarget) { + const query = target.query?.trim() ?? ""; + const targetKey = + target.environmentId !== null && target.cwd !== null + ? JSON.stringify([target.environmentId, target.cwd, query]) + : null; + const [pagination, setPagination] = useState<{ + readonly targetKey: string | null; + readonly cursors: ReadonlyArray; + }>({ + targetKey, + cursors: INITIAL_BRANCH_CURSORS, + }); + const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listRefs({ + environmentId: target.environmentId!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit: VCS_REF_LIST_LIMIT, + }, + }), + ) + : [], + [cursors, query, target.cwd, target.environmentId], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], + ); + const results = useAtomValue(pagesAtom); + const values = results.flatMap((result) => { + const value = Option.getOrNull(AsyncResult.value(result)); + return value === null ? [] : [value]; + }); + const refs = new Map(); + for (const value of values) { + for (const ref of value.refs) { + refs.set(ref.name, ref); + } + } + const first = values[0] ?? null; + const last = values.at(-1) ?? null; + const data: VcsListRefsResult | null = + first === null || last === null + ? null + : { + refs: [...refs.values()], + isRepo: first.isRepo, + hasPrimaryRemote: first.hasPrimaryRemote, + nextCursor: last.nextCursor, + totalCount: Math.max(...values.map((value) => value.totalCount)), + }; + const lastResult = results.at(-1); + const isFetchingNextPage = + results.length > 1 && + lastResult?.waiting === true && + Option.isNone(AsyncResult.value(lastResult)); + const failed = results.find((result) => result._tag === "Failure"); + const error = + failed?._tag === "Failure" + ? (() => { + const cause = Cause.squash(failed.cause); + return cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load refs."; + })() + : null; + const refresh = useCallback(() => { + const firstPage = pageAtoms[0]; + setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); + if (firstPage !== undefined) { + appAtomRegistry.refresh(firstPage); + } + }, [pageAtoms, targetKey]); + const loadNext = useCallback(() => { + if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) { + return; + } + setPagination((current) => { + const currentCursors = + current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS; + return currentCursors.includes(data.nextCursor!) + ? { targetKey, cursors: currentCursors } + : { targetKey, cursors: [...currentCursors, data.nextCursor!] }; + }); + }, [data?.nextCursor, targetKey]); + + return { + data, + refs: data?.refs ?? EMPTY_REFS, + error, + isPending: results.some((result) => result.waiting), + isFetchingNextPage, + refresh, + loadNext, + }; +} + export function useComposerPathSearch(target: ComposerPathSearchTarget) { const normalizedTarget = useMemo( () => ({ diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3ba61be38..eede50697 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -169,7 +169,7 @@ export function resolveThreadOutboxDeliveryAction(input: { if (!input.threadExists) { return input.shellStatus === "live" ? "remove" : "wait"; } - return input.environmentConnected && !input.threadBusy ? "send" : "wait"; + return input.environmentConnected ? "send" : "wait"; } /** diff --git a/apps/mobile/src/state/thread-outbox-storage.ts b/apps/mobile/src/state/thread-outbox-storage.ts index 2003c220b..ab0853f7d 100644 --- a/apps/mobile/src/state/thread-outbox-storage.ts +++ b/apps/mobile/src/state/thread-outbox-storage.ts @@ -1,6 +1,7 @@ import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { writeFileAtomically } from "../lib/atomic-file"; import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, @@ -9,6 +10,24 @@ import { const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; +const inFlightWrites = new Set>(); + +function trackInFlightWrite(operation: Promise): Promise { + inFlightWrites.add(operation); + void operation.catch(() => undefined).finally(() => inFlightWrites.delete(operation)); + return operation; +} + +/** + * Awaits queued-message writes so an app update restart cannot tear down the + * runtime while one is mid-file. + */ +export async function flushThreadOutboxWrites(): Promise { + while (inFlightWrites.size > 0) { + await Promise.allSettled(inFlightWrites); + } +} + export class ThreadOutboxStorageError extends Schema.TaggedErrorClass()( "ThreadOutboxStorageError", { @@ -89,11 +108,12 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { write: async (message) => { const fileName = messageFileName(message.messageId); try { - const file = await getMessageFile(message.messageId); - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(encodeQueuedThreadMessage(message))); + await trackInFlightWrite( + (async () => { + const file = await getMessageFile(message.messageId); + await writeFileAtomically(file, JSON.stringify(encodeQueuedThreadMessage(message))); + })(), + ); } catch (cause) { throw new ThreadOutboxStorageError({ operation: "write", diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 89f8b2679..b12ad2dc5 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -487,6 +487,27 @@ describe("thread outbox", () => { ).toBe("send"); }); + it("sends existing-thread messages whenever connected so queued messages can steer", () => { + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: false, + threadExists: true, + shellStatus: "live", + environmentConnected: true, + threadBusy: true, + }), + ).toBe("send"); + expect( + resolveThreadOutboxDeliveryAction({ + isCreation: false, + threadExists: true, + shellStatus: "live", + environmentConnected: false, + threadBusy: true, + }), + ).toBe("wait"); + }); + it("sends queued creations once connected and live, removing already-created ones", () => { expect( resolveThreadOutboxDeliveryAction({ diff --git a/apps/mobile/src/state/thread-outbox.ts b/apps/mobile/src/state/thread-outbox.ts index 59287b12e..1de1f8da6 100644 --- a/apps/mobile/src/state/thread-outbox.ts +++ b/apps/mobile/src/state/thread-outbox.ts @@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { appAtomRegistry } from "./atom-registry"; import { createThreadOutboxManager } from "./thread-outbox-manager"; import type { QueuedThreadMessage } from "./thread-outbox-model"; -import { expoThreadOutboxStorage } from "./thread-outbox-storage"; +import { expoThreadOutboxStorage, flushThreadOutboxWrites } from "./thread-outbox-storage"; export * from "./thread-outbox-model"; @@ -12,6 +12,17 @@ export const threadOutboxManager = createThreadOutboxManager({ storage: expoThreadOutboxStorage, }); +/** + * Lands queued outbox mutations before the JS runtime is torn down (app update + * restart). An enqueued message is published to the atom immediately but its + * durable write waits behind the mutation queue, so draining only the writes + * already mid-file would miss it. + */ +export async function flushThreadOutbox(): Promise { + await threadOutboxManager.serialize(async () => {}); + await flushThreadOutboxWrites(); +} + export function ensureThreadOutboxLoaded(): void { void threadOutboxManager.load(); } diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index fed97e81e..8dbddfe1f 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1,16 +1,79 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { vi } from "vite-plus/test"; + +const composerDraftFileMocks = vi.hoisted(() => { + let document = ""; + let writeError: Error | null = null; + let releaseRead: (() => void) | null = null; + let readBarrier = Promise.resolve(); + + return { + blockRead() { + readBarrier = new Promise((resolve) => { + releaseRead = resolve; + }); + }, + releaseRead() { + releaseRead?.(); + releaseRead = null; + }, + getDocument() { + return document; + }, + setDocument(value: unknown) { + document = JSON.stringify(value); + }, + setWriteError(error: Error | null) { + writeError = error; + }, + Directory: class { + create() {} + }, + File: class { + exists = true; + parentDirectory = null; + + create() {} + + moveSync() {} + + async text() { + await readBarrier; + return document; + } + + write(value: string) { + if (writeError) { + throw writeError; + } + document = value; + } + }, + }; +}); + +vi.mock("expo-file-system", () => ({ + Directory: composerDraftFileMocks.Directory, + File: composerDraftFileMocks.File, + Paths: { document: "/documents" }, +})); import { appAtomRegistry } from "./atom-registry"; import { clearComposerDraftContentState, + ComposerDraftPersistenceError, composerDraftsAtom, + copyComposerDraftContentIfEmpty, + copyComposerDraftContentState, decodePersistedComposerDrafts, type ComposerDraft, + flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, removeComposerDraftsForEnvironment, restoreComposerDraftSnapshotState, + setComposerDraftText, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -165,6 +228,53 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); }); + it("carries unfinished content to a newly selected project without overwriting its settings", () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-1:project-2"; + const source: ComposerDraft = { + text: "Keep this task", + attachments: [], + importedShareIds: ["share-1"], + workspaceSelection: { + mode: "worktree", + branch: "feature/source", + worktreePath: null, + }, + }; + const target: ComposerDraft = { + text: "", + attachments: [], + runtimeMode: "approval-required", + }; + + expect( + copyComposerDraftContentState( + { [sourceKey]: source, [targetKey]: target }, + sourceKey, + targetKey, + ), + ).toEqual({ + [sourceKey]: source, + [targetKey]: { + ...target, + text: source.text, + attachments: source.attachments, + importedShareIds: source.importedShareIds, + }, + }); + }); + + it("does not overwrite unfinished content already stored for the selected project", () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-1:project-2"; + const drafts: Record = { + [sourceKey]: { text: "Source task", attachments: [] }, + [targetKey]: { text: "Target task", attachments: [] }, + }; + + expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); + }); + it("merges shared content into a project draft without duplicating retries", () => { const draftKey = "new-task:environment-1:project-1"; const sharedAttachment = { @@ -268,4 +378,58 @@ describe("mobile composer drafts", () => { [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }); }); + + it("waits for persisted drafts before copying content between projects", async () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-1:project-2"; + const unrelatedKey = "environment-1:thread-1"; + const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; + const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; + const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; + + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + [targetKey]: target, + [unrelatedKey]: unrelated, + }, + }); + composerDraftFileMocks.blockRead(); + appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); + + const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); + + composerDraftFileMocks.releaseRead(); + await copy; + + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ + [sourceKey]: source, + [targetKey]: target, + [unrelatedKey]: unrelated, + }); + }); + + it("lands a still-debounced draft write when flushed", async () => { + const draftKey = "environment-1:thread-1"; + setComposerDraftText(draftKey, "typed right before the restart"); + + await flushComposerDrafts(); + + expect(JSON.parse(composerDraftFileMocks.getDocument())).toMatchObject({ + drafts: { [draftKey]: { text: "typed right before the restart" } }, + }); + }); + + it("propagates a flush write failure instead of resolving as saved", async () => { + const draftKey = "environment-1:thread-1"; + setComposerDraftText(draftKey, "unsaved"); + composerDraftFileMocks.setWriteError(new Error("storage unavailable")); + + try { + await expect(flushComposerDrafts()).rejects.toBeInstanceOf(ComposerDraftPersistenceError); + } finally { + composerDraftFileMocks.setWriteError(null); + } + }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 24fa547e2..7dbea2359 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; +import { writeFileAtomically } from "../lib/atomic-file"; import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; @@ -188,10 +189,7 @@ async function writePersistedComposerDrafts(drafts: Record } } +/** + * Lands any debounced or in-flight draft write before the JS runtime is torn + * down (app update restart), so the freshest draft state survives it. A write + * failure propagates so the caller can decide whether the restart may proceed. + */ +export async function flushComposerDrafts(): Promise { + // An edit during an awaited write schedules another debounced write, so + // keep landing snapshots until no debounce is pending after a queue drain. + do { + while (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + await persistenceQueue.run(() => + writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)), + ); + } + await persistenceQueue.run(() => Promise.resolve()); + } while (persistTimer !== null); +} + function schedulePersistComposerDrafts(drafts: Record): void { if (persistTimer !== null) { clearTimeout(persistTimer); @@ -253,7 +271,11 @@ export function ensureComposerDraftsLoaded(): void { function updateComposerDrafts( update: (current: Record) => Record, ): void { - const next = update(appAtomRegistry.get(composerDraftsAtom)); + const current = appAtomRegistry.get(composerDraftsAtom); + const next = update(current); + if (next === current) { + return; + } appAtomRegistry.set(composerDraftsAtom, next); schedulePersistComposerDrafts(next); } @@ -412,6 +434,51 @@ export function restoreComposerDraftSnapshotState( return next; } +export function copyComposerDraftContentState( + current: Record, + sourceDraftKey: string, + targetDraftKey: string, +): Record { + if (sourceDraftKey === targetDraftKey) { + return current; + } + const source = normalizeDraft(current[sourceDraftKey]); + const target = normalizeDraft(current[targetDraftKey]); + const sourceHasContent = + source.text.length > 0 || + source.attachments.length > 0 || + (source.importedShareIds?.length ?? 0) > 0; + const targetHasContent = + target.text.length > 0 || + target.attachments.length > 0 || + (target.importedShareIds?.length ?? 0) > 0; + if (!sourceHasContent || targetHasContent) { + return current; + } + return { + ...current, + [targetDraftKey]: { + ...target, + text: source.text, + attachments: source.attachments, + ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), + }, + }; +} + +export async function copyComposerDraftContentIfEmpty( + sourceDraftKey: string, + targetDraftKey: string, +): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + updateComposerDrafts((current) => + copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey), + ); +} + function mergeComposerDraftText(existing: string, incoming: string): string { if (incoming.length === 0) { return existing; @@ -578,7 +645,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI persistTimer = null; } appAtomRegistry.set(composerDraftsAtom, next); - await writePersistedComposerDrafts(next); + await persistenceQueue.run(() => writePersistedComposerDrafts(next)); } export function useComposerDraft(draftKey: string | null): ComposerDraft { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b09aadf7e..721c82a0e 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -129,10 +129,6 @@ export function useThreadComposerState() { ); }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); - const activeThreadBusy = - !!selectedThread && - (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); - const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { return null; @@ -308,7 +304,6 @@ export function useThreadComposerState() { modelSelection, runtimeMode, interactionMode, - activeThreadBusy, onChangeDraftMessage, onPickDraftImages, onPasteIntoDraft, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index d06a4098a..68c973ff9 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -37,7 +37,7 @@ import { type QueuedThreadMessage, type ThreadOutboxCommandStage, } from "./thread-outbox-model"; -import { environmentThreadShells, threadEnvironment } from "./threads"; +import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; import { editingQueuedMessageIdsAtom, @@ -362,22 +362,12 @@ export function useThreadOutboxDrain(): void { return true; } // The guards evaluated before the confirmation await are stale by now: - // the thread may have gone busy, or the user may have opened this - // message in the editor. Re-read both and defer to the next drain pass - // (returning true skips the failure/backoff path) rather than sending - // a payload the user is editing or racing an active turn. + // the user may have opened this message in the editor. Re-read that + // guard and defer to the next drain pass (returning true skips the + // failure/backoff path) rather than sending a payload being edited. if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) { return true; } - const freshThread = findThread( - appAtomRegistry.get(environmentThreadShells.threadShellsAtom), - nextQueuedMessage, - ); - const freshThreadBusy = - freshThread?.session?.status === "running" || freshThread?.session?.status === "starting"; - if (deliveryAction === "send" && creation === undefined && freshThreadBusy) { - return true; - } return deliveryAction === "remove" ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") : creation !== undefined diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index ef15e650a..f330e62a5 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -36,6 +36,7 @@ import * as CliState from "../cloud/CliState.ts"; import * as CliTokenManager from "../cloud/CliTokenManager.ts"; import { CLOUD_LINKED_USER_ID, + isAgentActivityPublishingEnabledValue, PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_URL_SECRET, } from "../cloud/config.ts"; @@ -142,7 +143,7 @@ function stringToBytes(value: string): Uint8Array { } export function isPublishAgentActivityEnabledValue(value: string | null): boolean { - return value === "true"; + return isAgentActivityPublishingEnabledValue(value); } interface CloudCliStatus { @@ -447,7 +448,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* => + Effect.gen(function* () { + const readSecretString = (name: string) => + secrets + .get(name) + .pipe( + Effect.map((bytes) => + Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null, + ), + ); + const [enabled, url, environmentCredential] = yield* Effect.all([ + readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET), + readSecretString(RELAY_URL_SECRET), + readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + // Empty strings are as unconfigured as missing files: the publisher's + // truthiness gate skips them, so the capability must too. + return ( + isAgentActivityPublishingEnabledValue(enabled) && + url !== null && + url !== "" && + environmentCredential !== null && + environmentCredential !== "" + ); + }).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 57b76453a..ea73acebc 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -3,10 +3,17 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { + PUBLISH_AGENT_ACTIVITY_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_URL_SECRET, +} from "../cloud/config.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; @@ -15,7 +22,21 @@ const isServerEnvironmentIdPersistenceError = Schema.is( ); const makeServerEnvironmentLayer = (baseDir: string) => - ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + +const emptySecretStoreLayer = Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.succeed(Option.none()), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: () => Effect.void, + }), +); const makeServerConfig = Effect.fn(function* (baseDir: string) { const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); @@ -73,6 +94,53 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.agentActivityPublishing).toBe(false); + }), + ); + + it.effect("reports agent activity publishing from the current secret state", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-publish-test-", + }); + const testLayer = Layer.mergeAll( + ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)), + ServerSecretStore.layer, + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + + yield* Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const encode = (value: string) => new TextEncoder().encode(value); + + const unlinked = yield* serverEnvironment.getDescriptor; + expect(unlinked.capabilities.agentActivityPublishing).toBe(false); + + // The opt-in alone is not enough: without relay link credentials no + // publish would leave this environment. + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("true")); + const withoutLink = yield* serverEnvironment.getDescriptor; + expect(withoutLink.capabilities.agentActivityPublishing).toBe(false); + + // Empty credentials are as unconfigured as missing ones: the + // publisher's truthiness gate skips them, so the capability must not + // advertise publishing. + yield* secrets.set(RELAY_URL_SECRET, encode("")); + yield* secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential")); + const emptyUrl = yield* serverEnvironment.getDescriptor; + expect(emptyUrl.capabilities.agentActivityPublishing).toBe(false); + + yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example")); + const linked = yield* serverEnvironment.getDescriptor; + expect(linked.capabilities.agentActivityPublishing).toBe(true); + + // The toggle changes at runtime, so the same service instance must + // reflect a flip without a restart. + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false")); + const disabled = yield* serverEnvironment.getDescriptor; + expect(disabled.capabilities.agentActivityPublishing).toBe(false); + }).pipe(Effect.provide(testLayer)); }), ); @@ -115,6 +183,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }).pipe( Effect.provide( ServerEnvironment.layer.pipe( + Layer.provide(emptySecretStoreLayer), Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), ), ), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 290fca161..0b3ffe3e5 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -9,6 +9,8 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; @@ -66,6 +68,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; @@ -157,13 +160,22 @@ export const make = Effect.gen(function* () { return ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), - getDescriptor: Effect.succeed(descriptor), + // The publish opt-in and relay link change at runtime (`t3 connect + // publish`, the client settings toggle), so the capability is read per + // descriptor request rather than baked in at startup. + getDescriptor: readAgentActivityPublishingActive(secrets).pipe( + Effect.map((agentActivityPublishing) => ({ + ...descriptor, + capabilities: { ...descriptor.capabilities, agentActivityPublishing }, + })), + ), }); }); /** * ServerEnvironment is acquired from persisted filesystem and host-process * state. It intentionally has no fallback Layer.succeed value: callers must - * provide the external platform services and a ServerConfig. + * provide the external platform services, a ServerConfig, and the + * ServerSecretStore backing the descriptor's publishing capability. */ export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 69b572916..944cbd85a 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -1,35 +1,60 @@ import * as NodeNet from "node:net"; import { it as effectIt } from "@effect/vitest"; +import { + CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, + PREVIEW_URL_MAX_LENGTH, + type DiscoveredLocalServer, +} from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; import { expect } from "vite-plus/test"; +import { FetchHttpClient } from "effect/unstable/http"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; -const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { - run: (input) => - Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: input.command, - argumentCount: input.args.length, - cwd: input.cwd, - cause: PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "PowerShell is not installed in the test environment", - }), +const processProbeFailure: ProcessRunner.ProcessRunner["Service"]["run"] = (input) => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cwd: input.cwd, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "PowerShell is not installed in the test environment", }), - ), + }), + ); + +const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: processProbeFailure, }); -const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) => +let integrationListeningPort: number | null = null; + +const TestIntegrationNet = Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), +}); + +const makeProbeFailureLayer = ( + run: ProcessRunner.ProcessRunner["Service"]["run"], + fetch: typeof globalThis.fetch = globalThis.fetch, +) => PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( @@ -41,19 +66,64 @@ const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run" findAvailablePort: (preferred) => Effect.succeed(preferred), }), Layer.succeed(HostProcessPlatform, "linux"), + FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))), ), ), ); const TestPortDiscoveryLive = PortScanner.layer.pipe( Layer.provide( - Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")), + Layer.mergeAll( + TestProcessRunner, + TestIntegrationNet, + Layer.succeed(HostProcessPlatform, "win32"), + FetchHttpClient.layer, + ), ), ); -const openServer = (port: number): Effect.Effect => +const LSOF_TEST_PORT = 43_123; + +const makeLsofScannerLayer = (input: { + readonly pid: () => number; + readonly fetch: typeof globalThis.fetch; +}) => + PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + Effect.succeed({ + stdout: `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }), + Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }), + Layer.succeed(HostProcessPlatform, "linux"), + FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)), + ), + ), + ), + ); + +const openServer = ( + port: number, + onConnection: (socket: NodeNet.Socket) => void, +): Effect.Effect => Effect.callback((resume) => { - const server = NodeNet.createServer(); + const server = NodeNet.createServer(onConnection); server.once("error", () => { resume(Effect.succeed(null)); }); @@ -72,9 +142,10 @@ const closeServer = (server: NodeNet.Server): Effect.Effect => const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( ports: ReadonlyArray, + onConnection: (socket: NodeNet.Socket) => void, ) { for (const port of ports) { - const server = yield* openServer(port); + const server = yield* openServer(port, onConnection); if (server !== null) return { port, server }; } return yield* Effect.die( @@ -83,8 +154,46 @@ const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(fun }); const commonDevServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS), - ({ server }) => closeServer(server), + openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => { + socket.once("data", () => { + socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); + }); + }).pipe( + Effect.tap(({ port }) => + Effect.sync(() => { + integrationListeningPort = port; + }), + ), + ), + ({ server }) => + closeServer(server).pipe( + Effect.ensuring( + Effect.sync(() => { + integrationListeningPort = null; + }), + ), + ), +); + +const commonNonHttpServer = Effect.acquireRelease( + openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => { + socket.on("error", () => undefined); + socket.once("data", () => socket.end("MYSQL\r\n\r\n")); + }).pipe( + Effect.tap(({ port }) => + Effect.sync(() => { + integrationListeningPort = port; + }), + ), + ), + ({ server }) => + closeServer(server).pipe( + Effect.ensuring( + Effect.sync(() => { + integrationListeningPort = null; + }), + ), + ), ); /** @@ -94,7 +203,7 @@ const commonDevServer = Effect.acquireRelease( */ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { it.effect( - "scan() returns a server we just opened on a curated dev port", + "scan() returns an HTTP server we just opened on a curated dev port", Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; @@ -105,13 +214,23 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall }), ); + it.effect( + "scan() excludes a listening port that does not speak HTTP", + Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () { + const { port } = yield* commonNonHttpServer; + const scanner = yield* PortScanner.PortDiscovery; + const result = yield* scanner.scan(); + expect(result.some((server) => server.port === port)).toBe(false); + }), + ); + it.effect( "retain drives an immediate broadcast to subscribers", Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; - yield* scanner.subscribe((servers) => + yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) => Effect.sync(() => { for (const server of servers) received.push(server.port); }), @@ -122,7 +241,395 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall ); }); -effectIt("does not swallow process probe defects", () => +effectIt.effect("revalidates a successful HTML probe after its cache entry expires", () => { + let responds = true; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return responds + ? Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })) + : Promise.reject(new TypeError("not HTTP")); + }) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + expect(yield* scanner.scan()).toHaveLength(1); + expect(yield* scanner.scan()).toHaveLength(1); + expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + + responds = false; + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toEqual([ + `http://localhost:${LSOF_TEST_PORT}/`, + `http://localhost:${LSOF_TEST_PORT}/`, + `https://localhost:${LSOF_TEST_PORT}/`, + ]); + }).pipe(Effect.provide(layer)); +}); + +effectIt.effect("keeps a full configured URL when the discovered server root fails", () => { + const requests: string[] = []; + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; + const fetchFn = ((input: Parameters[0]) => { + const url = String(input); + requests.push(url); + return Promise.resolve( + url === configuredUrl + ? new Response("docs", { headers: { "content-type": "text/html" } }) + : new Response("not found", { + status: 404, + headers: { "content-type": "text/html" }, + }), + ); + }) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const servers = yield* scanner.scan([configuredUrl]); + expect(servers).toHaveLength(1); + expect(servers[0]?.url).toBe(configuredUrl); + expect(requests).toContain(configuredUrl); + }).pipe(Effect.provide(layer)); +}); + +effectIt.effect("probes configured custom ports through a canonical loopback host", () => { + const customPort = 43_124; + const configuredUrl = `http://0.0.0.0:${customPort}/docs`; + const expectedUrl = `http://localhost:${customPort}/docs`; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("docs", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = makeProbeFailureLayer(processProbeFailure, fetchFn); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const servers = yield* scanner.scan([configuredUrl]); + expect(servers).toHaveLength(1); + expect(servers[0]?.host).toBe("localhost"); + expect(servers[0]?.port).toBe(customPort); + expect(servers[0]?.url).toBe(expectedUrl); + expect(requests).toEqual([expectedUrl]); + }).pipe(Effect.provide(layer)); +}); + +effectIt.effect("preserves explicit loopback hosts and bounds wildcard rewrites", () => { + const ipv4Url = "https://127.0.0.1:43125/docs"; + const ipv6Url = "http://[::1]:43126/docs"; + const wildcardPrefix = "http://0.0.0.0/"; + const maximumWildcardUrl = `${wildcardPrefix}${"a".repeat( + PREVIEW_URL_MAX_LENGTH - wildcardPrefix.length, + )}`; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("docs", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = makeProbeFailureLayer(processProbeFailure, fetchFn); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const servers = yield* scanner.scan([ipv4Url, ipv6Url, maximumWildcardUrl]); + expect(servers.map((server) => server.url)).toEqual([ipv4Url, ipv6Url]); + expect(requests).toEqual([ipv4Url, ipv6Url]); + }).pipe(Effect.provide(layer)); +}); + +effectIt.effect("projects configured paths independently for simultaneous subscribers", () => { + const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; + const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`; + const fetchFn = ((input: Parameters[0]) => { + const url = String(input); + return Promise.resolve( + url === docsUrl || url === adminUrl + ? new Response("app", { headers: { "content-type": "text/html" } }) + : new Response("not found", { status: 404 }), + ); + }) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const docsSnapshots: ReadonlyArray[] = []; + const adminSnapshots: ReadonlyArray[] = []; + yield* scanner.subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, (servers) => + Effect.sync(() => docsSnapshots.push(servers)), + ); + yield* scanner.subscribe({ configuredUrls: [adminUrl], initialSnapshot: [] }, (servers) => + Effect.sync(() => adminSnapshots.push(servers)), + ); + yield* scanner.retain; + + expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); + expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); + }).pipe(Effect.scoped, Effect.provide(layer)); +}); + +effectIt.effect( + "keeps each subscriber's candidates when their combined union exceeds the per-client cap", + () => { + const firstSubscriberUrls = Array.from( + { length: CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS }, + (_, index) => `http://localhost:${LSOF_TEST_PORT}/app-${index}`, + ); + const secondSubscriberUrl = `http://localhost:${LSOF_TEST_PORT}/app-${CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS}`; + const fetchFn = ((input: Parameters[0]) => + Promise.resolve( + String(input) === secondSubscriberUrl + ? new Response("app", { headers: { "content-type": "text/html" } }) + : new Response("not found", { status: 404 }), + )) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const secondSnapshots: ReadonlyArray[] = []; + yield* scanner.subscribe( + { configuredUrls: firstSubscriberUrls, initialSnapshot: [] }, + () => Effect.void, + ); + yield* scanner.subscribe( + { configuredUrls: [secondSubscriberUrl], initialSnapshot: [] }, + (servers) => Effect.sync(() => secondSnapshots.push(servers)), + ); + yield* scanner.retain; + + expect(secondSnapshots.at(-1)?.[0]?.url).toBe(secondSubscriberUrl); + }).pipe(Effect.scoped, Effect.provide(layer)); + }, +); + +effectIt.effect("stops probing a subscriber's configured paths after its scope closes", () => { + const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; + const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + const url = String(input); + requests.push(url); + return Promise.resolve( + url === docsUrl || url === adminUrl + ? new Response("app", { headers: { "content-type": "text/html" } }) + : new Response("not found", { status: 404 }), + ); + }) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const docsScope = yield* Scope.make(); + yield* scanner + .subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, () => Effect.void) + .pipe(Effect.provideService(Scope.Scope, docsScope)); + yield* scanner.subscribe( + { configuredUrls: [adminUrl], initialSnapshot: [] }, + () => Effect.void, + ); + yield* scanner.retain; + yield* Scope.close(docsScope, Exit.void); + + requests.length = 0; + yield* TestClock.adjust(Duration.seconds(15)); + expect(requests).toContain(adminUrl); + expect(requests).not.toContain(docsUrl); + }).pipe(Effect.scoped, Effect.provide(layer)); +}); + +effectIt.effect("uses the current configured fragment when readiness comes from cache", () => { + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("docs", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + const oldUrl = `http://localhost:${LSOF_TEST_PORT}/docs#old`; + const newUrl = `http://localhost:${LSOF_TEST_PORT}/docs#new`; + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + expect((yield* scanner.scan([oldUrl]))[0]?.url).toBe(oldUrl); + const requestCount = requests.length; + expect((yield* scanner.scan([newUrl]))[0]?.url).toBe(newUrl); + expect(requests).toHaveLength(requestCount); + }).pipe(Effect.provide(layer)); +}); + +effectIt.effect("shares a configured root probe with discovered-root classification", () => { + const requests: string[] = []; + const rootUrl = `http://localhost:${LSOF_TEST_PORT}/`; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + expect(yield* scanner.scan([rootUrl])).toHaveLength(1); + expect(requests).toEqual([rootUrl]); + + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([rootUrl])).toHaveLength(1); + expect(requests).toEqual([rootUrl, rootUrl]); + }).pipe(Effect.provide(layer)); +}); + +effectIt.effect("starts fresh cache entries after the probing batch completes", () => + Effect.gen(function* () { + const baseClock = yield* Clock.Clock; + const times = [0, 20_000, 20_000, 20_000]; + let timeIndex = 0; + const currentTimeMillis = () => times[Math.min(timeIndex++, times.length - 1)]!; + const clock: Clock.Clock = { + ...baseClock, + currentTimeMillisUnsafe: currentTimeMillis, + currentTimeMillis: Effect.sync(currentTimeMillis), + }; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + yield* Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + expect(yield* scanner.scan()).toHaveLength(1); + expect(yield* scanner.scan()).toHaveLength(1); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer), Effect.provideService(Clock.Clock, clock)); + }), +); + +effectIt.effect("caches a failed web probe until its bounded cache entry expires", () => { + let responds = false; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return responds + ? Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })) + : Promise.reject(new TypeError("not HTTP")); + }) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toHaveLength(2); + + responds = true; + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan()).toHaveLength(1); + expect(requests).toHaveLength(3); + }).pipe(Effect.provide(layer)); +}); + +effectIt.effect("falls back to HTTPS and does not follow redirects while probing", () => { + const redirects: Array = []; + const fetchFn = (async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + redirects.push(init?.redirect); + if (String(input).startsWith("http:")) throw new TypeError("TLS listener"); + return new Response(null, { status: 302, headers: { location: "https://example.com" } }); + }) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const servers = yield* scanner.scan(); + expect(servers).toHaveLength(1); + expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); + expect(redirects).toEqual(["manual", "manual"]); + }).pipe(Effect.provide(layer)); +}); + +effectIt.effect( + "excludes HTTP errors, non-navigation responses, and successful non-documents", + () => { + let pid = 1; + let makeResponse = () => + new Response("not found", { status: 404, headers: { "content-type": "text/html" } }); + const fetchFn = ((_input: Parameters[0]) => + Promise.resolve(makeResponse())) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => pid, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + expect(yield* scanner.scan()).toHaveLength(0); + + pid += 1; + makeResponse = () => + new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + expect(yield* scanner.scan()).toHaveLength(0); + + pid += 1; + makeResponse = () => + new Response("ready", { status: 200, headers: { "content-type": "text/plain" } }); + expect(yield* scanner.scan()).toHaveLength(0); + + pid += 1; + makeResponse = () => new Response(null, { status: 304, headers: { location: "/cached" } }); + expect(yield* scanner.scan()).toHaveLength(0); + + pid += 1; + makeResponse = () => + new Response(null, { status: 204, headers: { "content-type": "text/html" } }); + expect(yield* scanner.scan()).toHaveLength(0); + + pid += 1; + makeResponse = () => new Response(null, { status: 302 }); + expect(yield* scanner.scan()).toHaveLength(0); + + pid += 1; + makeResponse = () => + new Response("", { + status: 200, + headers: { "content-type": "application/xhtml+xml; charset=utf-8" }, + }); + expect(yield* scanner.scan()).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }, +); + +effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => { + const aborted: string[] = []; + const fetchFn = (( + input: Parameters[0], + init?: Parameters[1], + ) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + const onAbort = () => { + aborted.push(String(input)); + reject(new DOMException("Aborted", "AbortError")); + }; + if (signal?.aborted) { + onAbort(); + } else { + signal?.addEventListener("abort", onAbort, { once: true }); + } + })) as typeof globalThis.fetch; + const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const scanFiber = yield* Effect.forkChild(scanner.scan()); + yield* TestClock.adjust(Duration.seconds(2)); + expect(yield* Fiber.join(scanFiber)).toHaveLength(0); + expect(aborted).toEqual([ + `http://localhost:${LSOF_TEST_PORT}/`, + `https://localhost:${LSOF_TEST_PORT}/`, + ]); + }).pipe(Effect.provide(layer)); +}); + +effectIt.effect("does not swallow process probe defects", () => Effect.gen(function* () { const defect = new Error("unexpected process probe defect"); const layer = makeProbeFailureLayer(() => Effect.die(defect)); @@ -140,7 +647,7 @@ effectIt("does not swallow process probe defects", () => }), ); -effectIt("does not swallow process probe interruption", () => +effectIt.effect("does not swallow process probe interruption", () => Effect.gen(function* () { const layer = makeProbeFailureLayer(() => Effect.interrupt); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index c306fca2b..4571aeef4 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -8,29 +8,49 @@ * Windows / lsof missing: checks a curated list of common dev ports through * the shared Net service. * + * Listening ports are published only after a bounded HTTP(S) probe finds a + * successful HTML document or a redirect to one. + * Positive and negative results are cached briefly by candidate URL and listener identity, + * limiting repeated requests without leaving stale classifications around. + * * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber * polls forever, but each tick is a no-op when the retain count is zero. */ -import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; +import { + CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, + PREVIEW_URL_MAX_LENGTH, + ThreadId, + type DiscoveredLocalServer, +} from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; -import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; +import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import * as ProcessRunner from "../processRunner.ts"; export class PortDiscovery extends Context.Service< PortDiscovery, { - readonly scan: () => Effect.Effect>; + readonly scan: ( + configuredUrls?: ReadonlyArray, + ) => Effect.Effect>; readonly subscribe: ( + input: { + readonly configuredUrls: ReadonlyArray; + readonly initialSnapshot: ReadonlyArray; + }, listener: (servers: ReadonlyArray) => Effect.Effect, ) => Effect.Effect; readonly retain: Effect.Effect; @@ -53,12 +73,20 @@ export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; +const WEB_PROBE_TIMEOUT = Duration.seconds(1); +const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); +const WEB_PROBE_CONCURRENCY = 16; +const NAVIGATION_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); type Listener = (servers: ReadonlyArray) => Effect.Effect; -interface ScannerState { +interface ListenerSubscription { + readonly configuredUrls: ReadonlyArray; readonly lastSnapshot: ReadonlyArray; - readonly listeners: ReadonlySet; +} + +interface ScannerState { + readonly listeners: ReadonlyMap; readonly terminalProcesses: ReadonlyMap< string, { @@ -74,11 +102,86 @@ interface TerminalProcessOwner { readonly terminalId: string; } +interface WebProbeCacheEntry { + readonly pid: number | null; + readonly isWeb: boolean; + readonly expiresAtMillis: number; +} + +interface WebProbeGroup { + readonly server: DiscoveredLocalServer; + readonly urls: ReadonlyArray; + readonly configuredKey: string | null; +} + +interface WebProbeSnapshot { + readonly discovered: ReadonlyArray; + readonly configured: ReadonlyMap; +} + const terminalOwnerKey = (owner: { readonly threadId: string; readonly terminalId: string; }): string => `${owner.threadId}\u0000${owner.terminalId}`; +const parseConfiguredUrl = (raw: string): URL | null => { + try { + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (!isLoopbackHost(url.hostname)) return null; + return url; + } catch { + return null; + } +}; + +const localServerKey = (host: string, port: number): string => + `${isLoopbackHost(host) ? "loopback" : host.toLowerCase()}:${port}`; + +const urlPort = (url: URL): number => + url.port.length > 0 ? Number.parseInt(url.port, 10) : url.protocol === "http:" ? 80 : 443; + +const webProbeCacheKey = (raw: string): string => { + const url = new URL(raw); + url.hash = ""; + return url.href; +}; + +const normalizeConfiguredUrls = (urls: ReadonlyArray): ReadonlyArray => [ + ...new Set( + urls + .slice(0, CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS) + .filter((raw) => raw.length <= PREVIEW_URL_MAX_LENGTH) + .map(parseConfiguredUrl) + .filter((url): url is URL => url !== null && url.href.length <= PREVIEW_URL_MAX_LENGTH) + .map((url) => { + if (url.hostname === "0.0.0.0") url.hostname = "localhost"; + return url.href; + }) + .filter((url) => url.length <= PREVIEW_URL_MAX_LENGTH), + ), +]; + +const projectWebProbeSnapshot = ( + snapshot: WebProbeSnapshot, + configuredUrls: ReadonlyArray, +): ReadonlyArray => { + const visibleByServer = new Map(); + for (const raw of normalizeConfiguredUrls(configuredUrls)) { + const url = new URL(raw); + const port = urlPort(url); + const serverKey = localServerKey(url.hostname, port); + if (visibleByServer.has(serverKey)) continue; + const configured = snapshot.configured.get(webProbeCacheKey(raw)); + if (configured) visibleByServer.set(serverKey, { ...configured, url: raw }); + } + for (const server of snapshot.discovered) { + const key = localServerKey(server.host, server.port); + if (!visibleByServer.has(key)) visibleByServer.set(key, server); + } + return [...visibleByServer.values()].toSorted((left, right) => left.port - right.port); +}; + const parseLsofOutput = ( raw: string, terminalByProcessId: ReadonlyMap = new Map(), @@ -190,12 +293,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcessPlatform; + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make({ - lastSnapshot: [], - listeners: new Set(), + listeners: new Map(), terminalProcesses: new Map(), retainCount: 0, }); + const webProbeCacheRef = yield* Ref.make>(new Map()); + const scanSemaphore = yield* Semaphore.make(1); const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { const results = yield* Effect.forEach( @@ -221,6 +326,149 @@ export const make = Effect.gen(function* PortDiscoveryMake() { })); }); + const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => + httpClient.get(url).pipe( + Effect.map((response) => { + const location = response.headers.location?.trim(); + if (NAVIGATION_REDIRECT_STATUSES.has(response.status) && location) return url; + if (response.status < 200 || response.status >= 300) return null; + if (response.status === 204 || response.status === 205) return null; + const contentType = response.headers["content-type"] + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + return contentType === "text/html" || contentType === "application/xhtml+xml" ? url : null; + }), + Effect.scoped, + Effect.timeoutOption(WEB_PROBE_TIMEOUT), + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + ), + ); + + const makeWebProbeGroups = ( + servers: ReadonlyArray, + configuredUrls: ReadonlyArray, + ): ReadonlyArray => { + const serversByKey = new Map( + servers.map((server) => [localServerKey(server.host, server.port), server] as const), + ); + const groups: WebProbeGroup[] = []; + const configuredResources = new Set(); + + for (const raw of configuredUrls) { + const url = new URL(raw); + const port = urlPort(url); + const key = localServerKey(url.hostname, port); + const resourceKey = webProbeCacheKey(raw); + if (configuredResources.has(resourceKey)) continue; + configuredResources.add(resourceKey); + groups.push({ + server: serversByKey.get(key) ?? { + host: url.hostname, + port, + url: raw, + processName: null, + pid: null, + terminal: null, + }, + urls: [raw], + configuredKey: resourceKey, + }); + } + + for (const server of servers) { + groups.push({ + server, + urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], + configuredKey: null, + }); + } + + return groups; + }; + + const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(function* ( + servers: ReadonlyArray, + configuredUrls: ReadonlyArray, + ) { + const nowMillis = yield* Clock.currentTimeMillis; + const cached = yield* Ref.get(webProbeCacheRef); + const groups = makeWebProbeGroups(servers, configuredUrls); + const batchProbes = new Map< + string, + Effect.Effect<{ readonly probe: WebProbeCacheEntry; readonly fresh: boolean }> + >(); + const batchProbeSemaphore = yield* Semaphore.make(1); + const getProbe = (url: string, pid: number | null) => { + const key = webProbeCacheKey(url); + const identity = `${key}\u0000${pid ?? ""}`; + return batchProbeSemaphore + .withPermits(1)( + Effect.gen(function* () { + const existing = batchProbes.get(identity); + if (existing) return [existing] as const; + const cachedProbe = cached.get(key); + const cachedIsCurrent = + cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis; + const memoized = yield* Effect.cached( + cachedIsCurrent + ? Effect.succeed({ probe: cachedProbe, fresh: false }) + : probeWebUrl(url).pipe( + Effect.map((result) => ({ + probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, + fresh: true, + })), + ), + ); + batchProbes.set(identity, memoized); + return [memoized] as const; + }), + ) + .pipe(Effect.flatMap(([probe]) => probe)); + }; + const probed = yield* Effect.forEach( + groups, + (group) => + Effect.gen(function* () { + const probes: Array = []; + let visibleUrl: string | null = null; + for (const url of group.urls) { + const key = webProbeCacheKey(url); + const { probe, fresh } = yield* getProbe(url, group.server.pid); + probes.push([key, probe, fresh]); + if (probe.isWeb) { + visibleUrl = url; + break; + } + } + return { group, probes, visibleUrl }; + }), + { concurrency: WEB_PROBE_CONCURRENCY }, + ); + const completedAtMillis = yield* Clock.currentTimeMillis; + const nextCache = new Map( + [...cached].filter(([, probe]) => probe.expiresAtMillis > completedAtMillis), + ); + const discovered: DiscoveredLocalServer[] = []; + const configured = new Map(); + for (const { group, probes, visibleUrl } of probed) { + for (const [key, probe, fresh] of probes) { + nextCache.set( + key, + fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, + ); + } + if (visibleUrl === null) continue; + const server = { ...group.server, url: visibleUrl }; + if (group.configuredKey === null) discovered.push(server); + else configured.set(group.configuredKey, server); + } + yield* Ref.set(webProbeCacheRef, nextCache); + return { discovered, configured } satisfies WebProbeSnapshot; + }); + const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => Effect.logDebug("preview port process probe failed; falling back to common-port probes", { @@ -229,7 +477,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { platform: hostPlatform, }).pipe(Effect.as(null)); - const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { + const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* ( + configuredUrls: ReadonlyArray, + ) { const state = yield* Ref.get(stateRef); const terminalByProcessId = new Map(); for (const registration of state.terminalProcesses.values()) { @@ -259,8 +509,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverWindowsProbeFailure, }), ); - if (listeners !== null) return listeners; - return yield* probeCommonPorts(); + if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); + return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); const lsofResult = yield* processRunner @@ -281,27 +531,47 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverLsofProbeFailure, }), ); - if (lsofResult !== null) return lsofResult; - return yield* probeCommonPorts(); + if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); + return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); }); - const broadcast = Effect.fn("PortDiscovery.broadcast")(function* ( - servers: ReadonlyArray, - ) { - const listeners = (yield* Ref.get(stateRef)).listeners; - yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true }); - }); + const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( + (configuredUrls: ReadonlyArray) => + scanSemaphore.withPermits(1)(scanUnlocked(configuredUrls)), + ); + + const scanOnce: PortDiscovery["Service"]["scan"] = (configuredUrls = []) => { + const normalized = normalizeConfiguredUrls(configuredUrls); + return scanSnapshot(normalized).pipe( + Effect.map((snapshot) => projectWebProbeSnapshot(snapshot, normalized)), + ); + }; const pollTick = Effect.fn("PortDiscovery.pollTick")( function* () { if ((yield* Ref.get(stateRef)).retainCount <= 0) return; - const next = yield* scanOnce(); - const changed = yield* Ref.modify(stateRef, (state) => - serversEqual(state.lastSnapshot, next) - ? [false, state] - : [true, { ...state, lastSnapshot: next }], - ); - if (changed) yield* broadcast(next); + const configuredUrls = [ + ...new Set( + [...(yield* Ref.get(stateRef)).listeners.values()].flatMap( + (subscription) => subscription.configuredUrls, + ), + ), + ]; + const snapshot = yield* scanSnapshot(configuredUrls); + const notifications = yield* Ref.modify(stateRef, (state) => { + const listeners = new Map(state.listeners); + const changed: Array]> = []; + for (const [listener, subscription] of listeners) { + const next = projectWebProbeSnapshot(snapshot, subscription.configuredUrls); + if (serversEqual(subscription.lastSnapshot, next)) continue; + listeners.set(listener, { ...subscription, lastSnapshot: next }); + changed.push([listener, next]); + } + return [changed, { ...state, listeners }]; + }); + yield* Effect.forEach(notifications, ([listener, servers]) => listener(servers), { + discard: true, + }); }, Effect.catchCause((cause: Cause.Cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause)), @@ -332,15 +602,19 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ); const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")( - (listener) => + (input, listener) => Effect.acquireRelease( - Ref.update(stateRef, (state) => ({ - ...state, - listeners: new Set([...state.listeners, listener]), - })), + Ref.update(stateRef, (state) => { + const listeners = new Map(state.listeners); + listeners.set(listener, { + configuredUrls: normalizeConfiguredUrls(input.configuredUrls), + lastSnapshot: input.initialSnapshot, + }); + return { ...state, listeners }; + }), () => Ref.update(stateRef, (state) => { - const listeners = new Set(state.listeners); + const listeners = new Map(state.listeners); listeners.delete(listener); return { ...state, listeners }; }), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 2a4de7eda..5127ecf7d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -35,6 +35,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { + isAgentActivityPublishingEnabledValue, PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, @@ -102,7 +103,7 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n } export function isAgentActivityPublishingEnabled(value: string | null): boolean { - return value === "true"; + return isAgentActivityPublishingEnabledValue(value); } export function resolveAgentActivityPublishingStartupState(input: { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index efba7a591..82ce2da82 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2137,23 +2137,31 @@ const makeWsRpcLayer = ( observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { "rpc.aggregate": "preview", }), - [WS_METHODS.subscribeDiscoveredLocalServers]: (_input) => + [WS_METHODS.subscribeDiscoveredLocalServers]: (input) => observeRpcStream( WS_METHODS.subscribeDiscoveredLocalServers, Stream.callback((queue) => Effect.gen(function* () { + const configuredUrls = input.configuredUrls ?? []; yield* portDiscovery.retain; - const initial = yield* portDiscovery.scan(); + const initial = yield* portDiscovery.scan(configuredUrls); const initialScannedAt = DateTime.formatIso(yield* DateTime.now); yield* Queue.offer(queue, { servers: initial, scannedAt: initialScannedAt, + configuredUrlProbing: true, }); - yield* portDiscovery.subscribe((servers) => - Effect.gen(function* () { - const scannedAt = DateTime.formatIso(yield* DateTime.now); - yield* Queue.offer(queue, { servers, scannedAt }); - }), + yield* portDiscovery.subscribe( + { configuredUrls, initialSnapshot: initial }, + (servers) => + Effect.gen(function* () { + const scannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { + servers, + scannedAt, + configuredUrlProbing: true, + }); + }), ); }), ), diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 3e839c3b3..bfeecb660 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -5,16 +5,20 @@ import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; import packageJson from "./package.json" with { type: "json" }; -const bundledPackagePrefixes = [ - "@pierre/diffs", - "@t3tools/", - "effect-acp", - "effect-codex-app-server", -]; +// The bundle used to inline only workspace packages, leaving every third-party +// runtime dep external. External deps must exist on the real filesystem (the WSL +// backend runs plain `wsl.exe -- node`, which cannot read inside an asar), so the +// desktop build unpacked `**\/node_modules\/**` wholesale: 13,875 loose files to +// support 20 native binaries. NSIS install time tracks file count, not bytes. +// +// Inverted here — bundle everything except the packages that genuinely cannot be +// inlined. See scripts/lib/cli-external-packages.ts for what earns an exemption. +import { + isExternalCliDependency, + shouldBundleCliDependency, +} from "../../scripts/lib/cli-external-packages.ts"; -export function shouldBundleCliDependency(id: string): boolean { - return bundledPackagePrefixes.some((prefix) => id.startsWith(prefix)); -} +export { shouldBundleCliDependency }; const repoEnv = loadRepoEnv(); const cliBuildChannel = packageJson.version.includes("-canary.") @@ -41,7 +45,14 @@ export default mergeConfig( sourcemap: true, clean: true, deps: { + // Both halves are required. `alwaysBundle` forces the JS dependencies in + // (declared deps are external by default, which is what this change is + // undoing). `neverBundle` forces the native packages out: returning + // false from `alwaysBundle` only means "no opinion", so a transitive + // dependency would still be bundled — which silently inlined + // msgpackr-extract and its loader, losing native acceleration. alwaysBundle: shouldBundleCliDependency, + neverBundle: (id: string) => isExternalCliDependency(id), onlyBundle: false, }, banner: { diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index f20ab0b37..cd33bd216 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -7,7 +7,7 @@ import { type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport"; -import { Link2, X } from "lucide-react"; +import { Link2, Unlink2, X } from "lucide-react"; import { useState } from "react"; import { Button } from "~/components/ui/button"; @@ -310,7 +310,11 @@ export function BrowserDeviceToolbar({ onPointerDown={(event) => event.preventDefault()} onClick={toggleAspectRatio} > - + {aspectRatio === null ? ( + + ) : ( + + )} ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -1633,6 +1634,7 @@ export default function Sidebar() { const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -1837,8 +1839,8 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition. + // PR states stream in per-row. The next partition applies the configured + // merge rule and the always-on close rule. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); @@ -1989,7 +1991,12 @@ export default function Sidebar() { pinned.push(thread); } else if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + autoSettleOnMerge, + changeRequestState, + }) ) { settled.push(thread); } else { @@ -2024,6 +2031,7 @@ export default function Sidebar() { }; }, [ autoSettleAfterDays, + autoSettleOnMerge, changeRequestStateByKey, nowMinute, scopedProjectKeys, @@ -3578,6 +3586,7 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } + autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index 36981930a..95023ee8a 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -160,10 +160,10 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { gradientTransform="translate(216 18) rotate(137) scale(120 84)" gradientUnits="userSpaceOnUse" > - + @@ -193,7 +193,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { ))} - + @@ -306,7 +306,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) { gradientTransform="translate(704 18) rotate(145) scale(132 88)" gradientUnits="userSpaceOnUse" > - + @@ -325,7 +325,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) { diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index db1341996..643cf95ee 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -203,6 +203,7 @@ export const ChatHeader = memo(function ChatHeader({ ); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Enter") { renameCommittedRef.current = true; commitRename(event.currentTarget.value); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8e0464dcc..9792eb6ea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -714,7 +714,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ /> ; @@ -963,19 +960,16 @@ function TimelineMinimap({ return null; } - const safeBottomInset = Math.max(0, Math.ceil(bottomInset)); - return (