diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts new file mode 100644 index 000000000000..dc2e7fb962d1 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -0,0 +1,243 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopCliCommand from "./DesktopCliCommand.ts"; +import * as DesktopCliShim from "./DesktopCliShim.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const environmentFor = ( + path: Path.Path, + input: { home: string; baseDir: string; platform?: NodeJS.Platform; isPackaged?: boolean }, +) => + DesktopEnvironment.DesktopEnvironment.of({ + path, + platform: input.platform ?? "linux", + isPackaged: input.isPackaged ?? true, + homeDirectory: input.home, + baseDir: input.baseDir, + stateDir: path.join(input.baseDir, "userdata"), + serverRoot: "/opt/T3 Code/resources/app.asar", + appImagePath: Option.none(), + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + +/** The service for a packaged app; the launcher is written on Install. */ +const commandIn = ( + input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean }, + spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"], +) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const baseDir = input.baseDir ?? path.join(input.home, ".t3"); + yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true }); + const make = DesktopCliCommand.make.pipe( + Effect.provideService( + DesktopEnvironment.DesktopEnvironment, + environmentFor(path, { ...input, baseDir }), + ), + ); + return yield* spawner + ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) + : make; + }); + +/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */ +const fakePowerShell = (initial: string) => { + const registry = { path: initial, failReads: false, writes: 0 }; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const { options } = command as unknown as { + readonly options: { readonly env?: Record }; + }; + const env = options.env ?? {}; + let exitCode = 0; + let stdout = ""; + if (env.T3_SET === "1") { + registry.path = env.T3_PATH ?? ""; + registry.writes += 1; + } else if (registry.failReads) { + exitCode = 1; + } else { + stdout = Buffer.from(registry.path, "utf8").toString("base64"); + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { registry, spawner }; +}; + +it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { + it.effect("links the launcher onto PATH and removes only that link", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const command = yield* commandIn({ home }); + const link = path.join(home, ".local", "bin", "t3"); + + expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + // Install writes the launcher itself, even when no local backend ever did. + const installed = yield* command.install; + expect(installed.installedPath).toBe(link); + expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); + expect((yield* command.install).installedPath).toBe(link); + + expect((yield* command.uninstall).installedPath).toBeNull(); + expect(yield* fs.exists(link)).toBe(false); + // The launcher itself stays for setup commands. + expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("never replaces or removes a t3 it did not create", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const command = yield* commandIn({ home }); + const theirs = path.join(home, ".local", "bin", "t3"); + yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); + yield* fs.writeFileString(theirs, "npm's t3\n"); + // Even a broken link in the next folder is someone else's. + yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true }); + yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3")); + + const error = yield* Effect.flip(command.install); + expect(error.message).toContain("Another t3 command is already installed"); + yield* command.uninstall; + expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); + expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + }).pipe(Effect.scoped), + ); + + it.effect("finds and removes a link left by a previous T3 home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") }); + const link = (yield* before.install).installedPath; + + const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); + expect((yield* after.state).installedPath).toBe(link); + // Installing again points the link at this home's launcher. + expect((yield* after.install).installedPath).toBe(link); + expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); + yield* after.uninstall; + expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false); + }).pipe(Effect.scoped), + ); + + it.effect("does not read a large binary another t3 links to", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + // A big executable that happens to contain the marker text is still not ours. + const binary = path.join(home, "native-t3"); + yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); + yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); + yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3")); + const command = yield* commandIn({ home }); + expect((yield* command.state).installedPath).toBeNull(); + yield* command.uninstall; + expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary); + }).pipe(Effect.scoped), + ); + + it.effect("reports when another t3 earlier on PATH would run instead", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const shadow = path.join(home, "shadow"); + yield* fs.makeDirectory(shadow); + yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + const previous = process.env.PATH; + process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); + yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous))); + + const command = yield* commandIn({ home }); + const installed = yield* command.install; + expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3")); + expect(installed.onPath).toBe(false); + yield* fs.remove(path.join(shadow, "t3")); + expect((yield* command.state).onPath).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("leaves the Windows PATH alone when it cannot be read", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools"; + const { registry, spawner } = fakePowerShell(userPath); + const command = yield* commandIn({ home, platform: "win32" }, spawner); + + registry.failReads = true; + const error = yield* Effect.flip(command.install); + expect(error.message).toContain("left unchanged"); + expect(registry).toMatchObject({ path: userPath, writes: 0 }); + + registry.failReads = false; + const launcherDir = DesktopCliShim.launcherPath( + environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }), + ).replace(/[\\/]t3\.cmd$/, ""); + yield* command.install; + expect(registry.path).toBe(`${userPath};${launcherDir}`); + yield* command.uninstall; + expect(registry.path).toBe(userPath); + }).pipe(Effect.scoped), + ); + + it.effect("does not claim or remove a Windows PATH entry the user added", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const launcherDir = path.dirname( + DesktopCliShim.launcherPath( + environmentFor(path, { home, baseDir: path.join(home, ".t3"), platform: "win32" }), + ), + ); + const userPath = `C:\\Tools;${launcherDir}`; + const { registry, spawner } = fakePowerShell(userPath); + const command = yield* commandIn({ home, platform: "win32" }, spawner); + + expect((yield* command.state).installedPath).toBeNull(); + yield* command.uninstall; + expect(registry.path).toBe(userPath); + }).pipe(Effect.scoped), + ); + + it.effect("offers nothing for a development build", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const command = yield* commandIn({ + home: yield* fs.makeTempDirectoryScoped(), + isPackaged: false, + }); + expect((yield* command.state).supported).toBe(false); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts new file mode 100644 index 000000000000..534af3716db7 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -0,0 +1,261 @@ +import type { DesktopCliCommandState } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +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 Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopCliShim from "./DesktopCliShim.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +// Settings → Install `t3` command, like VS Code's "Install 'code' command". +// The app's launcher (see DesktopCliShim) lives in the T3 home and is off PATH +// by default. Installing links it into a folder on the user's PATH, or on +// Windows adds the launcher's folder to the user's PATH. Removing undoes only +// what installing did: a link that points at one of the app's launchers, or a +// PATH entry the app recorded adding. + +export class DesktopCliCommandError extends Schema.TaggedError()( + "DesktopCliCommandError", + { message: Schema.String }, +) {} + +/** User-writable folders that login shells commonly put on PATH, in preference order. */ +const unixCandidates = (home: string, platform: NodeJS.Platform) => + platform === "darwin" + ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] + : [`${home}/.local/bin`, `${home}/bin`]; + +const pathEntries = (value: string | undefined, separator: string) => + (value ?? "").split(separator).filter((entry) => entry.length > 0); + +/** Windows paths compare without case or a trailing separator. */ +const sameWindowsPath = (left: string, right: string) => + left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); + +/** + * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping + * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for + * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` + * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it + * opens see the change. A failed read exits nonzero rather than reading as + * empty, so a write never replaces the user's PATH wholesale. + */ +const WINDOWS_USER_PATH_SCRIPT = ` +$ErrorActionPreference = 'Stop' +if ($env:T3_SET -eq '1') { + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') + $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString + if ($key.GetValueNames() -contains 'Path' -and $key.GetValueKind('Path') -eq 'String') { + $kind = [Microsoft.Win32.RegistryValueKind]::String + } + $key.SetValue('Path', $env:T3_PATH, $kind) + Add-Type -Namespace T3 -Name Env -MemberDefinition '[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);' + $result = [System.UIntPtr]::Zero + [void][T3.Env]::SendMessageTimeout([System.IntPtr]0xffff, 0x1A, [System.UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) +} else { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + $value = if ($key) { $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') } else { '' } + [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$value))) +} +`; + +export class DesktopCliCommand extends Context.Service< + DesktopCliCommand, + { + readonly state: Effect.Effect; + readonly install: Effect.Effect; + readonly uninstall: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopCliCommand") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = environment.path; + const windows = environment.platform === "win32"; + const launcher = DesktopCliShim.launcherPath(environment); + const binDirectory = path.dirname(launcher); + /** Records that Install added `binDirectory` to the Windows PATH, so Remove takes out only that. */ + const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); + + const fail = (message: string) => new DesktopCliCommandError({ message }); + const exists = (target: string) => fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + const writableDirectory = (directory: string) => + fs.access(directory, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + + /** + * A link to one of the app's launchers, by the marker the launcher carries. + * This also finds links made under a previous T3 home, so Remove can clean + * them up and Install does not add a second. + */ + const isOurLink = (link: string) => + Effect.gen(function* () { + yield* fs.readLink(link); + // The launcher is a few KB; never read a large binary another `t3` links to. + const info = yield* fs.stat(link); + if (info.type !== "File" || Number(info.size) > 16_384) return false; + const content = yield* fs.readFileString(link); + return content.includes(DesktopCliShim.MARKER); + }).pipe(Effect.orElseSucceed(() => false)); + + const powershell = (env: Record) => + Effect.scoped( + Effect.gen(function* () { + const root = process.env.SystemRoot ?? process.env.WINDIR; + const handle = yield* spawner.spawn( + ChildProcess.make( + root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_USER_PATH_SCRIPT], + { stdin: "ignore", stderr: "ignore", env, extendEnv: true }, + ), + ); + const [stdout, exitCode] = yield* Effect.all( + [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return yield* Effect.fail(`powershell exited with ${exitCode}`); + return stdout; + }), + ); + const readUserPath = powershell({}).pipe( + Effect.map((encoded) => Buffer.from(encoded.trim(), "base64").toString("utf8")), + Effect.mapError(() => fail("Could not read your PATH, so it was left unchanged.")), + ); + const writeUserPath = (value: string) => + powershell({ T3_SET: "1", T3_PATH: value }).pipe( + Effect.asVoid, + Effect.mapError(() => fail("Could not update your PATH.")), + ); + + /** The `t3` a new shell runs, by PATH order, or none. */ + const firstOnPath = Effect.gen(function* () { + for (const directory of pathEntries(process.env.PATH, ":")) { + const candidate = path.join(directory, "t3"); + if (yield* exists(candidate)) return Option.some(candidate); + } + return Option.none(); + }); + + /** Where this app's command is installed now, if anywhere. */ + const installedAt = Effect.gen(function* () { + if (windows) { + if (!(yield* exists(ownedPathMarker))) return Option.none(); + const entries = pathEntries(yield* readUserPath, ";"); + return entries.some((entry) => sameWindowsPath(entry, binDirectory)) + ? Option.some(launcher) + : Option.none(); + } + for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + const link = path.join(directory, "t3"); + if (yield* isOurLink(link)) return Option.some(link); + } + return Option.none(); + }); + + const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { + if (!environment.isPackaged) { + return { supported: false, installedPath: null, onPath: false } as const; + } + const installed = yield* installedAt; + if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false }; + // On Windows only terminals opened after the change see it. On Unix the + // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it. + const first = yield* firstOnPath; + const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); + return { supported: true, installedPath: installed.value, onPath }; + }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); + + /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */ + const ensureLauncher = DesktopCliShim.install.pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(fail(`Could not set up the t3 launcher at ${launcher}.`)), + onSome: () => Effect.void, + }), + ), + ); + + const install: DesktopCliCommand["Service"]["install"] = Effect.gen(function* () { + if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app."); + yield* ensureLauncher; + if (windows) { + const entries = pathEntries(yield* readUserPath, ";"); + if (!entries.some((entry) => sameWindowsPath(entry, binDirectory))) { + yield* writeUserPath([...entries, binDirectory].join(";")); + yield* fs + .writeFileString(ownedPathMarker, `${binDirectory}\n`) + .pipe(Effect.mapError(() => fail("Added t3 to your PATH but could not record it."))); + } + return yield* state; + } + const existing = yield* installedAt; + if (Option.isSome(existing)) { + const target = yield* fs.readLink(existing.value).pipe(Effect.option); + if (Option.getOrUndefined(target) === launcher) return yield* state; + // A link to a previous T3 home's launcher: point it at this one instead. + yield* fs + .remove(existing.value) + .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); + } + const onPath = pathEntries(process.env.PATH, ":"); + const candidates = unixCandidates(environment.homeDirectory, environment.platform); + // Prefer a folder already on PATH that the user can write to without admin rights. + for (const directory of [ + ...candidates.filter((candidate) => onPath.includes(candidate)), + ...candidates.filter((candidate) => !onPath.includes(candidate)), + ]) { + const link = path.join(directory, "t3"); + const created = (yield* exists(directory)) + ? yield* writableDirectory(directory) + : yield* fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!created) continue; + // symlink fails if anything, even a broken link, is already there. + const linked = yield* fs.symlink(launcher, link).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (linked) return yield* state; + } + return yield* fail( + `Another t3 command is already installed, or no folder on your PATH is writable. Run the launcher directly at ${launcher}.`, + ); + }).pipe(Effect.withSpan("desktop.cliCommand.install")); + + const uninstall: DesktopCliCommand["Service"]["uninstall"] = Effect.gen(function* () { + if (windows) { + if (yield* exists(ownedPathMarker)) { + const entries = pathEntries(yield* readUserPath, ";"); + const kept = entries.filter((entry) => !sameWindowsPath(entry, binDirectory)); + if (kept.length !== entries.length) yield* writeUserPath(kept.join(";")); + yield* fs.remove(ownedPathMarker).pipe(Effect.ignore); + } + return yield* state; + } + for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + const link = path.join(directory, "t3"); + if (yield* isOurLink(link)) { + yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`))); + } + } + return yield* state; + }).pipe(Effect.withSpan("desktop.cliCommand.uninstall")); + + return DesktopCliCommand.of({ state, install, uninstall }); +}); + +export const layer = Layer.effect(DesktopCliCommand, make); diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts new file mode 100644 index 000000000000..c5a1bb109e4d --- /dev/null +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -0,0 +1,181 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs the rendered launcher in a real shell. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { type CliShimTarget, renderCliShim } from "./DesktopCliShim.ts"; + +const directories: Array = []; +afterEach(() => { + for (const directory of directories.splice(0)) { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); + +const tempRoot = () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-shim-")); + directories.push(root); + return root; +}; + +const writeExecutable = (path: string, content: string) => { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, content, { mode: 0o755 }); +}; + +/** Writes the launcher for `target` at `/bin/t3`, with `/home` as its T3 home. */ +const writeShim = (root: string, target: CliShimTarget) => { + const shim = NodePath.join(root, "bin", "t3"); + writeExecutable(shim, renderCliShim({ target, shimPath: shim, t3Home: `${root}/home` })); + return shim; +}; + +const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = {}) => + NodeChildProcess.spawnSync(shim, args, { + encoding: "utf8", + env: { PATH: process.env.PATH, ...env }, + }); + +/** An app executable that reports its environment and arguments. */ +const REPORTER = + '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; + +/** + * A stand-in AppImage whose image holds the reporter. `--appimage-mount` + * prints the mount and stays up until killed, recording that it was; with + * `canMount` false it fails like a host without FUSE. `--appimage-extract` + * unpacks into ./squashfs-root. + */ +const fakeAppImage = (canMount = true) => { + const root = tempRoot(); + const image = NodePath.join(root, "image"); + writeExecutable(NodePath.join(image, "t3code"), REPORTER); + const appImage = NodePath.join(root, "T3 Code.AppImage"); + writeExecutable( + appImage, + [ + "#!/bin/sh", + `if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`, + '[ "$1" = --appimage-mount ] || exit 9', + '[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8', + canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', + `trap 'touch "${root}/unmounted"; exit 0' TERM`, + "while :; do sleep 0.05; done", + "", + ].join("\n"), + ); + const shim = writeShim(root, { kind: "appimage", appImage, executableName: "t3code" }); + return { root, image, appImage, shim }; +}; + +describe("renderCliShim", () => { + it("runs the AppImage's bundled CLI with every argument and unmounts after", () => { + const { root, image, shim } = fakeAppImage(); + const result = run(shim, ["browser", "setup", "a b"]); + expect(result.status).toBe(3); + expect(result.stdout.split("\n")).toEqual([ + `node=1 cli=${shim} home=${root}/home`, + `${image}/resources/app.asar/apps/server/dist/bin.mjs`, + "browser", + "setup", + "a b", + "", + ]); + // The trap kills the mount helper as the launcher exits. + NodeChildProcess.spawnSync("sh", [ + "-c", + `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`, + ]); + expect(NodeFS.existsSync(NodePath.join(root, "unmounted"))).toBe(true); + }); + + it("extracts the AppImage where it cannot be mounted", () => { + const { shim } = fakeAppImage(false); + // Extract-and-run is ignored too, so the runtime never starts the app. + const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); + expect(result.status).toBe(3); + expect(result.stdout).toMatch( + /squashfs-root\/resources\/app\.asar\/apps\/server\/dist\/bin\.mjs/, + ); + }); + + it("keeps the install's T3 home under sudo, unless one is set", () => { + const root = tempRoot(); + writeExecutable(NodePath.join(root, "app"), REPORTER); + const shim = writeShim(root, { + kind: "direct", + executable: NodePath.join(root, "app"), + entry: "/bin.mjs", + }); + // sudo clears the environment, so the launcher supplies its own. + expect(run(shim, []).stdout).toContain(`home=${root}/home`); + expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); + // Run by a relative path, it still names itself absolutely. + const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); + expect(relative.stdout).toContain(`cli=${shim}`); + }); + + it("runs an installed app's server directly", () => { + const root = tempRoot(); + const executable = NodePath.join(root, "opt", "T3 Code", "t3code"); + writeExecutable(executable, REPORTER); + const shim = writeShim(root, { + kind: "direct", + executable, + entry: "/opt/T3's/app.asar/bin.mjs", + }); + expect(run(shim, ["browser", "setup"]).stdout.split("\n").slice(1)).toEqual([ + "/opt/T3's/app.asar/bin.mjs", + "browser", + "setup", + "", + ]); + }); + + it("tells a person whose app moved to reopen it", () => { + const root = tempRoot(); + const shim = writeShim(root, { + kind: "direct", + executable: NodePath.join(root, "Gone.app/Contents/MacOS/T3 Code"), + entry: "/bin.mjs", + }); + const result = run(shim, ["--version"]); + expect(result.status).toBe(127); + expect(result.stderr).toContain("Open the app once to update this command."); + }); + + it("writes a Windows launcher that keeps cmd from reinterpreting paths", () => { + const executable = "C:\\Apps\\R&whoami&X 100%\\!CHANNEL!\\T3 Code.exe"; + const script = renderCliShim({ + target: { kind: "windows", executable, entry: "C:\\Apps\\server.asar\\bin.mjs" }, + shimPath: "C:\\Users\\José\\.t3\\bin\\t3.cmd", + t3Home: "C:\\Users\\José\\.t3", + }); + const lines = script.split("\r\n"); + expect(lines).toContain("setlocal EnableExtensions DisableDelayedExpansion"); + expect(lines).toContain("chcp 65001 >nul"); + expect(lines).toContain( + 'if exist "C:\\Apps\\R&whoami&X 100%%\\!CHANNEL!\\T3 Code.exe" goto run', + ); + // Paths only ever appear quoted or inside `set "..."`, never bare where `&` would split them. + const bare = lines.filter( + (line) => line.includes("whoami") && !/"[^"]*whoami[^"]*"/.test(line), + ); + expect(bare).toEqual([]); + expect(lines).toContain('set "T3CODE_CLI_PATH=C:\\Users\\José\\.t3\\bin\\t3.cmd"'); + }); + + it("switches the Windows console to UTF-8 only when a path needs it", () => { + const ascii = renderCliShim({ + target: { kind: "windows", executable: "C:\\T3\\T3 Code.exe", entry: "C:\\T3\\bin.mjs" }, + shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd", + t3Home: "C:\\Users\\me\\.t3", + }); + // A Ctrl-C that ends the batch would leave the console switched. + expect(ascii).not.toContain("chcp"); + expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%"); + }); +}); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts new file mode 100644 index 000000000000..5cd942f66ced --- /dev/null +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -0,0 +1,187 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +// A desktop install puts no `t3` on PATH, so commands the server asks a person +// to run (`sudo t3 browser setup`) had nothing to call. The app keeps a small +// launcher for its bundled CLI in the T3 home, which is never on PATH and so +// never shadows another `t3`, and the server names it by absolute path in those +// commands through T3CODE_CLI_PATH. An AppImage mounts somewhere new each run, +// so its launcher mounts the AppImage itself instead of pointing into it. +const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); + +export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; + +/** Server entry inside the app, relative to its server root (an asar archive when packaged). */ +const SERVER_ENTRY = "apps/server/dist/bin.mjs"; + +const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; +/** cmd.exe expands `%` even inside quotes; Windows paths cannot contain `"`. */ +const cmdText = (value: string) => value.replaceAll("%", "%%"); +const cmdWord = (value: string) => `"${cmdText(value)}"`; + +const MOVED = "T3 Code has moved or been removed. Open the app once to update this command."; + +export type CliShimTarget = + | { readonly kind: "appimage"; readonly appImage: string; readonly executableName: string } + | { readonly kind: "direct"; readonly executable: string; readonly entry: string } + | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; + +/** + * The launcher script. Electron runs the server as plain Node with + * `ELECTRON_RUN_AS_NODE`, which reads the entry from inside the asar archive. + * The launcher's own path and the app's T3 home are written in, so the command + * the server shows is absolute and `sudo`, which clears the environment, still + * runs against this install's home. + */ +export const renderCliShim = (input: { + readonly target: CliShimTarget; + readonly shimPath: string; + readonly t3Home: string; +}) => { + const { target } = input; + if (target.kind === "windows") { + const utf8 = [target.executable, target.entry, input.shimPath, input.t3Home].some((value) => + [...value].some((character) => character.codePointAt(0)! > 0x7f), + ); + const restore = utf8 ? ["chcp %t3_codepage% >nul"] : []; + return [ + "@echo off", + `rem ${MARKER}`, + // Delayed expansion would rewrite `!name!` in paths and arguments. + "setlocal EnableExtensions DisableDelayedExpansion", + // The file is UTF-8. Only a non-ASCII path needs the console switched to + // UTF-8 to read it; a Ctrl-C that ends the batch skips the restore, so the + // switch is left out for the common all-ASCII install. + ...(utf8 + ? [ + `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, + "chcp 65001 >nul", + ] + : []), + `set "T3CODE_CLI_PATH=${cmdText(input.shimPath)}"`, + `if not defined T3CODE_HOME set "T3CODE_HOME=${cmdText(input.t3Home)}"`, + 'set "ELECTRON_RUN_AS_NODE=1"', + // A goto, not a parenthesized block: "Program Files (x86)" would close the block early. + `if exist ${cmdWord(target.executable)} goto run`, + ...restore, + `echo ${MOVED} 1>&2`, + "exit /b 127", + ":run", + `${cmdWord(target.executable)} ${cmdWord(target.entry)} %*`, + 'set "t3_exit=%ERRORLEVEL%"', + ...restore, + "exit /b %t3_exit%", + "", + ].join("\r\n"); + } + const header = [ + "#!/bin/sh", + `# ${MARKER}`, + `export T3CODE_CLI_PATH=${shellWord(input.shimPath)}`, + `home=${shellWord(input.t3Home)}`, + 'export T3CODE_HOME="${T3CODE_HOME:-$home}"', + "export ELECTRON_RUN_AS_NODE=1", + `app=${shellWord(target.kind === "appimage" ? target.appImage : target.executable)}`, + 'if [ ! -x "$app" ]; then', + ` echo "${MOVED}" >&2`, + " exit 127", + "fi", + ]; + if (target.kind === "direct") { + return [...header, `exec "$app" ${shellWord(target.entry)} "$@"`, ""].join("\n"); + } + const run = (root: string) => + `"${root}/${target.executableName}" "${root}/resources/app.asar/${SERVER_ENTRY}" "$@"`; + return [ + ...header, + // Extract-and-run would make the runtime start the app instead of mounting it. + "unset APPIMAGE_EXTRACT_AND_RUN", + // The AppImage runtime prints its mount point, then stays mounted until + // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. + "work=$(mktemp -d) || exit 1", + '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', + "mounter=$!", + `trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`, + "trap 'exit 130' INT TERM", + "mount=", + 'while kill -0 "$mounter" 2>/dev/null; do', + ' mount=$(head -n 1 "$work/mount")', + ' [ -d "$mount" ] && break', + " mount=", + " sleep 0.05", + "done", + // Without FUSE the runtime cannot mount (it prints why and exits); extracting the image still works. + 'if [ -z "$mount" ]; then', + ' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }', + ' mount="$work/squashfs-root"', + "fi", + run("$mount"), + "", + ].join("\n"); +}; + +/** Where the packaged app keeps its launcher: `/bin/t3`, `t3.cmd` on Windows. */ +export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) => + environment.path.join( + environment.baseDir, + "bin", + environment.platform === "win32" ? "t3.cmd" : "t3", + ); + +/** + * Writes the packaged app's launcher to `/bin` and returns its path + * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout + * and get none. + */ +export const install = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (!environment.isPackaged) return Option.none(); + const fs = yield* FileSystem.FileSystem; + const path = environment.path; + const windows = environment.platform === "win32"; + const shimPath = launcherPath(environment); + const entry = path.join(environment.serverRoot, SERVER_ENTRY); + const target: CliShimTarget = windows + ? { kind: "windows", executable: process.execPath, entry } + : Option.match(environment.appImagePath, { + onSome: (appImage) => ({ + kind: "appimage" as const, + appImage, + executableName: path.basename(process.execPath), + }), + // macOS and .deb installs live at a fixed path, so the launcher runs the app directly. + onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }), + }); + const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir }); + + return yield* Effect.gen(function* () { + const existing = yield* fs.readFileString(shimPath).pipe(Effect.option); + if (Option.isSome(existing) && !existing.value.includes(MARKER)) { + // Someone else's file; leave it, and let commands fall back to plain `t3`. + yield* logWarning("leaving a t3 launcher the app did not write", { shimPath }); + return Option.none(); + } + if (Option.getOrUndefined(existing) !== content) { + yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true }); + // Written beside the launcher and renamed over it, so a running `t3` never reads half a file. + const staging = `${shimPath}.${process.pid}.tmp`; + yield* fs.writeFileString(staging, content, { mode: 0o755 }); + yield* fs.rename(staging, shimPath); + yield* logInfo("installed t3 launcher", { shimPath }); + } + return Option.some(shimPath); + }).pipe( + // Best-effort: nothing here may block the backend's start; commands then fall back to plain `t3`. + Effect.catchCause((cause) => + logWarning("could not install t3 launcher", { shimPath, cause: Cause.pretty(cause) }).pipe( + Effect.as(Option.none()), + ), + ), + Effect.withSpan("desktop.cliShim.install"), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 289e3fb321f0..be923b47c5af 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import serverPackageJson from "../../../server/package.json" with { type: "json" }; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopCliShim from "../app/DesktopCliShim.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -547,6 +548,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv function* ( input: SharedBootstrapInput & { readonly resourceMonitorPath: Option.Option; + readonly cliPath: Option.Option; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -595,6 +597,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", + // The server names this launcher in commands it asks a person to run. + T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), }, // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). extendEnv: true, @@ -912,7 +916,11 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( + const cliPath = yield* DesktopCliShim.install.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, cliPath }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index ed83b91904e8..aa8ca1e917d8 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -82,6 +82,11 @@ import { takeLegacyLocalStorage, } from "./methods/legacyLocalStorage.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; +import { + getCliCommandState, + installCliCommand, + uninstallCliCommand, +} from "./methods/cliCommand.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -163,6 +168,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); + yield* ipc.handle(getCliCommandState); + yield* ipc.handle(installCliCommand); + yield* ipc.handle(uninstallCliCommand); for (const previewMethod of PreviewIpc.methods) { yield* ipc.handle(previewMethod); } diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0d514e88ba27..c619a00d1113 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -31,6 +31,9 @@ export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; +export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state"; +export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; +export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; diff --git a/apps/desktop/src/ipc/methods/cliCommand.ts b/apps/desktop/src/ipc/methods/cliCommand.ts new file mode 100644 index 000000000000..be9672c68251 --- /dev/null +++ b/apps/desktop/src/ipc/methods/cliCommand.ts @@ -0,0 +1,34 @@ +import { DesktopCliCommandStateSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopCliCommand from "../../app/DesktopCliCommand.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getCliCommandState = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.getState")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).state; + }), +}); + +export const installCliCommand = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_INSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.install")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).install; + }), +}); + +export const uninstallCliCommand = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.uninstall")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).uninstall; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6c0232c94383..e2a9a92b24f0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -38,6 +38,7 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; +import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -217,6 +218,7 @@ const layerDesktopApplication = Layer.mergeAll( layerDesktopAppActivation, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, + DesktopCliCommand.layer, DesktopShellEnvironment.layer, layerDesktopSsh, ).pipe( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index bef6249ead0e..4a3cf139c79d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -300,6 +300,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), + cliCommand: { + getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), + install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), + uninstall: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL), + }, onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { if (typeof state !== "object" || state === null) return; diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 4ec88a7cc516..a87d8af85ea0 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -75,6 +75,28 @@ describe("CDP relay", () => { }); }); + it("resumes paused child frames, so cross-site iframes run", async () => { + const send = vi.fn(async () => ({})); + const relay = createCdpRelayConnection(makeTarget(send), () => {}); + // The page's own session is already running; only its children wait. + relay.receive( + JSON.stringify({ + id: 1, + method: "Runtime.runIfWaitingForDebugger", + sessionId: "t3-preview-page", + }), + ); + relay.receive( + JSON.stringify({ + id: 2, + method: "Runtime.runIfWaitingForDebugger", + sessionId: "iframe-session", + }), + ); + await settle(); + expect(send.mock.calls).toEqual([["Runtime.runIfWaitingForDebugger", {}, "iframe-session"]]); + }); + it("routes tab events to the page session once the page is announced", async () => { const written: Array> = []; const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 19000aa50c63..62c2410c19b8 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -158,9 +158,14 @@ export function createCdpRelayConnection( }; const pageCommand = (command: CdpCommand): Promise => { - // The page is already running; nothing waits for a debugger. - if (command.method === "Runtime.runIfWaitingForDebugger") return Promise.resolve({}); const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; + // The page itself is already running, but its child frames are not: + // Playwright auto-attaches with waitForDebuggerOnStart, so Chromium pauses + // every new cross-site iframe and worker until this reaches it. Swallowing + // it left sign-in checks and captchas, which run in such frames, frozen. + if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) { + return Promise.resolve({}); + } return target.send(command.method, command.params ?? {}, child); }; diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz new file mode 100644 index 000000000000..860521672c8a Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz differ diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js index 99be5e83bec8..8f55f236996d 100644 --- a/apps/mobile/fingerprint.config.js +++ b/apps/mobile/fingerprint.config.js @@ -1,7 +1,6 @@ // @ts-check const fs = require("node:fs"); const path = require("node:path"); -const { SourceSkips } = require("expo/fingerprint"); // Expo's fingerprint ignores the app version, so binaries of different majors // share a runtime version whenever native code is unchanged, and a production @@ -15,7 +14,7 @@ if (!majorVersion) { } module.exports = { - sourceSkips: - SourceSkips.ExpoConfigVersions | SourceSkips.PackageJsonAndroidAndIosScriptsIfNotContainRun, + // Hash the pinned Screens fork's native source, rather than only its version. + nativeModuleSourceType: "files", extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }], }; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index c0db129ee8c8..3115a5a34a04 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -287,6 +287,7 @@ @implementation T3MarkdownText { UILongPressGestureRecognizer *_longPressGestureRecognizer; UITapGestureRecognizer *_pressGestureRecognizer; NSArray *_contextAccessibilityElements; + BOOL _textLayoutNeedsUpdate; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -384,16 +385,18 @@ - (void)prepareForRecycle _textView.frame = CGRectZero; _textView.attributedText = nil; _contextAccessibilityElements = nil; + _textLayoutNeedsUpdate = YES; } - (void)layoutSubviews { [super layoutSubviews]; - // _textView's frame is assigned inside drawRect, which only fires when - // state changes. Trigger a redraw whenever the host frame moves out from - // under it (rotation, parent relayout) so the text view resizes and - // onTextLayout re-fires with the new line wrapping. + // Resize during UIKit's layout pass. Doing this in drawRect can leave the + // text view's drawing surface clipped to its previous width after a pane + // expands, even though TextKit has already rewrapped the lines. if (!CGRectEqualToRect(_textView.frame, _view.frame)) { + _textView.frame = _view.frame; + _textLayoutNeedsUpdate = YES; [self setNeedsDisplay]; } } @@ -455,7 +458,7 @@ - (void)drawRect:(CGRect)rect // entirely when nothing actually changed so a JS-side state update made in // response to onSelectionChange doesn't deselect what the user is selecting. const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; - const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); + const BOOL frameChanged = _textLayoutNeedsUpdate; if (!textChanged && !frameChanged) { return; } @@ -482,9 +485,9 @@ - (void)drawRect:(CGRect)rect _suppressSelectionChange = NO; } if (frameChanged) { - _textView.frame = _view.frame; _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); } + _textLayoutNeedsUpdate = NO; // Text attachments have no native link element. Expose their existing runs // at the measured glyph bounds, without inserting views into text layout. diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift new file mode 100644 index 000000000000..43070a614582 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -0,0 +1,48 @@ +import ExpoModulesCore +import UIKit + +/// Reports the body geometry of a native navigation column. +final class T3LayoutMetricsView: ExpoView { + let onMetricsChange = EventDispatcher() + private var lastMetrics: NSDictionary? + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + isUserInteractionEnabled = false + registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in + view.publishMetrics() + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + lastMetrics = nil + publishMetrics() + } + + override func layoutSubviews() { + super.layoutSubviews() + publishMetrics() + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + publishMetrics() + } + + private func publishMetrics() { + guard window != nil, bounds.width > 0, bounds.height > 0 else { return } + let metrics: [String: Any] = [ + "width": bounds.width, "height": bounds.height, + "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", + "safeArea": [ + "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom, + "left": safeAreaInsets.left, "right": safeAreaInsets.right, + ], + ] + let next = metrics as NSDictionary + guard lastMetrics?.isEqual(next) != true else { return } + lastMetrics = next + onMetricsChange(metrics) + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index a5813c8a0e8b..3674e232ee5b 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -11,6 +11,17 @@ public final class T3NativeControlsModule: Module { private var filePresentation: T3NativeFilePresentation? public func definition() -> ModuleDefinition { + Constants { + if #available(iOS 26.0, *) { + return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil] + } + return ["supportsWorkspaceColumns": false] + } + View(T3LayoutMetricsView.self) { + ViewName("LayoutMetrics") + Events("onMetricsChange") + } + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e0cf2258f1e1..b74009a24446 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -123,7 +123,7 @@ "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.7.0", "react-native-safe-area-context": "~5.9.1", - "react-native-screens": "~4.28.0", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.5", "react-native-webview": "14.0.1", @@ -145,7 +145,8 @@ "install": { "exclude": [ "react-native-keyboard-controller", - "typescript" + "typescript", + "react-native-screens" ] }, "autolinking": { diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 32e5bdbed55e..bab8dcc69ba3 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,3 +1,8 @@ +import { + createV5StackNavigator as createNativeStackNavigator, + createV5SheetStackNavigator, +} from "./native/createV5StackNavigator"; +import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; import { createPathConfigForStaticNavigation, getPathFromState, @@ -6,7 +11,6 @@ import { useNavigation, } from "@react-navigation/native"; import { - createNativeStackNavigator, createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; @@ -191,7 +195,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { presentation: "fullScreenModal", }; -const SettingsContentStack = createNativeStackNavigator({ +const SettingsContentStack = createV5SheetStackNavigator({ initialRouteName: "Settings", screenOptions: { ...GLASS_HEADER_OPTIONS, @@ -406,7 +410,7 @@ const SettingsContentStack = createNativeStackNavigator({ // The outer stack never owns visible chrome. Settings routes render inside a // nested stack whose native header remains mounted, while Clerk owns auth chrome. // Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. -const SettingsSheetStack = createNativeStackNavigator({ +const SettingsSheetStack = createV5SheetStackNavigator({ initialRouteName: "SettingsContent", screenOptions: { headerShown: false, @@ -678,7 +682,7 @@ function NotFoundScreen() { ); } -const RootStackConfig = createNativeStackNavigator({ +const RootStackConfig = createWorkspaceStackNavigator({ initialRouteName: "Home", layout: RootStackLayout, screenOptions: { diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index f64370c3cead..5120e7c1868d 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -1,3 +1,4 @@ +import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns"; import Constants from "expo-constants"; import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { Platform, View } from "react-native"; @@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing"; */ export function brandTitleOffset(): number { if (Platform.OS !== "ios") return 0; - return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; + return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0; } /** diff --git a/apps/mobile/src/components/ScreenHeader.tsx b/apps/mobile/src/components/ScreenHeader.tsx index 6024ceec08d9..4a3877571966 100644 --- a/apps/mobile/src/components/ScreenHeader.tsx +++ b/apps/mobile/src/components/ScreenHeader.tsx @@ -53,6 +53,10 @@ export function ScreenHeader(props: ScreenHeaderProps) { ]} options={{ headerShown: true, + headerBackVisible: + !layout.usesSplitView || + !props.backInSplitView || + !(props.backInSplitView.onPress || props.onBack), title: props.title, unstable_headerSubtitle: props.subtitle || undefined, ...(props.matchSearchSurface diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index df18347f336c..ed42f5345abe 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -1,14 +1,14 @@ +import { LegendList } from "@legendapp/list/react-native"; import type { ProjectEntry } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; +import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, flattenFileTree, @@ -17,8 +17,6 @@ import { } from "./fileTree"; const fileTreeCache = new WeakMap, ReadonlyArray>(); -const FILE_TREE_INITIAL_RENDER_COUNT = 20; -const FILE_TREE_RENDER_BATCH_SIZE = 12; const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; function cachedFileTree(entries: ReadonlyArray): ReadonlyArray { @@ -45,7 +43,8 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly selected: boolean; readonly expanded: boolean; readonly loaded: boolean; - readonly onPressDirectory: (path: string) => void; + readonly loading: boolean; + readonly onPressDirectory: (path: string, expand: boolean) => void; readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; }) { @@ -62,7 +61,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { }} onPress={() => { if (node.kind === "directory") { - props.onPressDirectory(node.path); + props.onPressDirectory(node.path, !props.expanded); return; } props.onPressFile(node.path); @@ -97,7 +96,9 @@ const FileTreeRow = memo(function FileTreeRow(props: { > {node.name} - {node.kind === "directory" && props.loaded ? ( + {node.kind === "directory" && props.expanded && props.loading ? ( + + ) : node.kind === "directory" && props.loaded ? ( {node.children.length} @@ -110,10 +111,12 @@ export function FileTreeBrowser(props: { readonly entries: ReadonlyArray; readonly error: string | null; readonly isPending: boolean; + readonly isRefreshing: boolean; readonly searchQuery: string; readonly searchTruncated: boolean; readonly selectedPath: string | null; readonly loadedDirectories: ReadonlySet; + readonly loadingDirectories: ReadonlySet; readonly onLoadDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; @@ -127,12 +130,13 @@ 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 + IOS_NAV_BAR_HEIGHT : 0; + const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0; const { onLoadDirectory, onPreviewFile, onSelectFile, loadedDirectories, + loadingDirectories, selectedPath: controlledSelectedPath, } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); @@ -184,17 +188,21 @@ export function FileTreeBrowser(props: { [], ); - const toggleDirectory = useCallback((path: string) => { - setExpandedPaths((current) => { - const next = new Set(current); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }, []); + const toggleDirectory = useCallback( + (path: string, expand: boolean) => { + if (expand) onLoadDirectory(path); + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, + [onLoadDirectory], + ); const handleSelectFile = useCallback( (path: string) => { if (pendingSelectionTimeoutRef.current !== null) { @@ -219,6 +227,7 @@ export function FileTreeBrowser(props: { selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} loaded={loadedDirectories.has(item.node.path)} + loading={loadingDirectories.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} onPressFile={handleSelectFile} @@ -229,36 +238,40 @@ export function FileTreeBrowser(props: { handleSelectFile, onPreviewFile, loadedDirectories, + loadingDirectories, selectedPath, toggleDirectory, ], ); - // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and - // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"` - // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge - // blur to a scroll view in the screen's primary position; a scroll view buried in - // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. + const extraData = useMemo( + () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), + [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], + ); + + // UIKit owns the header inset on every supported iOS version. Keep the + // list as direct screen content so automatic inset adjustment can find it. return ( - item.node.path} - contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} scrollIndicatorInsets={ - NATIVE_LIQUID_GLASS_SUPPORTED - ? { top: headerInset, left: 0, right: 0, bottom: 0 } - : undefined + Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined } keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" - initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} - maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} - updateCellsBatchingPeriod={16} - windowSize={5} + estimatedItemSize={42} + recycleItems + maintainVisibleContentPosition + extraData={extraData} contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} - refreshControl={} + refreshControl={ + + } renderItem={renderItem} ListHeaderComponent={ <> diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index e0421e2a587c..6c8cac046bb2 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -43,6 +43,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { ThreadInspectorContentStack } from "../threads/thread-inspector-content-stack"; import { FileTreeBrowser } from "./FileTreeBrowser"; import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; @@ -561,9 +562,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { key={JSON.stringify([environmentId, cwd])} entries={entriesQuery.entries} loadedDirectories={entriesQuery.loadedDirectories} + loadingDirectories={entriesQuery.loadingDirectories} onLoadDirectory={entriesQuery.loadDirectory} error={entriesQuery.error} isPending={entriesQuery.isPending} + isRefreshing={entriesQuery.isRefreshing} searchQuery={searchQuery} searchTruncated={entriesQuery.searchTruncated} selectedPath={null} @@ -751,8 +754,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { // Hand the file navigator to the workspace so it renders beside the // navigator, outside this screen's native header. const renderWorkspaceInspector = useCallback( - () => renderInspector(inspectorHeaderInset), - [inspectorHeaderInset, renderInspector], + () => ( + renderInspector(inspectorHeaderInset)} + /> + ), + [cwd, inspectorHeaderInset, renderInspector, threadId], ); useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index a8bde49488fc..9b945d1a5f5b 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -77,6 +77,8 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode { }; } +const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + function compareNodes( left: Pick, right: Pick, @@ -84,7 +86,7 @@ function compareNodes( if (left.kind !== right.kind) { return left.kind === "directory" ? -1 : 1; } - return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }); + return fileNameCollator.compare(left.name, right.name); } export function buildFileTree(entries: ReadonlyArray): ReadonlyArray { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index d35ed1a98bdb..998c23b1e300 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -78,7 +78,6 @@ export function ThreadFileNavigatorPane(props: { sharesBackground: false, tintColor: foregroundColor, type: "button" as const, - width: 44, }, ] as ComponentProps["headerRightBarButtonItems"], [foregroundColor, toggleAuxiliaryPane], @@ -89,6 +88,7 @@ export function ThreadFileNavigatorPane(props: { key={JSON.stringify([props.environmentId, props.cwd])} entries={entriesQuery.entries} loadedDirectories={entriesQuery.loadedDirectories} + loadingDirectories={entriesQuery.loadingDirectories} onLoadDirectory={entriesQuery.loadDirectory} error={ canReadFiles @@ -98,6 +98,7 @@ export function ThreadFileNavigatorPane(props: { : (fileAccess.error ?? "This connection cannot read host files.") } isPending={fileAccess.isPending || entriesQuery.isPending} + isRefreshing={entriesQuery.isRefreshing} searchQuery={searchQuery} searchTruncated={entriesQuery.searchTruncated} selectedPath={props.selectedPath} diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index 604fb3bd5231..1553736dcdbd 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -1,7 +1,7 @@ import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; @@ -30,7 +30,8 @@ export function useFileTreeEntries(input: { }) : null, ); - const [revision, render] = useReducer((value: number) => value + 1, 0); + const [, render] = useReducer((value: number) => value + 1, 0); + const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0); const refreshVersion = useRef(0); const directories = useMemo( () => ({ @@ -43,6 +44,9 @@ export function useFileTreeEntries(input: { }), [cwd, environmentId], ); + const [refreshingDirectories, setRefreshingDirectories] = useState( + null, + ); useEffect( () => () => { refreshVersion.current++; @@ -83,6 +87,7 @@ export function useFileTreeEntries(input: { entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, ), ); + entriesChanged(); } else { const error = Cause.squash(result.cause); directories.errors.set( @@ -113,8 +118,8 @@ export function useFileTreeEntries(input: { } }; visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); - return { revision, entries: [...merged.values()], reachableDirectories }; - }, [directories, revision, rootData, searchData, searching]); + return { revision: entriesRevision, entries: [...merged.values()], reachableDirectories }; + }, [directories, entriesRevision, rootData, searchData, searching]); const refresh = useCallback(() => { refreshRoot(); @@ -126,6 +131,7 @@ export function useFileTreeEntries(input: { directories.pending.clear(); directories.errors.clear(); const version = ++refreshVersion.current; + setRefreshingDirectories(directories); const remaining = paths.values(); const worker = async () => { while (version === refreshVersion.current) { @@ -134,9 +140,34 @@ export function useFileTreeEntries(input: { await loadDirectory(next.value, true); } }; - for (let index = 0; index < Math.min(4, paths.size); index++) void worker(); + const queries = + cwd !== null && environmentId !== null + ? [ + projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }), + ...(searching + ? [ + projectEnvironment.searchEntries({ + environmentId, + input: { cwd, query: debouncedQuery, limit: 200 }, + }), + ] + : []), + ] + : []; + const work = [ + ...queries.map((atom) => + executeAtomQuery(appAtomRegistry, atom, { reportFailure: false, reportDefect: false }), + ), + ...Array.from({ length: Math.min(4, paths.size) }, () => worker()), + ]; render(); + return Promise.all(work).finally(() => { + if (version === refreshVersion.current) setRefreshingDirectories(null); + }); }, [ + cwd, + debouncedQuery, + environmentId, directories, loadDirectory, refreshRoot, @@ -147,6 +178,7 @@ export function useFileTreeEntries(input: { return { entries: snapshot.entries, + isRefreshing: refreshingDirectories === directories, error: root.error ?? (searching ? search.error : null) ?? @@ -158,6 +190,7 @@ export function useFileTreeEntries(input: { (searching && (query !== debouncedQuery || search.isPending)), searchTruncated: searching && (search.data?.truncated ?? false), loadedDirectories: new Set(directories.entries.keys()), + loadingDirectories: new Set(directories.pending.keys()), loadDirectory, refresh, }; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e1cc7448eb59..ac58bcc8c787 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,6 +1,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useRef } from "react"; +import { use, useCallback, useRef } from "react"; +import { Platform } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; +import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -9,11 +11,14 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; +import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; export function HomeHeader(props: HomeHeaderProps) { + const primaryColumn = use(NativePrimaryColumnContext); + const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null; const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; // The list uses a fixed creation order and ignores sort/group options, so @@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) { // Static header config (glass, title, fonts) lives in Stack.tsx // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. headerTintColor: iconColor, - unstable_headerRightItems: () => [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ], + unstable_headerRightItems: () => + iPadSidebar + ? createSidebarHeaderItems({ + filterIcon: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle", + filterMenu, + onOpenSettings: props.onOpenSettings, + }) + : [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open settings", + icon: { name: "ellipsis", type: "sfSymbol" } as const, + identifier: "home-settings", + label: "", + onPress: props.onOpenSettings, + type: "button", + }), + ], // The keys below are set per-branch (not `undefined`) so a later // reapply cannot clobber options owned by NativeHeaderToolbar. - ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ...(iPadSidebar ? { - unstable_headerToolbarItems: () => [ - createNativeMailSearchToolbarItem({ - composeButtonId: "home-new-task", - composeSystemImageName: "square.and.pencil", - filterMenu, - filterButtonId: "home-filter", - filterSystemImageName: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onStartNewTask, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "home-search-text", - showsSearchDismissButton: true, - }), - ], - } - : { - // Pre-Liquid-Glass iOS: standard pull-down search in the nav - // bar; create + sort live in the plain bottom toolbar below. headerSearchBarOptions: { ref: searchBarRef, autoCapitalize: "none" as const, hideNavigationBar: false, + hideWhenScrolling: false, + obscureBackground: false, + placement: "stacked" as const, + allowToolbarIntegration: false, placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); + onCancelButtonPress: () => props.onSearchQueryChange(""), + onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text), + }, + unstable_headerToolbarItems: () => [], + } + : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? { + headerSearchBarOptions: { + ref: searchBarRef, + autoCapitalize: "none" as const, + onCancelButtonPress: () => props.onSearchQueryChange(""), }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); + unstable_headerToolbarItems: () => [ + createNativeMailSearchToolbarItem({ + composeButtonId: "home-new-task", + composeSystemImageName: "square.and.pencil", + filterMenu, + filterButtonId: "home-filter", + filterSystemImageName: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onComposePress: props.onStartNewTask, + onSearchTextChange: props.onSearchQueryChange, + placeholder: "Search", + searchTextChangeId: "home-search-text", + showsSearchDismissButton: true, + }), + ], + } + : { + // Pre-Liquid-Glass iOS: standard pull-down search in the nav + // bar; create + sort live in the plain bottom toolbar below. + headerSearchBarOptions: { + ref: searchBarRef, + autoCapitalize: "none" as const, + hideNavigationBar: false, + placeholder: "Search", + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + onChangeText: (event) => { + props.onSearchQueryChange(event.nativeEvent.text); + }, }, - }, - }), + }), }} /> - {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( + {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( navigation.navigate("SettingsSheet", { screen: "SettingsContent", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6538697da297..48f074a3438f 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -13,15 +13,18 @@ import { import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import { ActivityIndicator, Platform, + ScrollView, View, + type ScrollViewProps, type GestureResponderEvent, type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; +import { ScrollViewMarker } from "react-native-screens"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -30,7 +33,10 @@ import { EmptyState } from "../../components/EmptyState"; import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey } from "../../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useThreadSearch } from "../../state/queries"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; @@ -140,7 +146,6 @@ const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; // swipe-row-activation), so render further ahead: a fast fling then reaches // rows that are already built instead of rows still being rebuilt. const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; -const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android * header is rendered in-flow above this screen and @@ -219,17 +224,61 @@ function HomeTopContentSpacer() { return ; } +function HomeScrollView(props: ComponentProps) { + const insets = useSafeAreaInsets(); + const primaryColumn = use(NativePrimaryColumnContext); + if (Platform.OS !== "ios") return ; + + // v5 needs the actual content scroll view registered with its screen controller. + // The layout observer and header siblings prevent UIKit from finding it implicitly. + return ( + = 27 ? "automatic" : "soft", + }} + > + + + ); +} + +function renderHomeScrollView(props: ScrollViewProps) { + // FlatList clones this element with its cells and ref. Keep the marker inside + // the component so those cells remain children of the actual ScrollView. + return ; +} + /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { + const primaryColumn = use(NativePrimaryColumnContext); + const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen"; + const containerClassName = cn( + "flex-1", + Platform.OS === "android" ? "bg-header" : contentBackground, + ); + const columnMetrics = useNativeColumnLayoutMetrics(); + const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null; + const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined; const queuedThreadKeys = useQueuedThreadKeys(); const openSwipeableRef = useRef(null); const insets = useSafeAreaInsets(); const { fabClearance } = useAndroidControlSizing(); - const iosBottomToolbarClearance = - Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED - ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT - : 0; + // UIKit's column safe area already includes its bottom toolbar. + const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24); const searchEnvironmentIds = useMemo( () => props.selectedEnvironmentId === null @@ -563,12 +612,13 @@ export function HomeScreen(props: HomeScreenProps) { inboxReturnAt: threadListInboxReturns.returnedAt, snoozedShelfExpanded, settledShelfExpanded, - selectedThreadKey: null, + selectedThreadKey, }); }, [ workingShelfEnabled, workingShelfExpanded, pendingOrder, + selectedThreadKey, queuedThreadKeys, nowMinute, snoozeWakeTick, @@ -715,6 +765,9 @@ export function HomeScreen(props: HomeScreenProps) { const thread = item.item.thread; return ( + @@ -958,12 +1017,12 @@ export function HomeScreen(props: HomeScreenProps) { } return ( - + {/* Shared with the iPad sidebar: cells are reused across data @@ -976,6 +1035,7 @@ export function HomeScreen(props: HomeScreenProps) { onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)} onTouchCancel={(event) => trackListTouches(event, false)} + renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} @@ -997,16 +1057,19 @@ export function HomeScreen(props: HomeScreenProps) { ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + contentInsetAdjustmentBehavior="never" + contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined} + contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" {...scrollGateHandlers} scrollEventThrottle={16} contentContainerStyle={{ + paddingHorizontal: primaryColumn ? 8 : 0, paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance + ? iosBottomClearance : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88), }} /> diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index caaed841ce01..801d5e85da80 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -61,6 +61,10 @@ import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderE import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; +import { + NativeWorkspaceModeContext, + NativeWorkspaceInspectorContext, +} from "../../native/v5-workspace-context"; interface AdaptiveWorkspaceContextValue { readonly layout: Layout; @@ -234,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; + const nativeWorkspace = use(NativeWorkspaceModeContext); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -468,9 +473,10 @@ function AdaptiveWorkspaceLayoutContent( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, ); useEffect(() => { + if (nativeWorkspace) return; const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); - }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); + }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, @@ -565,6 +571,19 @@ function AdaptiveWorkspaceLayoutContent( ], ); + if (nativeWorkspace) { + return ( + + + 0 }} + > + {props.children} + + + + ); + } return ( diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx new file mode 100644 index 000000000000..c8104f9f4b1a --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -0,0 +1,342 @@ +import { + createNavigatorFactory, + NavigationContext, + NavigationRouteContext, + StackRouter, + useNavigationBuilder, + type NavigatorTypeBagBase, + type ParamListBase, + type StackActionHelpers, + type StackNavigationState, + type StackRouterOptions, + type StaticConfig, + type TypedNavigator, +} from "@react-navigation/native"; +import { + NativeStackView, + type NativeStackNavigationEventMap, + type NativeStackNavigationOptions, + type NativeStackNavigatorProps, + type NativeStackTypeBag, +} from "@react-navigation/native-stack"; +import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; +import { View } from "react-native"; +import { Split, type SplitHostCommands } from "react-native-screens"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopedThreadKey } from "../../lib/scopedEntities"; + +import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; +import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios"; +import { + modalEnvelopeOptions, + V5CardStackView, + V5StackView, +} from "../../native/createV5StackNavigator.ios"; +import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns"; +import { V5StackHeader } from "../../native/V5StackHeader.ios"; +import { + nativeWorkspacePopAction, + projectWorkspaceStack, + partitionStackPresentations, +} from "../../native/workspace-stack-projection"; +import { + NativePrimaryColumnContext, + NativeWorkspaceInspectorContext, + NativeWorkspaceModeContext, +} from "../../native/v5-workspace-context"; + +type ViewProps = ComponentProps; +type Descriptor = ViewProps["descriptors"][string]; +type Route = ViewProps["state"]["routes"][number]; + +function ColumnScreen(props: { + readonly descriptor: Descriptor; + readonly primary?: boolean; + readonly selectedThreadKey?: string | null; + readonly onDismiss: (routeKey: string) => void; + readonly navigation: ViewProps["navigation"]; +}) { + const { descriptor } = props; + const primaryColumn = useMemo( + () => (props.primary ? { selectedThreadKey: props.selectedThreadKey ?? null } : null), + [props.primary, props.selectedThreadKey], + ); + const key = descriptor.route.key; + return ( + { + if (event.nativeEvent.isNativeDismiss) props.onDismiss(key); + }} + onWillAppear={() => + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }) + } + onDidAppear={() => + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }) + } + onWillDisappear={() => + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }) + } + onDidDisappear={() => + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } }) + } + > + + + + + + {descriptor.render()} + + + + + + ); +} + +function WorkspaceColumns( + props: ViewProps & { readonly primary: Route; readonly detail: Route[] }, +) { + const { layout, panes, togglePrimarySidebar, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const inspector = use(NativeWorkspaceInspectorContext); + const hostRef = useRef(null); + const activeDetailKey = props.detail.at(-1)?.key; + const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; + const selectedThreadKey = + threadParams && + "environmentId" in threadParams && + "threadId" in threadParams && + typeof threadParams.environmentId === "string" && + typeof threadParams.threadId === "string" + ? scopedThreadKey( + EnvironmentId.make(threadParams.environmentId), + ThreadId.make(threadParams.threadId), + ) + : null; + const handleNativeDismiss = useCallback( + (key: string) => { + const state = props.navigation.getState(); + const action = nativeWorkspacePopAction(state, key); + if (action) props.navigation.dispatch(action); + }, + [props.navigation], + ); + + useEffect(() => { + // In compact size classes UIKit exposes one column. Selecting a thread + // changes the visible column without rebuilding either navigation stack. + if (layout.usesSplitView) return; + hostRef.current?.show(activeDetailKey ? "secondary" : "primary"); + }, [activeDetailKey, layout.usesSplitView]); + + const primary = props.descriptors[props.primary.key]; + if (!primary) return null; + return ( + { + if (inspector.visible) toggleAuxiliaryPane(); + }} + columnMetrics={{ + minimumPrimaryColumnWidth: 280, + maximumPrimaryColumnWidth: 380, + preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, + minimumSecondaryColumnWidth: 320, + }} + > + + + + + + + + + [ + { + type: "button", + label: "", + icon: { type: "sfSymbol", name: "sidebar.left" }, + onPress: togglePrimarySidebar, + }, + ], + }} + canGoBack={false} + /> + + + props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + } + /> + + + {props.detail.map((route) => { + const descriptor = props.descriptors[route.key]; + return descriptor ? ( + + ) : null; + })} + + + + {/* Inspector content supplies its own navigation header. */} + + + ); +} + +function WorkspaceStackView(props: ViewProps) { + const projection = projectWorkspaceStack(props.state, (route) => { + const presentation = props.descriptors[route.key]?.options.presentation; + return presentation !== undefined && presentation !== "card"; + }); + // Linking normally restores Home via initialRouteName. A detail-only history + // must stay a single stack: placeholder descriptors cannot own an interactive + // sidebar because React Navigation rejects their actions and setOptions. + const primaryRoute = projection.primary; + if (!primaryRoute) return ; + const primaryDescriptor = props.descriptors[primaryRoute.key]!; + const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; + const baseDescriptor: Descriptor = { + ...primaryDescriptor, + route: baseRoute, + options: { headerShown: false }, + render: () => , + }; + const overlays = partitionStackPresentations(projection.overlays, (route) => { + const presentation = props.descriptors[route.key]?.options.presentation; + return presentation !== undefined && presentation !== "card"; + }); + const routes = [baseRoute, ...overlays.map((group) => group[0]!)]; + return ( + { + const route = group[0]!; + const descriptor = props.descriptors[route.key]!; + return [ + route.key, + { + ...descriptor, + options: modalEnvelopeOptions(descriptor.options), + render: () => ( + + ), + }, + ]; + }), + ), + [baseRoute.key]: baseDescriptor, + }} + /> + ); +} + +function WorkspaceStackNavigator({ + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + ...rest +}: NativeStackNavigatorProps) { + const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< + StackNavigationState, + StackRouterOptions, + StackActionHelpers, + NativeStackNavigationOptions, + NativeStackNavigationEventMap + >(StackRouter, { + id, + initialRouteName, + UNSTABLE_routeNamesChangeBehavior, + children, + layout, + screenListeners, + screenOptions, + screenLayout, + UNSTABLE_router, + }); + const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView; + return ( + + + + + + ); +} + +type WorkspaceTypeBag< + ParamList extends ParamListBase, + NavigatorID extends string | undefined, +> = Omit, "Navigator"> & { + Navigator: typeof WorkspaceStackNavigator; +}; + +export function createWorkspaceStackNavigator< + const ParamList extends ParamListBase, + const NavigatorID extends string | undefined = string | undefined, + const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag, + const Config extends StaticConfig = StaticConfig, +>(config?: Config): TypedNavigator { + return createNavigatorFactory(WorkspaceStackNavigator)(config); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx new file mode 100644 index 000000000000..fb22815dd2c1 --- /dev/null +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.tsx @@ -0,0 +1 @@ +export { createNativeStackNavigator as createWorkspaceStackNavigator } from "@react-navigation/native-stack"; 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 34d5570e6109..44ef2a992e8b 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -3,11 +3,9 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens" import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; /** - * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass - * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side - * silently drops the item and hides the navigation toolbar entirely. Screens - * that send it must fall back to standard search/toolbar primitives when this - * is false. + * Group search, filtering, and composition on platforms with Liquid Glass. + * The v5 header adapter turns this intent into UISearchController and native + * toolbar items. Earlier iOS versions use separate search and toolbar options. */ export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; @@ -20,12 +18,7 @@ type NativeMailSearchToolbarInput = Omit< >; /** - * Builds the patched react-native-screens Mail-style bottom search toolbar. - * - * Keeping this behind an app-level helper makes the iOS-only RNS patch an - * explicit layout primitive instead of a per-screen object literal. Android can - * keep using platform-specific header/search primitives without depending on - * this helper. + * Describe the thread-list search and actions for the native header adapter. */ export function createNativeMailSearchToolbarItem( input: NativeMailSearchToolbarInput, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index f0dae4ebb0e1..1f474a9d56b8 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -29,14 +29,19 @@ export function SettingsRouteScreen() { return ( <> + {Platform.OS === "ios" ? ( + + ) : null} {Platform.OS === "ios" && layout.usesSplitView ? ( - - navigation.goBack()} - /> - + <> + + navigation.goBack()} + /> + + ) : null} {Platform.OS === "android" ? ( diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 767a1fa1dc7f..70579053bd93 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -9,6 +9,7 @@ import { import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { SymbolView } from "../../components/AppSymbol"; import { ScreenHeader } from "../../components/ScreenHeader"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Pressable, View } from "react-native"; @@ -254,6 +255,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { const insets = useSafeAreaInsets(); + const nativeColumnMetrics = useNativeColumnLayoutMetrics(); const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); @@ -1202,6 +1204,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) Platform.OS === "android" ? themeVariables["--color-card-alt"] : terminalTheme.background, + // The v5 column extends behind UIKit chrome. Its measured top + // reservation is zero when UIKit has already inset the body or moved the bar. + paddingTop: + Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0, paddingBottom: Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0, }} diff --git a/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx b/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx new file mode 100644 index 000000000000..1ff71cb29cd6 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; +import { View, type ViewInstance } from "react-native"; + +import { useComposerPopoverHost } from "./ComposerPopoverHost"; + +const COMPOSER_GAP = 8; + +type Frame = { readonly x: number; readonly y: number; readonly width: number }; + +/** + * Places a composer popover just above the composer. Android only delivers + * drags to views inside their parent's bounds, so a popover hung above the + * composer with `bottom-full` takes taps but its list never scrolls. Render it + * into ComposerPopoverHost instead, which shares the composer's keyboard + * transform, so only the composer's resting layout needs measuring. + */ +export function ComposerPopoverAnchor(props: { readonly children: ReactNode }) { + const host = useComposerPopoverHost(); + const anchorRef = useRef(null); + const [frame, setFrame] = useState(null); + + const measure = () => { + const hostView = host?.hostRef.current; + if (!hostView) { + return; + } + anchorRef.current?.measureLayout(hostView, (x, y, width) => { + setFrame((current) => + current?.x === x && current.y === y && current.width === width ? current : { x, y, width }, + ); + }); + }; + + // No dependency array: the composer re-renders when its padding or expanded + // state changes, which moves it without resizing the anchor, so measure and + // re-project after every render. + useEffect(() => { + measure(); + host?.setContent( + frame === null ? null : ( + + {props.children} + + ), + ); + }); + + const setHostContent = host?.setContent; + useEffect(() => () => setHostContent?.(null), [setHostContent]); + + if (host === null) { + return {props.children}; + } + + // Covers the composer, so its onLayout fires whenever the composer resizes. + return ( + + ); +} diff --git a/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx b/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx new file mode 100644 index 000000000000..0552854b6e57 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; +import { View } from "react-native"; + +/** Places a composer popover just above the composer. */ +export function ComposerPopoverAnchor(props: { readonly children: ReactNode }) { + return {props.children}; +} diff --git a/apps/mobile/src/features/threads/ComposerPopoverHost.tsx b/apps/mobile/src/features/threads/ComposerPopoverHost.tsx new file mode 100644 index 000000000000..cefa3a1f5fed --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerPopoverHost.tsx @@ -0,0 +1,49 @@ +import type { ReactNode, RefObject } from "react"; +import { createContext, useContext, useMemo, useRef, useState } from "react"; +import { View, type ViewInstance } from "react-native"; + +type ComposerPopoverHostValue = { + readonly hostRef: RefObject; + readonly setContent: (content: ReactNode) => void; + /** Changes when the host resizes, which moves the bottom-anchored composer. */ + readonly layoutVersion: number; +}; + +const ComposerPopoverHostContext = createContext(null); + +export function useComposerPopoverHost() { + return useContext(ComposerPopoverHostContext); +} + +/** + * Full-size layer around the composer for its popovers. Mount it inside the + * composer's KeyboardStickyView: popovers placed here move with the composer + * and sit inside a parent that covers them, so Android delivers their scroll + * gestures. It wraps the composer because measureLayout needs an ancestor. + * Pass `hidden` whenever the composer itself is hidden: popovers live outside + * the composer's subtree, so they don't hide with it. + */ +export function ComposerPopoverHost(props: { + readonly hidden: boolean; + readonly children: ReactNode; +}) { + const hostRef = useRef(null); + const [content, setContent] = useState(null); + const [layoutVersion, setLayoutVersion] = useState(0); + const value = useMemo(() => ({ hostRef, setContent, layoutVersion }), [layoutVersion]); + + return ( + + setLayoutVersion((version) => version + 1)} + > + {props.children} + {props.hidden ? null : content} + + + ); +} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx index cf123a736559..5df9282d13f9 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -29,7 +29,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { useState, type ReactNode } from "react"; import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; -import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { ScrollView, useWindowDimensions, View } from "react-native"; import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; import { NativeHeaderToolbar } from "../../native/StackHeader"; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 17dce1395a88..af9678245760 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -102,6 +102,7 @@ import { type ComposerSendPresentation, } from "./composerSendPresentation"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; +import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor"; import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { @@ -782,7 +783,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {!voiceInput.isBusy && composerMenu.trigger && (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( - + - + ) : null} {selectedProviderStatus?.compatibilityAdvisory?.message && diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 1cc5d909aace..88c064b29126 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -137,6 +137,7 @@ import { COMPOSER_TRANSITION_DURATION_MS, ThreadComposer, } from "./ThreadComposer"; +import { ComposerPopoverHost } from "./ComposerPopoverHost"; import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed"; import { useThreadTurnSubagents } from "./ThreadAgentsSheet"; import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; @@ -563,6 +564,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + // A pending user-input request or a failed creation owns the composer slot. + const composerSlotHidden = + activeUserInputRequestId !== null || props.creationState?.kind === "failed"; // The open /usage-limits panel for this thread, model and turn. Only the open // moment is stored: the rows read live provider data, so a redeemed reset // credit or refreshed probe shows through. Anything that spends quota closes @@ -1176,265 +1180,263 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread style={{ position: "absolute", bottom: 0, left: 0, right: 0, top: 0 }} offset={{ closed: 0, opened: 0 }} > - {/* The fixed sticky host gives this bottom-anchored child a stable + - {/* Hidden (not unmounted) while a user-input request owns the + {/* Hidden (not unmounted) while a user-input request owns the composer slot, so composer drafts and editor state survive. A rejected creation has no thread to send to; the failure card owns the slot instead. */} - - {isProviderSubagent ? ( - - - navigation.navigate("Thread", { - environmentId: String(props.environmentId), - threadId: String(props.selectedThread.lineage.parentThreadId), - }) - } - /> - - ) : ( - <> - - - )} + + {isProviderSubagent ? ( + + + navigation.navigate("Thread", { + environmentId: String(props.environmentId), + threadId: String(props.selectedThread.lineage.parentThreadId), + }) + } + /> + + ) : ( + <> + + + )} + - - + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c18874b1cb03..b5f258a0889f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1628,6 +1628,7 @@ function renderFeedEntry( summaryToolIcon={entry.summaryToolIcon} hasFailure={entry.hasFailure} shimmer={entry.shimmer} + thought={entry.thought} onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)} /> ); @@ -2937,6 +2938,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "run-fold": return resolveThreadFeedFixedItemSize(entry.type); case "work-toggle": + // A live thought wraps up to four lines, so that row measures itself. + return entry.thought ? undefined : WORK_GROUP_TOGGLE_HEIGHT; case "thinking": return WORK_GROUP_TOGGLE_HEIGHT; case "activity-group": diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 9f1e0a9411e9..5ff9397a179d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -664,11 +664,11 @@ function ThreadRouteContent( () => inspectorMode === null ? null : ( ), [ diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index ac03553eb7dc..1ff108198c13 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,3 +1,4 @@ +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import type { EnvironmentId, ModelSelection, @@ -15,10 +16,7 @@ import { getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; -import { - createNativeStackNavigator, - type NativeStackNavigationProp, -} from "@react-navigation/native-stack"; +import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; import { AsyncResult } from "effect/reactivity"; import { diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index 1198b06cb6a4..6f434ad656b7 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -1,8 +1,6 @@ +import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; -import { - createNativeStackNavigator, - type NativeStackNavigationOptions, -} from "@react-navigation/native-stack"; +import { type NativeStackNavigationOptions } from "@react-navigation/native-stack"; import type { ReactNode } from "react"; import { Platform } from "react-native"; diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 5a1601b0e5ae..b36218cd29aa 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { View } from "react-native"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; @@ -43,11 +43,11 @@ function InspectorContentPane(props: { } export function ThreadInspectorContentStack(props: { - readonly Files: ComponentType; - readonly Git: ComponentType; + readonly renderFiles: () => ReactNode; + readonly renderGit?: () => ReactNode; readonly mode: ThreadInspectorMode; readonly resetKeys: readonly [string | null, string | null]; - readonly Route?: ComponentType; + readonly renderRoute?: () => ReactNode; }) { const [mountedModes, setMountedModes] = useState>( () => new Set([props.mode]), @@ -81,10 +81,6 @@ export function ThreadInspectorContentStack(props: { return () => clearTimeout(timeout); }, [props.mode]); - const Files = props.Files; - const Git = props.Git; - const Route = props.Route; - return ( - - - - + - {Route ? ( + {props.renderGit ? ( + + + + ) : null} + {props.renderRoute ? ( - + ) : null} ); } + +// Render callbacks carry changing route data; they are not component types. +function InspectorRenderer(props: { readonly render: () => ReactNode }) { + return <>{props.render()}; +} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 4a9067529510..32e6d272b678 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1222,11 +1222,11 @@ export function ThreadWorkGroupToggle(props: { readonly toolIcon?: ToolActivityIcon; readonly hasFailure: boolean; readonly shimmer: boolean; + readonly thought?: string | undefined; readonly onToggle: () => void; }) { - const accessibilityLabel = props.hasFailure - ? `${props.summary}, tool call failed` - : props.summary; + const statusLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; + const accessibilityLabel = props.thought ? `${props.thought} ${statusLabel}` : statusLabel; const icon = props.summaryToolIcon ?? (props.toolSurface @@ -1235,6 +1235,28 @@ export function ThreadWorkGroupToggle(props: { return ( + {props.thought ? ( + // The latest thought sits above the status line as an ordinary work-log + // row, wrapped up to four lines. + + + + + + {props.thought} + + + ) : null} row.type === "work-toggle")).toMatchObject({ - summary: "First paragraph. Second paragraph.", + summary: "Thinking", + thought: "First paragraph.", live: true, }); } else { @@ -2293,6 +2294,32 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])( }, ); +it("keeps the latest thought under the live tool status", () => { + const at = "2026-06-20T00:00:02.000Z"; + const thought: OrchestrationV2TurnItem = { + ...base("found-thought", at, 1), + type: "reasoning", + status: "completed", + streaming: false, + text: "Found the cause: no commits yet. Checking the UI next.", + }; + const tool = { ...command(at), status: "running" as const, completedAt: null }; + const rows = deriveThreadFeedPresentation( + buildThreadFeed([projected(userMessage(), 0), projected(thought, 1), projected(tool, 2)]), + { runId, status: "running", startedAt: at, completedAt: null }, + new Set(), + new Set(), + at, + ); + expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({ + thought: "Found the cause: no commits yet.", + live: true, + }); + expect(rows.find((row) => row.type === "work-toggle")).not.toMatchObject({ + summary: "Thinking", + }); +}); + it("stops stranded thinking after a steer and follows the next thought or tool", () => { const at = "2026-06-20T00:00:02.000Z"; const thought = (id: string): OrchestrationV2TurnItem => ({ @@ -2316,7 +2343,8 @@ it("stops stranded thinking after a steer and follows the next thought or tool", at, ); expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({ - summary: "first-thought", + summary: "Thinking", + thought: "first-thought", live: true, shimmer: true, }); @@ -2340,9 +2368,11 @@ it("stops stranded thinking after a steer and follows the next thought or tool", ]) { const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer); expect(live).toHaveLength(1); - expect(live[0]).toMatchObject({ - summary: items.at(-1)!.type === "reasoning" ? "next-thought" : "Running vp", - }); + expect(live[0]).toMatchObject( + items.at(-1)!.type === "reasoning" + ? { summary: "Thinking", thought: "next-thought" } + : { summary: "Running vp" }, + ); } expect(first.status).toBe("running"); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d88b1dba2ea2..2dc513e40f02 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/client-runtime/work-log/command-label"; import { contextCompactionLabel, + liveThoughtLine, toolItemForDisplay, workEntryDisplayIndicatesToolFailure, liveActivityToolStatus, @@ -213,6 +214,8 @@ type ThreadFeedEntryContent = readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; + /** First sentence of the latest thought, shown above the live status line. */ + readonly thought?: string; } | { readonly type: "run-fold"; @@ -1496,8 +1499,17 @@ function appendToolGroupRows( const shimmer = activeTail && (active || latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry)); + const latestThought = + live && !expanded + ? activities.findLast( + (activity) => + activity.workEntry.itemType === "reasoning" && + (activity.workEntry.detail?.trim() ?? "") !== "", + )?.workEntry.detail + : undefined; + const thought = latestThought ? liveThoughtLine(latestThought) : ""; const summary = live - ? expanded && latestActivity.workEntry.itemType === "reasoning" + ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning" ? latestActivity.lifecycleStatus === "inProgress" ? "Thinking" : "Thought" @@ -1552,6 +1564,7 @@ function appendToolGroupRows( ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), ...(summaryToolIcon ? { summaryToolIcon } : {}), + ...(thought ? { thought } : {}), hasFailure: (() => { const lastToolLike = activities.findLast((activity) => activity.toolLike); return ( diff --git a/apps/mobile/src/native/NativeColumnContent.ios.tsx b/apps/mobile/src/native/NativeColumnContent.ios.tsx new file mode 100644 index 000000000000..77b03098b36c --- /dev/null +++ b/apps/mobile/src/native/NativeColumnContent.ios.tsx @@ -0,0 +1,31 @@ +import { HeaderHeightContext } from "@react-navigation/elements"; +import { useState, type ReactNode } from "react"; +import { View } from "react-native"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +import { NativeColumnLayoutMetricsContext } from "./native-layout-metrics"; +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; +import { NativeLayoutObserver } from "./NativeLayoutObserver"; + +export function NativeColumnContent(props: { + readonly children: ReactNode; + readonly primary?: boolean; +}) { + const [metrics, setMetrics] = useState(null); + return ( + + + + {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} + + + + + {props.children} + + + + + + + ); +} diff --git a/apps/mobile/src/native/NativeLayoutObserver.ios.tsx b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx new file mode 100644 index 000000000000..8d284d9edeec --- /dev/null +++ b/apps/mobile/src/native/NativeLayoutObserver.ios.tsx @@ -0,0 +1,33 @@ +import { requireNativeView, requireOptionalNativeModule } from "expo"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; + +interface ObserverProps extends ViewProps { + readonly onMetricsChange?: (event: NativeSyntheticEvent) => void; +} + +// An older development client can still run the compact app while it rebuilds. +function resolveObserver() { + try { + const module = requireOptionalNativeModule<{ + readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown }; + }>("T3NativeControls"); + if (!module?.ViewPrototypes?.T3NativeControls_LayoutMetrics) return null; + return requireNativeView("T3NativeControls", "LayoutMetrics"); + } catch { + return null; + } +} +const Observer = resolveObserver(); + +export function NativeLayoutObserver(props: { + readonly onChange?: (metrics: NativeLayoutMetrics) => void; +}) { + return Observer ? ( + props.onChange?.(event.nativeEvent) : undefined} + style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }} + /> + ) : null; +} diff --git a/apps/mobile/src/native/NativeLayoutObserver.tsx b/apps/mobile/src/native/NativeLayoutObserver.tsx new file mode 100644 index 000000000000..e2b24a55a213 --- /dev/null +++ b/apps/mobile/src/native/NativeLayoutObserver.tsx @@ -0,0 +1,7 @@ +import type { NativeLayoutMetrics } from "../lib/reserved-regions"; + +export function NativeLayoutObserver(_props: { + readonly onChange?: (metrics: NativeLayoutMetrics) => void; +}) { + return null; +} diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx new file mode 100644 index 000000000000..c85529b1dbf2 --- /dev/null +++ b/apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx @@ -0,0 +1,7 @@ +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; +const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>( + "T3NativeControls", +); +export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = + Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true; diff --git a/apps/mobile/src/native/NativeWorkspaceColumns.tsx b/apps/mobile/src/native/NativeWorkspaceColumns.tsx new file mode 100644 index 000000000000..4bb7aa16ac9b --- /dev/null +++ b/apps/mobile/src/native/NativeWorkspaceColumns.tsx @@ -0,0 +1,3 @@ +import { Platform } from "react-native"; +// Android retains its existing navigator. The iOS file checks native availability. +export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad; diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx new file mode 100644 index 000000000000..2c843dab7cdc --- /dev/null +++ b/apps/mobile/src/native/V5StackHeader.ios.tsx @@ -0,0 +1,253 @@ +import type { + NativeStackHeaderItem, + NativeStackHeaderItemMenu, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { isValidElement, useRef } from "react"; +import { Platform, StyleSheet } from "react-native"; +import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme"; +import { + SearchBar, + Stack, + type HeaderBarButtonMailSearchToolbarItem, + type SearchBarCommands, + type StackHeaderConfigProps, + type StackHeaderMenuElementIOS, + type StackHeaderMenuIOS, + type HeaderBarButtonItemWithMenu, + type HeaderBarButtonSearchBarPlacementItem, +} from "react-native-screens"; + +type HeaderItem = NonNullable["leadingItems"]>[number]; +type ToolbarItem = + | NativeStackHeaderItem + | HeaderBarButtonMailSearchToolbarItem + | HeaderBarButtonSearchBarPlacementItem; +type Options = NativeStackNavigationOptions & { + readonly unstable_navigationItemStyle?: "editor" | "navigator"; + readonly headerSubtitle?: string; + readonly unstable_headerSubtitle?: string; + readonly headerSubtitleStyle?: { color?: string; fontSize?: number; fontFamily?: string }; + readonly unstable_headerCenterItems?: () => NativeStackHeaderItem[]; + readonly unstable_headerToolbarItems?: () => ToolbarItem[]; +}; + +type MenuElementInput = + | NativeStackHeaderItemMenu["menu"]["items"][number] + | HeaderBarButtonItemWithMenu["menu"]["items"][number]; +type MenuInput = { readonly title?: string; readonly items: readonly MenuElementInput[] }; + +function convertIcon( + icon: + | Extract["icon"] + | HeaderBarButtonItemWithMenu["icon"], +) { + if (!icon) return undefined; + if (icon.type === "image") + return icon.tinted === false + ? { type: "imageSource" as const, imageSource: icon.source } + : { type: "templateSource" as const, templateSource: icon.source }; + return icon; +} + +function convertMenu(menu: MenuInput, id: string): StackHeaderMenuIOS { + const children = menu.items.flatMap((item, index) => { + if ("hidden" in item && item.hidden) return []; + const childId = `${id}:${index}`; + if (item.type === "submenu") { + return [ + { + ...convertMenu({ items: item.items }, childId), + title: "label" in item ? item.label : item.title, + displayInline: + "inline" in item + ? item.inline + : "displayInline" in item + ? item.displayInline + : undefined, + }, + ]; + } + // Use actions even for checked entries: the app owns their durable state. + return [ + { + id: childId, + type: "menuItem" as const, + itemType: "action" as const, + title: "label" in item ? item.label : item.title, + icon: convertIcon(item.icon), + onPress: item.disabled ? undefined : item.onPress, + keepsMenuPresented: item.keepsMenuPresented, + disabled: item.disabled, + destructive: item.destructive, + subtitle: + ("subtitle" in item ? item.subtitle : undefined) ?? + ("description" in item ? item.description : undefined), + state: item.state, + }, + ]; + }); + return { id, type: "menu", title: menu.title, children }; +} + +function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderItem[] { + return items.map((item, index): HeaderItem => { + const id = + item.type === "spacing" ? `${prefix}:${index}` : (item.identifier ?? `${prefix}:${index}`); + switch (item.type) { + case "spacing": + return { type: "spacer", id, sizing: "fixed", width: item.spacing }; + case "custom": + return { + type: "item", + id, + render: () => item.element, + hidesSharedBackground: item.hidesSharedBackground, + }; + case "menu": + return { + type: "item", + id, + identifier: item.identifier, + title: item.label || item.accessibilityLabel || undefined, + icon: convertIcon(item.icon), + menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`), + hidesSharedBackground: item.hidesSharedBackground, + }; + case "button": + return { + type: "item", + id, + identifier: item.identifier, + title: item.label || item.accessibilityLabel || undefined, + icon: convertIcon(item.icon), + onPress: item.disabled ? undefined : item.onPress, + hidesSharedBackground: item.hidesSharedBackground, + }; + } + }); +} + +/** Translate the existing screen options to v5's native header API. */ +export function V5StackHeader(props: { + readonly options: Options; + readonly canGoBack: boolean; + readonly primary?: boolean; +}) { + const { options } = props; + const theme = useMobileNavigationTheme(); + const searchRef = useRef(null); + const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack }; + const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading"); + const trailing = convertItems( + [ + ...(options.unstable_headerCenterItems?.() ?? []), + ...(options.unstable_headerRightItems?.(itemProps) ?? []), + ], + "trailing", + ); + const toolbar = options.unstable_headerToolbarItems?.() ?? []; + const mailSearch = toolbar.find( + (item): item is HeaderBarButtonMailSearchToolbarItem => item.type === "mailSearchToolbar", + ); + const bottomSearch = Boolean(mailSearch) && Platform.OS === "ios" && !Platform.isPad; + const bottom = toolbar.flatMap((item, index) => { + if (item.type === "mailSearchToolbar") return []; + if (item.type === "searchBarPlacement") + return [{ type: "item", id: `toolbar-search:${index}`, searchBarPlacement: true }]; + return convertItems([item], `toolbar:${index}`); + }); + if (mailSearch) { + if (mailSearch.filterMenu) + bottom.push({ + type: "item", + id: mailSearch.filterButtonId ?? "filter", + icon: { + type: "sfSymbol", + name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease", + }, + menu: convertMenu(mailSearch.filterMenu, "filter-menu"), + }); + if (bottomSearch) { + if (bottom.length) + bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 }); + bottom.push({ type: "item", id: "home-search", searchBarPlacement: true }); + if (mailSearch.onComposePress) + bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 }); + } + if (mailSearch.onComposePress) + bottom.push({ + type: "item", + id: mailSearch.composeButtonId ?? "compose", + icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" }, + onPress: mailSearch.onComposePress, + }); + } + const left = options.headerLeft?.(itemProps); + const right = options.headerRight?.(itemProps); + if (!options.unstable_headerLeftItems && isValidElement(left)) + leading.push({ type: "item", id: "custom-left", render: () => left }); + if (!options.unstable_headerRightItems && isValidElement(right)) + trailing.push({ type: "item", id: "custom-right", render: () => right }); + const title = typeof options.headerTitle === "string" ? options.headerTitle : options.title; + const titleElement = + typeof options.headerTitle === "function" + ? options.headerTitle({ children: title ?? "", tintColor: options.headerTintColor }) + : undefined; + const searchOptions = options.headerSearchBarOptions; + const titleStyle = StyleSheet.flatten(options.headerTitleStyle); + const headerStyle = StyleSheet.flatten(options.headerStyle); + const appearance = { + backgroundColor: + headerStyle?.backgroundColor ?? + (options.headerTransparent ? "transparent" : theme.colors.card), + shadowColor: options.headerShadowVisible === false ? "transparent" : undefined, + titleFontSize: titleStyle?.fontSize, + titleFontFamily: titleStyle?.fontFamily, + titleFontWeight: titleStyle?.fontWeight, + titleFontColor: titleStyle?.color, + subtitleFontSize: options.headerSubtitleStyle?.fontSize, + subtitleFontColor: options.headerSubtitleStyle?.color, + }; + return ( +