From 388b43a27cc37ae067087073825633d4fc5de3b7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 05:28:00 -0400 Subject: [PATCH 1/3] perf(dev): faster cold-start for dev web serving (#5584) Co-authored-by: Claude Fable 5 --- apps/web/package.json | 2 + apps/web/scripts/warm-dep-cache.ts | 22 ++++++ apps/web/tsconfig.json | 9 ++- apps/web/vite.config.ts | 37 +++++++++- docs/internals/scripts.md | 3 + pnpm-lock.yaml | 78 +++++++++++++++++++++ scripts/dev-runner.test.ts | 109 +++++++++++++++++++++++++++++ scripts/dev-runner.ts | 8 +++ t3.json | 2 +- 9 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 apps/web/scripts/warm-dep-cache.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5b1789caee2..83f55abcfd5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,11 +52,13 @@ "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/babel__core": "^7.20.5", + "@types/compression": "^1.8.1", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", + "compression": "^1.8.1", "msw": "2.12.11", "tailwindcss": "^4.0.0", "vite": "catalog:", diff --git a/apps/web/scripts/warm-dep-cache.ts b/apps/web/scripts/warm-dep-cache.ts new file mode 100644 index 00000000000..4fe50a3bf9c --- /dev/null +++ b/apps/web/scripts/warm-dep-cache.ts @@ -0,0 +1,22 @@ +// @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists. +/** + * Pre-warms Vite's dependency-optimizer cache (`node_modules/.vite/deps`) so + * the first `vp run dev` in a fresh worktree doesn't stall the initial page + * load on a full optimize pass. Run by the t3.json worktree setup script; + * safe to re-run — a valid cache makes this a fast no-op. + * + * The cache cannot be shared between worktrees: Vite's config hash includes + * the absolute project root, so each worktree must warm its own. + */ +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { optimizeDeps, resolveConfig } from "vite"; + +const webRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); + +// logLevel "error" silences the "manually calling optimizeDeps is deprecated" +// warning — deliberate here: warming ahead of the server is the whole point. +const config = await resolveConfig({ root: webRoot, logLevel: "error" }, "serve"); +await optimizeDeps(config); +console.log("[warm-dep-cache] web dependency cache is warm"); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 0e226fc136d..186b6ebfe50 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -25,5 +25,12 @@ } ] }, - "include": ["src", "vite.config.ts", "vercel.ts", "test", "../../scripts/lib/public-config.ts"] + "include": [ + "src", + "vite.config.ts", + "vercel.ts", + "test", + "scripts/warm-dep-cache.ts", + "../../scripts/lib/public-config.ts" + ] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 24d76c2d9f3..6ee5de587b9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,10 +1,13 @@ +import * as NodeZlib from "node:zlib"; + import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import compression from "compression"; import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config"; import "vite-plus/test/config"; -import { defineConfig } from "vite-plus"; +import { defineConfig, type Connect, type Plugin } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; @@ -55,6 +58,8 @@ const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); // Vite 8.1's experimental bundled dev mode: serves rolldown-bundled chunks in // dev for much faster startup/reload on large module graphs, with HMR served // as hot patches. Opt-in while experimental: T3CODE_BUNDLED_DEV=1 pnpm dev:web +// The dev runner defaults this on for --share runs (remote browsers pay a +// round trip per import level in unbundled dev); T3CODE_BUNDLED_DEV=0 opts out. const bundledDevEnv = process.env.T3CODE_BUNDLED_DEV?.trim().toLowerCase(); const bundledDev = bundledDevEnv === "1" || bundledDevEnv === "true"; @@ -114,6 +119,28 @@ function resolveDevProxyTarget( const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl); +// Vite's dev server sends JS uncompressed. On localhost that is free; over a +// shared origin (tailnet, LAN) it is the whole cold-start: bundled dev serves +// one ~25 MB chunk, and a typical uplink moves that in about a minute while +// both machines sit idle. Compressing turns it into a few seconds of CPU. +// Brotli quality 5 keeps encode time in the hundreds of ms; the default +// (quality 11) would trade the transfer stall for an equally long encode stall. +function devCompressionPlugin(): Plugin { + return { + name: "t3code:dev-compression", + apply: "serve", + configureServer(server) { + // compression() is typed against Express's req/res, which extend the + // node http objects Connect actually passes — safe to narrow. + server.middlewares.use( + compression({ + brotli: { params: { [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 5 } }, + }) as unknown as Connect.NextHandleFunction, + ); + }, + }; +} + // Vite rejects requests whose Host header isn't localhost, which blocks sharing // a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale: // the DNS is controlled by tailscale, so they can't be rebound by an attacker. @@ -128,6 +155,7 @@ export default defineConfig(() => { return { assetsInclude: ["**/*.wasm"], plugins: [ + devCompressionPlugin(), tanstackRouter(), react(), babel({ @@ -187,6 +215,13 @@ export default defineConfig(() => { port, strictPort: true, allowedHosts, + // Transform the whole module graph at server start instead of on the + // first request. Without this, a cold worktree discovers and transforms + // modules one import-level at a time while the browser waits — which + // over a tailnet origin turns into minutes of waterfall. + warmup: { + clientFiles: ["./src/main.tsx"], + }, ...(devProxyTarget ? { // One entry per shared prefix; the server's dev catch-all 404s the diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md index 2a020701064..9440115e1a9 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -24,6 +24,9 @@ authenticated. - `vp run dev`: Starts contracts, server, and web in watch mode. - `vp run dev --share`: Also publishes the web port over HTTPS on this machine's tailnet. The startup pairing URL is built against the shared origin, and the mapping is removed on exit. + Shared runs default to Vite's bundled dev mode (`T3CODE_BUNDLED_DEV=1`): a remote browser pays a + network round trip per import level in unbundled dev, which turns a cold module graph into + minutes of waterfall. Set `T3CODE_BUNDLED_DEV=0` to opt a shared run back out. - `vp run dev --browser`: Auto-opens a browser. Off by default. The dev runner writes `T3CODE_NO_BROWSER` itself from this flag, so setting `T3CODE_NO_BROWSER=0` in your environment has no effect; use `--browser`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0be461aacbf..1eb0379e84a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -623,6 +623,9 @@ importers: '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 '@types/react': specifier: ~19.2.14 version: 19.2.16 @@ -638,6 +641,9 @@ importers: babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 + compression: + specifier: ^1.8.1 + version: 1.8.1 msw: specifier: 2.12.11 version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) @@ -4777,6 +4783,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/bun@1.3.14': resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} @@ -4786,6 +4795,12 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -4801,6 +4816,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -4813,6 +4834,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -4843,6 +4867,12 @@ packages: '@types/pngjs@6.0.5': resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -4857,6 +4887,12 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -14913,6 +14949,11 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.12.4 + '@types/bun@1.3.14': dependencies: bun-types: 1.3.14 @@ -14929,6 +14970,15 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.6 + '@types/node': 24.12.4 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.4 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -14943,6 +14993,19 @@ snapshots: '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 24.12.4 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + '@types/fs-extra@9.0.13': dependencies: '@types/node': 24.12.4 @@ -14955,6 +15018,8 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -14989,6 +15054,10 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 @@ -15005,6 +15074,15 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/send@1.2.1': + dependencies: + '@types/node': 24.12.4 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.12.4 + '@types/statuses@2.0.6': {} '@types/unist@2.0.11': {} diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 2ea3064c2a4..17be2c6263f 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -986,6 +986,115 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); }); + // A shared origin means a remote browser, where unbundled dev's + // per-module waterfall pays a tailnet round trip per import level. The + // runner defaults bundled dev on for the spawned stack, but only + // defaults: an explicit T3CODE_BUNDLED_DEV (even "0") must pass through. + describe("--share bundled dev default", () => { + const shareSpawnedEnv = (input: { readonly ambientBundledDev: string | undefined }) => + Effect.gen(function* () { + let captured: Record | undefined; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const spawned = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + readonly options?: { readonly env?: Record }; + }; + if (spawned.command === "vp") { + captured = spawned.options?.env; + return Effect.succeed(mockProcess(0)); + } + // tailscale: answer `status --json` with a valid tailnet name, + // succeed the `serve`/`off` calls. + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(2), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: spawned.args.includes("status") + ? Stream.make( + new TextEncoder().encode( + JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }), + ), + ) + : Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + + yield* runDevRunnerWithInput({ + ...devServerInput, + mode: "dev", + port: undefined, + share: true, + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService( + HostProcessEnvironment, + input.ambientBundledDev === undefined + ? {} + : { T3CODE_BUNDLED_DEV: input.ambientBundledDev }, + ), + ); + + return captured; + }); + + it.effect("defaults T3CODE_BUNDLED_DEV=1 for a shared run", () => + Effect.gen(function* () { + const env = yield* shareSpawnedEnv({ ambientBundledDev: undefined }); + assert.equal(env?.T3CODE_BUNDLED_DEV, "1"); + }), + ); + + it.effect("keeps an explicit T3CODE_BUNDLED_DEV=0 opt-out", () => + Effect.gen(function* () { + const env = yield* shareSpawnedEnv({ ambientBundledDev: "0" }); + assert.equal(env?.T3CODE_BUNDLED_DEV, "0"); + }), + ); + + it.effect("leaves T3CODE_BUNDLED_DEV unset without --share", () => + Effect.gen(function* () { + let captured: Record | undefined; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + captured = ( + command as { + readonly options?: { readonly env?: Record }; + } + ).options?.env; + return Effect.succeed(mockProcess(0)); + }), + ); + + yield* runDevRunnerWithInput({ + ...devServerInput, + mode: "dev", + port: undefined, + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, {}), + ); + + assert.equal(captured?.T3CODE_BUNDLED_DEV, undefined); + }), + ); + }); + it.effect("spawns nothing when --dry-run is combined with --share", () => { let spawnCount = 0; const spawnerLayer = Layer.succeed( diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index cb4e3f74c4f..6324d852bb1 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -780,6 +780,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { if (input.devUrl === undefined) { env.VITE_DEV_SERVER_URL = shared.url; } + // A shared origin serves a remote browser, where unbundled dev's + // per-module requests each pay a tailnet round trip — a cold module + // graph takes minutes to first paint. Bundled dev collapses that to + // a few chunk requests. Only defaulted, so T3CODE_BUNDLED_DEV=0 + // still opts a --share run back out. + if (env.T3CODE_BUNDLED_DEV === undefined) { + env.T3CODE_BUNDLED_DEV = "1"; + } yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`); } } diff --git a/t3.json b/t3.json index 3a6956003af..007e8f96194 100644 --- a/t3.json +++ b/t3.json @@ -4,7 +4,7 @@ "scripts": [ { "name": "Setup Worktree", - "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env", + "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env && node apps/web/scripts/warm-dep-cache.ts", "icon": "configure", "runOnWorktreeCreate": true } From a1762fdd7482728800f1f4fd260c632f01a30b1f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 05:28:51 -0400 Subject: [PATCH 2/3] fix(dev): Improve instructions for --share (#5586) Co-authored-by: Claude Fable 5 --- .agents/skills/test-t3-app/SKILL.md | 19 ++++--------------- AGENTS.md | 4 ++-- scripts/dev-runner.test.ts | 24 ++++++++++++++++++++++++ scripts/dev-runner.ts | 8 ++++++++ 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 45524f6fcd3..0e11b50e1c8 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -40,7 +40,7 @@ Treat the overall testing or implementation loop—not an assistant turn or one - Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. - Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. - On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. -- Tell the user when a test environment remains available, including its non-secret web URL when useful. Never include a pairing token. +- Tell the user when a test environment remains available, including its non-secret web URL when useful. Include a pairing token only when the user still needs to pair (see below). ## Authenticate the browser on the first navigation @@ -50,24 +50,13 @@ Treat the overall testing or implementation loop—not an assistant turn or one 4. Wait for the pairing exchange and redirect to finish before navigating elsewhere. 5. Continue in the same browser context so its stored bearer session remains available. -Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it. +Keep pairing URLs out of screenshots, committed files, and durable logs. When the user asked for a shared environment, the deliverable IS the full pairing URL — paste it in your reply, token and all; a bare origin is useless to them. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it, so never open a URL you handed to the user. ## Recover a consumed or expired pairing token -Create another token against the same database and web URL as the running dev server: +Run `node apps/server/src/bin.ts pair` from the repository root. It discovers the running dev server (worktree `.t3` first, same precedence as the dev runner) and prints a fresh `Pair URL` against the server's current web origin, including a `--share` tailnet origin. Pass `--base-dir ` only when the server was started with `--home-dir`, using the identical path. -```bash -T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ - --base-dir \ - --dev-url \ - --base-url \ - --ttl 15m \ - --label agent-ui-test -``` - -Use the `Pair URL` from this command once. Derive `` and `` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. - -Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. +Tokens from `pair` carry standard client scopes. The startup pairing URL carries admin scopes; if the user needs Settings → Connections management (`access:write`), restart the server and hand over the new startup URL instead. ## Inspect or seed SQLite state diff --git a/AGENTS.md b/AGENTS.md index c3a7fe92bf4..1b41f833ce5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. -- `--share` publishes over the tailnet. Do not open the URL when you use this, just send it to the user with the pairing code included in url -- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. +- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself. +- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management). - Stop what you started, by the PID you tracked. See rule 1. ## Test data diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 17be2c6263f..6914ebb6977 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -227,6 +227,30 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("strips inherited service-launcher context", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: { + T3_SERVICE_LAUNCHER_CONTEXT: '{"childVersion":"9.9.9"}', + T3_BOOT_SERVICE_UNIT: "t3code.service", + }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3_SERVICE_LAUNCHER_CONTEXT, undefined); + assert.equal(env.T3_BOOT_SERVICE_UNIT, undefined); + }), + ); + it.effect("does not force websocket logging on in dev mode when unset", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 6324d852bb1..d426cc7829b 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -340,6 +340,14 @@ export function createDevRunnerEnv({ delete output.T3CODE_HOME; } + // A dev-runner server is never launcher-managed. When the shell that runs + // this script was itself spawned by the machine's managed t3 service (an + // agent working inside T3 Code), these leak through and the child server + // fails startup with "The service launcher started a different t3 version" + // (serviceLauncherClient.ts resolveStartup). + delete output.T3_SERVICE_LAUNCHER_CONTEXT; + delete output.T3_BOOT_SERVICE_UNIT; + if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); // HOST is Vite's own bind address, and the desktop branch below is the From 8100062a78f1e1942663a0cea9ddc42d2f835525 Mon Sep 17 00:00:00 2001 From: Jay Meistrich Date: Fri, 7 Aug 2026 10:40:06 +0100 Subject: [PATCH 3/3] fix(mobile): improve keyboard avoiding (#5451) --- apps/mobile/package.json | 5 ++ .../src/features/threads/ThreadFeed.tsx | 56 +------------------ 2 files changed, 7 insertions(+), 54 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c12ca979bf2..8b6834c9714 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -138,5 +138,10 @@ "@react-native-menu/menu" ] } + }, + "reanimated": { + "staticFeatureFlags": { + "DISABLE_COMMIT_PAUSING_MECHANISM": true + } } } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 28df94b529b..db7fecf64ff 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -45,14 +45,7 @@ import { import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Animated, { - FadeIn, - FadeInUp, - useSharedValue, - withTiming, - type LayoutAnimationsValues, - type SharedValue, -} from "react-native-reanimated"; +import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; @@ -117,14 +110,6 @@ function formatMessageTime(input: string): string { return MESSAGE_TIME_FORMATTER.format(timestamp); } -// Rows shift when content above them grows (streaming text, work-log folds); -// animating the container position turns those jumps into slides. Applied -// conditionally — see the gated transition in ThreadFeed: while browsing -// history the animation must NOT run, or every estimate→actual size -// correction plays as a visible slide against the instant scroll-offset -// compensation from maintainVisibleContentPosition. -const FEED_ITEM_LAYOUT_DURATION_MS = 180; - // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's // classNames. The fold row's min-h-11 (44px) stays taller than its single // text-sm line at every supported base font size (26px at the 22pt maximum), @@ -1457,11 +1442,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.onHeaderMaterialVisibilityChange], ); - // True while the viewport sits within ~one screen of the list end — the - // only region where layout shifts should animate. Starts true because the - // list opens pinned to the end. - const nearListEnd = useSharedValue(true); - const handleScroll = useCallback( (event: NativeSyntheticEvent) => { // anchorTopInset, not topContentInset: under automatic insets the list @@ -1469,10 +1449,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the // header height back or the material toggles a full header too late. reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - nearListEnd.value = - contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; - // Latch bookkeeping. LegendList recomputes its inset-aware end distance // before invoking this handler, so getState() is current. Returning to // the end re-arms follow no matter who scrolled (the user, or our own @@ -1488,7 +1464,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } } }, - [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow], + [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, setEndFollow], ); const handleScrollBeginDrag = useCallback(() => { userScrollSessionRef.current = true; @@ -1508,33 +1484,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userScrollSessionRef.current = false; }, []); - // Gated variant of the 180ms feed layout slide. Instant while browsing - // history: maintainVisibleContentPosition compensates the scroll offset in - // the same frame a row's measured size lands, so an instant reposition is - // invisible — animating it is exactly what made cold upward scrolls slide - // and jump. Near the end the slide stays on: streaming growth and sends - // shift rows at rest, where the animation is the thing preventing a hard - // visual snap. - const feedItemLayoutTransition = useMemo(() => { - return (values: LayoutAnimationsValues) => { - "worklet"; - const duration = nearListEnd.value ? FEED_ITEM_LAYOUT_DURATION_MS : 0; - return { - initialValues: { - originX: values.currentOriginX, - originY: values.currentOriginY, - width: values.currentWidth, - height: values.currentHeight, - }, - animations: { - originX: withTiming(values.targetOriginX, { duration }), - originY: withTiming(values.targetOriginY, { duration }), - width: withTiming(values.targetWidth, { duration }), - height: withTiming(values.targetHeight, { duration }), - }, - }; - }; - }, [nearListEnd]); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); const nextHeight = Math.round(event.nativeEvent.layout.height); @@ -1886,7 +1835,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - itemLayoutAnimation={feedItemLayoutTransition} // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): // lets its scroll math clamp programmatic scrolls to -headerInset // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short