diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 988e223d219f..2f9faba635e3 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -25,6 +25,7 @@ github:D3OXY github:dbalders github:eggfriedrice24 github:extoci +github:f-trycua github:flamboh github:FllipEis github:gbarros-dev diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py new file mode 100644 index 000000000000..0b3a53285916 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.py @@ -0,0 +1,76 @@ +"""Stage an untrusted preview ZIP without letting it replace packaging code.""" + +import shutil +import stat +import sys +import zipfile +from pathlib import Path + +ROOTS = ("server/dist", "desktop/dist-electron") +REQUIRED_FILES = { + "server/dist/bin.mjs", + "server/dist/client/index.html", + "desktop/dist-electron/main.cjs", +} +# The current bundle is about 32 MiB compressed. Bound extraction on the +# trusted runner even when the PR replaces the uploader entirely. +MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 +MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 +MAX_ENTRIES = 50_000 + + +def stage_bundle(archive: Path, destination: Path): + if archive.stat().st_size > MAX_ARCHIVE_BYTES: + raise ValueError("Preview archive is too large") + with zipfile.ZipFile(archive) as bundle: + entries = bundle.infolist() + if len(entries) > MAX_ENTRIES: + raise ValueError("Preview archive has too many entries") + if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES: + raise ValueError("Expanded preview bundle is too large") + seen = set() + files = set() + for entry in entries: + name = entry.filename.removesuffix("/") + parts = name.split("/") + # Reject ambiguous paths before normalization, including names + # that would alias on the macOS signing runner. + if ( + entry.orig_filename != entry.filename + or any(part in ("", ".", "..") for part in parts) + or any(char in name for char in "\\:") + or not name.isascii() + or any(ord(char) < 32 or ord(char) == 127 for char in name) + ): + raise ValueError(f"Unsafe preview path: {entry.filename!r}") + allowed = any(name.startswith(root + "/") for root in ROOTS) + if entry.is_dir(): + allowed |= any(root == name or root.startswith(name + "/") for root in ROOTS) + if not allowed: + raise ValueError(f"Unexpected preview path: {name!r}") + kind = stat.S_IFMT(entry.external_attr >> 16) + if kind not in (0, stat.S_IFDIR if entry.is_dir() else stat.S_IFREG): + raise ValueError(f"Non-regular preview entry: {name!r}") + if name.casefold() in seen: + raise ValueError(f"Duplicate preview path: {name!r}") + seen.add(name.casefold()) + if not entry.is_dir(): + files.add(name) + if not REQUIRED_FILES <= files: + raise ValueError("Preview bundle is missing required entry points") + # Validate all names before writing anything. This is a fresh directory + # outside the checkout; neither pre-existing links nor trusted files + # can be followed or overwritten. ZIP permissions are never restored. + destination.mkdir(parents=True, exist_ok=False) + for entry in entries: + target = destination / entry.filename + if entry.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with bundle.open(entry) as source, target.open("xb") as output: + shutil.copyfileobj(source, output) + + +if __name__ == "__main__": + stage_bundle(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/.github/scripts/stage-preview-bundle.test.py b/.github/scripts/stage-preview-bundle.test.py new file mode 100644 index 000000000000..5957279c8885 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.test.py @@ -0,0 +1,97 @@ +import importlib.util +import stat +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location( + "stage_preview_bundle", Path(__file__).with_name("stage-preview-bundle.py") +) +staging = importlib.util.module_from_spec(spec) +spec.loader.exec_module(staging) + + +class StagePreviewBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.archive = self.root / "bundle.zip" + self.destination = self.root / "staged" + + def bundle(self, extra=(), missing=None): + with zipfile.ZipFile(self.archive, "w") as bundle: + for name in sorted(staging.REQUIRED_FILES - {missing}): + bundle.writestr(name, b"bundle data, never executed") + for name, content in extra: + bundle.writestr(name, content) + + def stage(self): + staging.stage_bundle(self.archive, self.destination) + + def test_preserves_valid_bundle_layout_and_bytes(self): + self.bundle([("server/", b""), ("server/dist/", b""), + ("desktop/dist-electron/chunks/helper.cjs", b"chunk")]) + self.stage() + for name in staging.REQUIRED_FILES: + self.assertEqual((self.destination / name).read_bytes(), b"bundle data, never executed") + self.assertEqual((self.destination / "desktop/dist-electron/chunks/helper.cjs").read_bytes(), b"chunk") + + def test_rejects_builder_overwrite_and_unsafe_paths_before_writing(self): + for name in [ + "desktop/node_modules/electron-builder/cli.js", + "desktop/package.json", + "server/dist/../../desktop/package.json", + "../package.json", + "/server/dist/absolute", + "server/dist/./alias", + "server/dist//alias", + "server/dist/back\\slash", + "server/dist/file:stream", + "server/dist/BIN.MJS", + ]: + with self.subTest(name=name): + self.bundle([(name, b"untrusted")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_rejects_links_and_special_files(self): + for mode in [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR]: + with self.subTest(mode=mode): + entry = zipfile.ZipInfo("server/dist/link") + entry.create_system = 3 + entry.external_attr = (mode | 0o777) << 16 + self.bundle([(entry, b"../../../desktop/node_modules")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_requires_entry_points(self): + self.bundle(missing="desktop/dist-electron/main.cjs") + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_bounds_archive_size_expanded_size_and_entry_count(self): + for limit in ["MAX_ARCHIVE_BYTES", "MAX_EXPANDED_BYTES", "MAX_ENTRIES"]: + with self.subTest(limit=limit), patch.object(staging, limit, 1): + self.bundle() + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_refuses_existing_destination(self): + self.bundle() + self.destination.mkdir() + sentinel = self.destination / "trusted" + sentinel.write_text("untouched") + with self.assertRaises(FileExistsError): + self.stage() + self.assertEqual(sentinel.read_text(), "untouched") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a48528dbd53..0c10022472a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,9 @@ jobs: run: | sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test preview artifact validation + run: python3 -B .github/scripts/stage-preview-bundle.test.py + - name: Test workflow scripts run: node --test .github/scripts/*.test.cjs diff --git a/.gitignore b/.gitignore index 79f8735b25e5..8482c5a290e1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules *.log *.tsbuildinfo apps/*/dist +apps/*/dist-exe infra/*/dist .astro packages/*/dist diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 99d7f3cd22ba..8ed88559cfd8 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -2,7 +2,7 @@ title: Effect Service Conventions model: gpt-5-6-sol effort: medium -input: full_diff +input: incremental tools: - browse_code - modify_pr diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index d2e450235baa..87285d7b0881 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,8 +1,8 @@ --- title: UI Consistency -model: gpt-5-6-terra +model: gpt-5-6-sol effort: medium -input: full_diff +input: incremental tools: - browse_code - modify_pr diff --git a/AGENTS.md b/AGENTS.md index e3b5771d797c..ccf1fdc1d85c 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. -- 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). +- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. +- To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). - Stop what you started, by the PID you tracked. See rule 1. ## Test data diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index eedfff9744f8..48f66ab76b56 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -116,6 +116,8 @@ export function makeDevelopmentEnvironmentScript(environment) { ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH], ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL], ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS], + ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS], + ["T3CODE_OTLP_PROTOCOL", environment.T3CODE_OTLP_PROTOCOL], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); return [ diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 50f5e5f0ac84..84d08cd9b6b8 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -21,12 +21,17 @@ describe("electron development launcher", () => { VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", T3CODE_PORT: "16566", T3CODE_HOME: "/tmp/t3", + T3CODE_OTLP_PROTOCOL: "http/protobuf", }); assert.include( environmentScript, "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi", ); + assert.include( + environmentScript, + "if [ -z \"${T3CODE_OTLP_PROTOCOL:-}\" ]; then export T3CODE_OTLP_PROTOCOL='http/protobuf'; fi", + ); assert.notInclude(environmentScript, "\nexport VITE_DEV_SERVER_URL="); }); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e6abaab03251..365363881f5b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -158,18 +158,43 @@ export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances" ); const bootstrap = Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; const snapShot = yield* DesktopSnapShot.DesktopSnapShot; const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); + const settings = yield* desktopSettings.get; + // The renderer is served from the bundled client (or Vite in development) + // rather than through the local backend, so the window can open without one. + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + ...(environment.isDevelopment + ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) } + : { assetDirectory: environment.clientAssetsDir }), + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + yield* installDesktopIpcHandlers(); + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + + yield* snapShot.initialize; + + if (!settings.localEnvironmentEnabled) { + yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); + if (!(yield* Ref.get(state.quitting))) { + yield* desktopWindow.createMainIfBackendReady; + } + return; + } + + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); } @@ -186,7 +211,6 @@ const bootstrap = Effect.gen(function* () { }, ); - const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { mode: settings.serverExposureMode, @@ -194,16 +218,6 @@ const bootstrap = Effect.gen(function* () { } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - const rendererTarget = environment.isDevelopment - ? Option.getOrThrow(environment.devServerUrl) - : backendConfig.httpBaseUrl; - yield* electronProtocol.registerDesktopProtocol({ - scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), - targetOrigin: rendererTarget, - backendOrigin: backendConfig.httpBaseUrl, - clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, - }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, }); @@ -219,16 +233,13 @@ const bootstrap = Effect.gen(function* () { "bootstrap fell back to local-only because no advertised network host was available", ); } - yield* snapShot.initialize; - - yield* installDesktopIpcHandlers(); - yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - // In wsl-only mode the renderer is served by the WSL backend, which can be - // slow to cold-boot — show a "Connecting to WSL" splash immediately so the - // app feels responsive instead of presenting no window until WSL is ready. - // (Dual mode opens fast off the Windows primary, so no splash there.) + // The main window waits for the primary backend. In wsl-only mode that is + // the WSL backend, which can be slow to cold-boot — show a "Connecting to + // WSL" splash immediately so the app feels responsive instead of presenting + // no window until WSL is ready. (Dual mode opens fast off the Windows + // primary, so no splash there.) if (settings.wslOnly === true && settings.wslBackendEnabled === true) { yield* desktopWindow.showConnectingSplash; } diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index d157a4c6ba44..924b8ce6e72b 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -1,3 +1,4 @@ +import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Option from "effect/Option"; @@ -48,6 +49,10 @@ export const DesktopConfig = Config.all({ otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), + otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option), + otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( + Config.withDefault("http/json"), + ), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 262097ca78ea..0e5fbecd0224 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -52,6 +52,8 @@ describe("DesktopEnvironment", () => { T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", + T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", + T3CODE_OTLP_PROTOCOL: "http/protobuf", }, ); @@ -85,6 +87,14 @@ describe("DesktopEnvironment", () => { assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); assert.equal(environment.otlpExportIntervalMs, 2500); + assert.deepEqual( + environment.otlpHeaders, + Option.some({ + authorization: "Basic abc==", + "x-tenant": "t3", + }), + ); + assert.equal(environment.otlpProtocol, "http/protobuf"); }), ); @@ -102,6 +112,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + assert.equal(environment.otlpProtocol, "http/json"); }), ); @@ -120,6 +131,10 @@ describe("DesktopEnvironment", () => { environment.backendEntryPath, "/install/resources/server.asar/apps/server/dist/bin.mjs", ); + assert.equal( + environment.clientAssetsDir, + "/install/resources/server.asar/apps/server/dist/client", + ); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 9d7f00c3ee69..e7a489d5e89d 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -16,6 +16,7 @@ import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; +import type { OtlpProtocol } from "@t3tools/shared/observability"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; @@ -61,6 +62,8 @@ export class DesktopEnvironment extends Context.Service< // extracts on demand (see DesktopWslServerTree). readonly serverRoot: string; readonly backendEntryPath: string; + // Built web client the packaged renderer is served from over t3code://app. + readonly clientAssetsDir: string; readonly backendCwd: string; readonly preloadPath: string; readonly appUpdateYmlPath: string; @@ -70,6 +73,8 @@ export class DesktopEnvironment extends Context.Service< readonly commitHashOverride: Option.Option; readonly otlpTracesUrl: Option.Option; readonly otlpExportIntervalMs: number; + readonly otlpHeaders: Option.Option>; + readonly otlpProtocol: OtlpProtocol; readonly branding: DesktopAppBranding; readonly displayName: string; readonly appUserModelId: string; @@ -211,6 +216,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( appRoot, serverRoot, backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"), + clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"), backendCwd: input.isPackaged ? homeDirectory : appRoot, preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged @@ -222,6 +228,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( commitHashOverride: config.commitHashOverride, otlpTracesUrl: config.otlpTracesUrl, otlpExportIntervalMs: config.otlpExportIntervalMs, + otlpHeaders: config.otlpHeaders, + otlpProtocol: config.otlpProtocol, branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index d2ff0b4e2ad5..19ed351dffb7 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,5 +1,9 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { + makeLocalFileTracer, + makeTraceSink, + otlpSerializationLayer, +} from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -17,7 +21,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; -import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import { OtlpExporter, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -584,6 +588,7 @@ const tracerLayer = Layer.unwrap( : yield* OtlpTracer.make({ url: otlpTracesUrl.value, exportInterval: `${environment.otlpExportIntervalMs} millis`, + headers: Option.getOrUndefined(environment.otlpHeaders), resource: { serviceName: "desktop", attributes: { @@ -591,7 +596,7 @@ const tracerLayer = Layer.unwrap( "service.mode": environment.isDevelopment ? "development" : "packaged", }, }, - }); + }).pipe(Effect.provide(otlpSerializationLayer(environment.otlpProtocol))); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, @@ -603,7 +608,7 @@ const tracerLayer = Layer.unwrap( return Layer.succeed(Tracer.Tracer, tracer); }), -).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(OtlpSerialization.layerJson)); +).pipe(Layer.provide(OtlpExporter.layerFlusher)); export const layer = Layer.mergeAll( backendOutputLogFactoryLayer, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 1286bacbb698..a74d13bd4e1c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -381,9 +381,10 @@ describe("DesktopBackendConfiguration", () => { runtimeId: string; sha256: string; }> = []; - const observedNodePtyRoots: string[] = []; + const observedProbeRoots: string[] = []; let legacyCleanupCount = 0; const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64"; + const resolvedPath = "/home/test/.local/bin:/usr/bin:/bin"; return withPackagedWslHarness( { @@ -401,9 +402,14 @@ describe("DesktopBackendConfiguration", () => { }); return { ok: true, linuxAppRoot }; }, - ensureNodePty: (_distro, root) => { - observedNodePtyRoots.push(root); - return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + probeRuntime: (_distro, root) => { + observedProbeRoots.push(root); + return { ok: true, resolvedPath }; + }, + // The staged runtime carries its own Node and node-pty, so it must + // not require the mounted server tree's native dependency check. + ensureNodePty: () => { + throw new Error("the staged runtime must not probe for node-pty"); }, }), }, @@ -420,12 +426,22 @@ describe("DesktopBackendConfiguration", () => { sha256: archiveHash, }, ]); - assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]); + assert.deepEqual(observedProbeRoots, [linuxAppRoot]); assert.equal( config.entryPath, path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"), ); - assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`); + assert.deepEqual(config.args, [ + "-d", + "Ubuntu", + "--exec", + "env", + `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${resolvedPath}`, + `${linuxAppRoot}/t3`, + "--bootstrap-fd", + "0", + ]); + assert.notInclude(config.args, "/usr/bin/node"); assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`); assert.equal(legacyCleanupCount, 1); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -506,8 +522,11 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]); assert.equal(first.wslRuntimeId, observedRuntimeIds[0]); + assert.include(first.args, `/runtime/sha256-${firstHash}/t3`); assert.equal(second.wslRuntimeId, observedRuntimeIds[1]); + assert.include(second.args, `/runtime/sha256-${secondHash}/t3`); assert.isUndefined(invalidIdentity.wslRuntimeId); + assert.include(invalidIdentity.args, "/usr/bin/node"); assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); }), ); @@ -533,6 +552,7 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); assert.equal(config.entryPath, mountedEntryPath); + assert.include(config.args, "/usr/bin/node"); assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); assert.isUndefined(config.wslRuntimeId); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -540,9 +560,10 @@ describe("DesktopBackendConfiguration", () => { ); }); - it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => { + it.effect("resolveWsl retires a staged runtime whose executable does not start", () => { const archiveHash = "c".repeat(64); const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`; + const observedProbeRoots: string[] = []; const observedNodePtyRoots: string[] = []; const invalidatedRuntimeIds: string[] = []; return withPackagedWslHarness( @@ -554,11 +575,13 @@ describe("DesktopBackendConfiguration", () => { Effect.sync(() => { invalidatedRuntimeIds.push(runtimeId); }), + probeRuntime: (_distro, root) => { + observedProbeRoots.push(root); + return { ok: false, reason: `${root}/t3 --version failed (exit 127)` }; + }, ensureNodePty: (_distro, root) => { observedNodePtyRoots.push(root); - return root === stagedAppRoot - ? { ok: false, reason: "pty.node could not be loaded", fatal: true } - : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; }, }), }, @@ -567,8 +590,11 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]); + assert.deepEqual(observedProbeRoots, [stagedAppRoot]); + assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); + assert.include(config.args, "/usr/bin/node"); assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); + assert.notInclude(config.args, `${stagedAppRoot}/t3`); assert.equal(config.entryPath, mountedEntryPath); assert.isUndefined(config.wslRuntimeId); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -589,12 +615,13 @@ describe("DesktopBackendConfiguration", () => { Effect.sync(() => { invalidatedRuntimeIds.push(runtimeId); }), - ensureNodePty: (_distro, root) => ({ + probeRuntime: () => ({ ok: false, - reason: - root === stagedAppRoot - ? "unsupported CPU architecture or incompatible system libraries" - : "mounted tree is broken in some other way", + reason: "unsupported CPU architecture or incompatible system libraries", + }), + ensureNodePty: () => ({ + ok: false, + reason: "mounted tree is broken in some other way", fatal: true, }), }), @@ -624,51 +651,11 @@ describe("DesktopBackendConfiguration", () => { Effect.sync(() => { invalidatedRuntimeIds.push(runtimeId); }), - ensureNodePty: (_distro, root) => - root === stagedAppRoot - ? { ok: false, reason: "pty.node could not be loaded", fatal: true } - : { - ok: false, - reason: "WSL backend preflight timed out while probing for Node.js.", - fatal: false, - }, - }), - }, - () => - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isFalse(failure.fatal); - assert.equal(failure.retryLimit, 12); - assert.include(failure.reason, "timed out"); - assert.deepEqual(invalidatedRuntimeIds, []); - }), - ); - }); - - it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => { - const invalidatedRuntimeIds: string[] = []; - return withPackagedWslHarness( - { - archiveHash: "e".repeat(64), - forbidFallback: "A transient probe failure must not extract the fallback", - forbidCleanup: "A transient probe failure must not clean the fallback tree", - wsl: () => ({ - prepareRuntime: () => ({ - ok: true, - linuxAppRoot: "/home/test/.t3/wsl-runtime/cache", - }), - invalidateRuntime: (_distro, runtimeId) => - Effect.sync(() => { - invalidatedRuntimeIds.push(runtimeId); - }), + probeRuntime: () => ({ ok: false, reason: "t3 --version failed (exit 1)" }), ensureNodePty: () => ({ ok: false, reason: "WSL backend preflight timed out while probing for Node.js.", fatal: false, - retryLimit: 12, }), }), }, @@ -915,10 +902,14 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousOpenAiKey = process.env.OPENAI_API_KEY; const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; + const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; + const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; try { process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; + process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"'; + process.env.T3CODE_OTLP_PROTOCOL = "http/protobuf"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -938,13 +929,14 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); + assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); // The existing WSLENV is preserved byte-for-byte (note the empty // "::" segment survives — WSL ignores it, so we don't normalize // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is // already declared, so it isn't forwarded twice. assert.equal( config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY", + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", ); }).pipe( Effect.provide( @@ -967,6 +959,8 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OPENAI_API_KEY", previousOpenAiKey); restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); + restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); + restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index c95b0c9826fc..27948a0ce7be 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -87,14 +87,24 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; -// Sensitive env vars that the WSL backend needs but Windows process.env won't -// forward across the wsl.exe boundary without WSLENV. The dev-server URL is -// handled separately via a `--dev-url` CLI flag because WSLENV translation of +// Env vars that the WSL backend needs but Windows process.env won't forward +// across the wsl.exe boundary without WSLENV. The dev-server URL is handled +// separately via a `--dev-url` CLI flag because WSLENV translation of // URL-shaped values (colons / slashes) is unreliable. -const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; +const WSL_FORWARDED_ENV_NAMES = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "T3CODE_OTLP_HEADERS", + "T3CODE_OTLP_PROTOCOL", +] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const nodeBinDirOf = (nodePath: string): string => { + const lastSlash = nodePath.lastIndexOf("/"); + return lastSlash > 0 ? nodePath.slice(0, lastSlash) : "/usr/bin"; +}; + const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); @@ -210,18 +220,31 @@ interface SharedBootstrapInput { readonly observabilitySettings: BackendObservabilitySettings; } +// What the launch runs inside the distro. The staged runtime is the release's +// self-contained `t3` executable (Node inside); the mounted server tree is a +// script that needs the distro's own Node. +type WslPreflightRuntime = + | { + readonly kind: "executable"; + readonly entryPath: string; + } + | { + readonly kind: "node-script"; + // Absolute path to the node binary the preflight validated after the + // shared remote resolver repaired PATH. The launch must use this exact + // path so it doesn't fall through to a different/old node than the one + // node-pty was probed with. + readonly nodePath: string; + readonly linuxEntryPath: string; + }; + interface WslPreflightSuccess { readonly _tag: "Ready"; readonly runningDistro: string; readonly windowsEntryPath: string; - readonly linuxEntryPath: string; - // Absolute path to the node binary the preflight validated after the shared - // remote resolver repaired PATH. The launch must use this exact path so it - // doesn't fall through to a different/old node than the one node-pty was - // built against. - readonly nodePath: string; - // PATH captured from the same login shell after the shared resolver loaded - // version managers. The launch forwards this value directly without a shell. + readonly runtime: WslPreflightRuntime; + // PATH captured from the user's login shell. The launch forwards this value + // directly without a shell so the server can spawn provider CLIs by name. readonly resolvedPath: string; // Identifies the distro-local runtime cache selected from the packaged archive. readonly runtimeId?: string; @@ -355,39 +378,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f // fatal verdict the cached reason is the more actionable one to report. // A transient mounted failure is neither — it rules nothing out, so it stays // retryable and the staged verdict waits for an attempt that can answer. - let stagedFailure: - | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult } - | undefined; + let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined; + const failedStaged = (failure: { readonly reason: string }) => + ({ + _tag: "Failed", + reason: `WSL runtime unavailable: ${failure.reason}`, + fatal: true, + }) as const; if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - const stagedNodePty = yield* wslEnv.ensureNodePty( - runningDistro, - runtime.linuxAppRoot, - nodePtyOptions, - ); - if (stagedNodePty.ok) { + // The staged runtime supplies its own Node and node-pty. Provider PATH + // discovery must not require either dependency for runtime readiness. + const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); + if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; return { _tag: "Ready", runningDistro, windowsEntryPath: environment.backendEntryPath, - linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`, - nodePath: stagedNodePty.nodePath, - resolvedPath: stagedNodePty.resolvedPath, + runtime: { kind: "executable", entryPath: `${runtime.linuxAppRoot}/t3` }, + resolvedPath: stagedProbe.resolvedPath, runtimeId: input.runtimeArchive.runtimeId, } as const; } - // A transport failure says nothing about the staged tree, so it is - // retried against the same cache rather than spending a second probe on - // the mounted tree and risking a needless reinstall. - if (!stagedNodePty.fatal) return failedNodePty(stagedNodePty); yield* Effect.logWarning( - "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.", - { reason: stagedNodePty.reason }, + "The staged WSL runtime did not start; retrying from the mounted server tree.", + { reason: stagedProbe.reason }, ); - stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty }; + stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason }; } else { yield* Effect.logWarning( "Could not stage the WSL runtime; launching from the mounted server tree instead.", @@ -399,7 +419,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f const mounted = yield* resolveMountedAppRoot; if (!mounted.ok) { return stagedFailure && mounted.fatal - ? failedNodePty(stagedFailure.nodePty) + ? failedStaged(stagedFailure) : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const); } @@ -413,9 +433,9 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f // turn a retryable failure into a fatal one, ending the WSL attempt (and, // in wsl-only mode, persisting Windows) before the slow /mnt path had a // chance to answer and clear the bad cache. - return failedNodePty( - stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult, - ); + return stagedFailure && nodePtyResult.fatal + ? failedStaged(stagedFailure) + : failedNodePty(nodePtyResult); } // The mounted tree runs what the cache could not, so the cache is the broken @@ -429,8 +449,11 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f _tag: "Ready", runningDistro, windowsEntryPath: mounted.windowsEntryPath, - linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, - nodePath: nodePtyResult.nodePath, + runtime: { + kind: "node-script", + nodePath: nodePtyResult.nodePath, + linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, + }, resolvedPath: nodePtyResult.resolvedPath, } as const; }); @@ -611,13 +634,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl runtimeId: `sha256-${archiveHash}`, sha256: archiveHash, }, - // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and - // attached to the Windows artifact — see build-desktop-artifact.ts), so the - // WSL backend never needs a compiler, node-gyp, or network on first launch. - // Compiling from source is a dev-only convenience: a checkout has no shipped - // prebuilt, and developers have the toolchain. In packaged builds we instead - // surface a clear diagnostic if the prebuilt can't load (unsupported - // arch/distro), rather than silently dropping into a fragile runtime build. + // Packaged builds run the self-contained Linux runtime and, on fallback, + // whatever Linux node-pty the mounted tree carries, so the WSL backend never + // needs a compiler, node-gyp, or network on first launch. Compiling from + // source is a dev-only convenience: a checkout has no Linux binary, and + // developers have the toolchain. In packaged builds we instead surface a + // clear diagnostic if the binary can't load (unsupported arch/distro), + // rather than silently dropping into a fragile runtime build. allowBuild: !environment.isPackaged, }); @@ -716,15 +739,23 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // The WSL server spawns commands its providers reference by name — `npm`/`npx` // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those - // live in the resolved Node's bin dir, which `wsl.exe -- node` does NOT put on + // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on // the process PATH, so `npm install -g ...` fails with NotFound. Pass the - // user PATH entries captured by the login-shell preflight. Every dynamic - // value is a separate argv entry under `wsl.exe --exec`; no shell command is - // involved, so Windows cannot mangle nested quotes and stdin remains reserved - // for the bootstrap envelope. - const lastSlash = preflight.nodePath.lastIndexOf("/"); - const nodeBinDir = lastSlash > 0 ? preflight.nodePath.slice(0, lastSlash) : "/usr/bin"; - const launchPath = `${nodeBinDir}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; + // user PATH entries captured by the preflight. Every dynamic value is a + // separate argv entry under `wsl.exe --exec`; no shell command is involved, + // so Windows cannot mangle nested quotes and stdin remains reserved for the + // bootstrap envelope. A node-script runtime additionally leads with the + // probed Node's bin dir so the server cannot pick up a different node than + // the one node-pty was probed with. + const runtime = preflight.runtime; + const launchPath = + runtime.kind === "executable" + ? `${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}` + : `${nodeBinDirOf(runtime.nodePath)}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; + const command = + runtime.kind === "executable" + ? [runtime.entryPath] + : [runtime.nodePath, runtime.linuxEntryPath]; return { ...baseConfig, @@ -733,8 +764,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl "--exec", "env", `PATH=${launchPath}`, - preflight.nodePath, - preflight.linuxEntryPath, + ...command, "--bootstrap-fd", "0", ...devUrlArgs, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index eb0becee0981..0914167cffb0 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -257,6 +257,7 @@ describe("DesktopServerExposure", () => { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 0d204fb3ad42..508a5c296898 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,6 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ @@ -16,6 +19,8 @@ vi.mock("electron", () => ({ import * as ElectronProtocol from "./ElectronProtocol.ts"; +const protocolLayer = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer)); + describe("ElectronProtocol", () => { beforeEach(() => { handleMock.mockReset(); @@ -23,6 +28,46 @@ describe("ElectronProtocol", () => { unhandleMock.mockReset(); }); + it.effect("serves the bundled client from disk without a backend", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + yield* fileSystem.writeFileString(`${directory}/index.html`, "app"); + yield* fileSystem.writeFileString(`${directory}/app.js`, "export default 1;"); + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + assetDirectory: directory, + clerkFrontendApiHostname: undefined, + }); + const request = (pathname: string, init?: RequestInit) => + Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init))); + + // SPA routes fall back to index.html, including ones containing dots. + const page = yield* request("/settings/connections"); + assert.equal(yield* Effect.promise(() => page.text()), "app"); + assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'"); + const dottedRoute = yield* request("/environment/thread.with.dots", { + headers: { accept: "text/html" }, + }); + assert.equal(yield* Effect.promise(() => dottedRoute.text()), "app"); + + const script = yield* request("/app.js?v=1"); + assert.equal(yield* Effect.promise(() => script.text()), "export default 1;"); + assert.include(script.headers.get("content-type") ?? "", "javascript"); + + assert.equal((yield* request("/missing.js")).status, 404); + assert.equal((yield* request("/%2e%2e%2fsecret.txt")).status, 404); + assert.equal((yield* request("/%invalid")).status, 400); + assert.equal((yield* request("/", { method: "POST" })).status, 405); + assert.equal(netFetchMock.mock.calls.length, 0); + }).pipe(Effect.provide(Layer.merge(protocolLayer, NodeServices.layer)), Effect.scoped), + ); + it.effect("proxies the stable renderer origin to the current app server", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; @@ -37,7 +82,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: "clerk.t3.codes", }); assert.isDefined(handler); @@ -85,7 +129,7 @@ describe("ElectronProtocol", () => { assert.isNull(forwardedHeaders.get("referer")); assert.isNull(forwardedHeaders.get("sec-fetch-site")); assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("rejects custom protocol requests for another host", () => @@ -101,7 +145,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); @@ -110,7 +153,7 @@ describe("ElectronProtocol", () => { assert.equal(response.status, 404); assert.equal(netFetchMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("retries transient renderer target failures", () => @@ -129,7 +172,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:5733/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/"))); @@ -138,7 +180,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ready"); assert.equal(netFetchMock.mock.calls.length, 2); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol registration failures", () => @@ -153,7 +195,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, }), ).pipe(Effect.flip); @@ -162,7 +203,7 @@ describe("ElectronProtocol", () => { assert.equal(error.scheme, "t3code-dev"); assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol unregistration failures", () => @@ -178,7 +219,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }), ), @@ -192,14 +232,13 @@ describe("ElectronProtocol", () => { assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); } - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: "clerk.t3.codes", }); const directives = Object.fromEntries( diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index af0366f93f47..ed35bbc7952f 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,7 +1,10 @@ +import Mime from "@effect/platform-node/Mime"; 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 NodeTimersPromises from "node:timers/promises"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -48,12 +51,12 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError decodeURIComponent(url.pathname)).pipe( + Effect.orElseSucceed(() => null), + ); + if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 }); + const root = path.resolve(assetDirectory); + const assetPath = path.resolve(root, `.${pathname}`); + if (assetPath !== root && !assetPath.startsWith(root + path.sep)) { + return new Response(null, { status: 404 }); + } + const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null)); + let filePath = assetPath; + if (stat?.type !== "File") { + const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false; + if (path.extname(assetPath) !== "" && !wantsHtml) { + return new Response(null, { status: 404 }); + } + filePath = path.join(root, "index.html"); + } + const contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); + if (contents === null) return new Response(null, { status: 404 }); + return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), { + headers: { "content-type": Mime.getType(filePath) ?? "application/octet-stream" }, + }); +}); + async function fetchWithTransientRetry(url: string, init: RequestInit): Promise { let lastError: unknown; @@ -210,6 +252,8 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( function* (input: DesktopProtocolRegistrationInput) { @@ -220,9 +264,15 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ try: () => { - Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), - ); + Electron.protocol.handle(input.scheme, async (request) => { + if ("assetDirectory" in input) { + return withContentSecurityPolicy( + await runPromise(serveDesktopAsset(request, input.assetDirectory)), + contentSecurityPolicy, + ); + } + return proxyRequest(request, input.targetOrigin, contentSecurityPolicy); + }); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), }).pipe(Effect.andThen(Ref.set(registered, true))), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c1eba805c2b6..c97c602552f4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,12 +1,17 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; +import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { clearConnectionCatalog, getConnectionCatalog, setConnectionCatalog, } from "./methods/connectionCatalog.ts"; +import { + getLocalEnvironmentEnabled, + setLocalEnvironmentEnabled, +} from "./methods/localEnvironment.ts"; import { getAdvertisedEndpoints, getServerExposureState, @@ -68,6 +73,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; + yield* installNotificationBadge(); yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handle(AppActivationIpc.setReady); @@ -77,6 +83,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); + yield* ipc.handleSync(getLocalEnvironmentEnabled); + yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index ca6bbd30b3e4..226793657848 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; @@ -24,6 +25,8 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; 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"; +export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; +export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts new file mode 100644 index 000000000000..e17c48948098 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -0,0 +1,63 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; +import * as DesktopState from "../../app/DesktopState.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; +import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; + +// `relaunch` declares the lifecycle runtime services as requirements even +// though the mocked relaunch never touches them. +const unusedLifecycleRuntimeLayer = Layer.mergeAll( + DesktopShutdown.layer, + DesktopState.layer, + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of( + {} as DesktopEnvironment.DesktopEnvironment["Service"], + ), + ), + Layer.mock(DesktopWindow.DesktopWindow, {}), + Layer.mock(ElectronApp.ElectronApp, {}), + Layer.mock(ElectronTheme.ElectronTheme, {}), +); + +describe("local environment IPC", () => { + it.effect("relaunches only when the setting changes and keeps other settings", () => { + const relaunchReasons: Array = []; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + }), + Layer.mock(DesktopLifecycle.DesktopLifecycle, { + relaunch: (reason) => + Effect.sync(() => { + relaunchReasons.push(reason); + }), + }), + unusedLifecycleRuntimeLayer, + ); + return Effect.gen(function* () { + yield* setLocalEnvironmentEnabled.handler(false); + assert.isFalse(yield* getLocalEnvironmentEnabled.handler()); + yield* setLocalEnvironmentEnabled.handler(false); + assert.deepEqual(relaunchReasons, ["localEnvironmentEnabled=false"]); + + yield* setLocalEnvironmentEnabled.handler(true); + assert.isTrue(yield* getLocalEnvironmentEnabled.handler()); + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + assert.isTrue((yield* appSettings.get).wslBackendEnabled); + assert.deepEqual(relaunchReasons, [ + "localEnvironmentEnabled=false", + "localEnvironmentEnabled=true", + ]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/localEnvironment.ts b/apps/desktop/src/ipc/methods/localEnvironment.ts new file mode 100644 index 000000000000..74cccd04a0c5 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEnvironment.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; + +export const getLocalEnvironmentEnabled = makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.localEnvironment.getEnabled")(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + return (yield* appSettings.get).localEnvironmentEnabled; + }), +}); + +export const setLocalEnvironmentEnabled = makeIpcMethod({ + channel: IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.localEnvironment.setEnabled")(function* (enabled) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const change = yield* appSettings.setLocalEnvironmentEnabled(enabled); + if (change.changed) { + yield* lifecycle.relaunch(`localEnvironmentEnabled=${enabled}`); + } + }), +}); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts new file mode 100644 index 000000000000..a47732550f64 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -0,0 +1,144 @@ +import * as Effect from "effect/Effect"; +import { beforeEach, expect, vi } from "vite-plus/test"; +import { it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +const native = vi.hoisted(() => ({ + setBadgeCount: vi.fn(), + setOverlayIcon: vi.fn(), + isDestroyed: vi.fn(() => false), + getFocusedWindow: vi.fn(() => null as object | null), + image: { isEmpty: vi.fn(() => false) }, + createFromDataURL: vi.fn(), + webContents: { send: vi.fn() }, + listeners: new Map void>(), +})); +vi.mock("electron", () => ({ + app: { + setBadgeCount: native.setBadgeCount, + on: (event: string, listener: () => void) => native.listeners.set(event, listener), + removeListener: (event: string) => native.listeners.delete(event), + }, + BrowserWindow: { + getFocusedWindow: native.getFocusedWindow, + getAllWindows: () => [native], + }, + nativeImage: { createFromDataURL: native.createFromDataURL }, +})); + +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts"; + +const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" }; + +beforeEach(() => { + vi.clearAllMocks(); + native.getFocusedWindow.mockReturnValue(null); + native.isDestroyed.mockReturnValue(false); + native.image.isEmpty.mockReturnValue(false); + native.createFromDataURL.mockReturnValue(native.image); + native.setBadgeCount.mockImplementation(() => true); + native.listeners.clear(); +}); + +it.each(["darwin", "linux"] as const)("sets and clears the native %s count", (platform) => { + applyNotificationBadge(platform, badge); + applyNotificationBadge(platform, { count: 0, image: null }); + expect(native.setBadgeCount.mock.calls).toEqual([[2], [0]]); + expect(native.createFromDataURL).not.toHaveBeenCalled(); +}); + +it("sets and clears the Windows taskbar overlay", () => { + applyNotificationBadge("win32", badge); + expect(native.setOverlayIcon).toHaveBeenLastCalledWith( + native.image, + "2 threads with new notifications", + ); + applyNotificationBadge("win32", { count: 0, image: null }); + expect(native.setOverlayIcon).toHaveBeenLastCalledWith(null, ""); +}); + +it.each(["win32", "darwin", "linux"] as const)( + "rejects a late positive count while %s is focused", + (platform) => { + native.getFocusedWindow.mockReturnValue({}); + applyNotificationBadge(platform, badge); + if (platform === "win32") expect(native.setOverlayIcon).toHaveBeenCalledWith(null, ""); + else expect(native.setBadgeCount).toHaveBeenCalledWith(0); + expect(native.createFromDataURL).not.toHaveBeenCalled(); + }, +); + +it("ignores destroyed windows and clears invalid images", () => { + native.isDestroyed.mockReturnValue(true); + applyNotificationBadge("win32", badge); + expect(native.setOverlayIcon).not.toHaveBeenCalled(); + native.isDestroyed.mockReturnValue(false); + native.image.isEmpty.mockReturnValue(true); + applyNotificationBadge("win32", badge); + expect(native.setOverlayIcon.mock.calls[0]?.[0]).toBeNull(); +}); + +it("keeps notifications working when the native badge API fails", () => { + native.setBadgeCount.mockImplementation(() => { + throw new Error("Unavailable"); + }); + expect(() => applyNotificationBadge("linux", badge)).not.toThrow(); +}); + +it.effect("validates IPC and clears on native focus, quit, and disposal", () => + Effect.gen(function* () { + const handlers = new Map(); + yield* Effect.scoped( + Effect.gen(function* () { + yield* installNotificationBadge(); + const handler = handlers.get("desktop:set-notification-badge")!; + const event = { sender: { id: 1 } }; + for (const invalid of [ + { ...badge, count: -1 }, + { ...badge, count: 0.5 }, + { ...badge, count: Infinity }, + { ...badge, image: "https://example.com/icon.png" }, + { ...badge, image: `data:image/png;base64,${"a".repeat(16_384)}` }, + ]) { + yield* Effect.promise(() => expect(handler(event, invalid)).rejects.toBeDefined()); + } + expect(native.setBadgeCount).not.toHaveBeenCalled(); + yield* Effect.promise(() => Promise.resolve(handler(event, badge))); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(2); + native.listeners.get("browser-window-focus")!(); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + expect(native.webContents.send).toHaveBeenCalledWith("desktop:set-notification-badge"); + native.getFocusedWindow.mockReturnValue({}); + yield* Effect.promise(() => Promise.resolve(handler(event, badge))); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + expect(native.webContents.send).toHaveBeenCalledTimes(2); + yield* Effect.promise(() => Promise.resolve(handler(event, { count: 0, image: null }))); + expect(native.webContents.send).toHaveBeenCalledTimes(2); + native.getFocusedWindow.mockReturnValue(null); + yield* Effect.promise(() => Promise.resolve(handler(event, badge))); + native.listeners.get("before-quit")!(); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + }), + ).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide([ + ElectronApp.layer, + DesktopIpc.layer({ + handle: (channel, handler) => { + handlers.set(channel, handler); + }, + removeHandler: (channel) => { + handlers.delete(channel); + }, + on: vi.fn(), + removeAllListeners: vi.fn(), + }), + ]), + ); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + expect(native.listeners.size).toBe(0); + expect(handlers.size).toBe(0); + }), +); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.ts b/apps/desktop/src/ipc/methods/notificationBadge.ts new file mode 100644 index 000000000000..40e4549138f2 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notificationBadge.ts @@ -0,0 +1,71 @@ +import * as Electron from "electron"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import { SET_NOTIFICATION_BADGE_CHANNEL } from "../channels.ts"; + +const NotificationBadge = Schema.Struct({ + count: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 2_147_483_647 })), + image: Schema.NullOr( + Schema.String.check( + Schema.isMaxLength(16_384), + Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i), + ), + ), +}); + +export function applyNotificationBadge( + platform: NodeJS.Platform, + { count, image }: typeof NotificationBadge.Type, +): void { + try { + if (Electron.BrowserWindow.getFocusedWindow()) count = 0; + if (platform === "win32") { + const overlay = count > 0 && image ? Electron.nativeImage.createFromDataURL(image) : null; + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.setOverlayIcon( + overlay?.isEmpty() ? null : overlay, + count > 0 ? `${count} threads with new notifications` : "", + ); + } + } + } else if (platform === "darwin" || platform === "linux") { + Electron.app.setBadgeCount(count); + } + } catch (error) { + Effect.runSync(Effect.logWarning("Could not update notification badge", error)); + } +} + +export const installNotificationBadge = Effect.fn("desktop.ipc.installNotificationBadge")( + function* () { + const ipc = yield* DesktopIpc.DesktopIpc; + const app = yield* ElectronApp.ElectronApp; + const platform = yield* HostProcessPlatform; + const clear = () => { + applyNotificationBadge(platform, { count: 0, image: null }); + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send(SET_NOTIFICATION_BADGE_CHANNEL); + } + }; + yield* ipc.handle( + DesktopIpc.makeIpcMethod({ + channel: SET_NOTIFICATION_BADGE_CHANNEL, + payload: NotificationBadge, + result: Schema.Void, + handler: (badge) => + Effect.sync(() => { + if (badge.count > 0 && Electron.BrowserWindow.getFocusedWindow()) clear(); + else applyNotificationBadge(platform, badge); + }), + }), + ); + yield* app.on("browser-window-focus", clear); + yield* app.on("before-quit", clear); + yield* Effect.addFinalizer(() => Effect.sync(clear)); + }, +); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 6fcf5e813749..eca3db4ddf85 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -19,6 +19,8 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, @@ -208,19 +210,21 @@ describe("pasteAsText", () => { }); describe("pickProjectFavicon", () => { + const pickerLayer = (pickFiles: () => Effect.Effect>, settings?: DesktopSettings) => + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + DesktopAppSettings.layerTest(settings), + ); + it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); - const result = yield* pickProjectFavicon.handler("/project").pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), - Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), - }), - ), - ), - ); + const result = yield* pickProjectFavicon + .handler("/project") + .pipe(Effect.provide(pickerLayer(pickFiles))); assert.strictEqual(result, "/pictures/icon.png"); assert.deepEqual(pickFiles.mock.calls, [ @@ -240,4 +244,21 @@ describe("pickProjectFavicon", () => { ]); }), ); + + it.effect("does not open a picker while the local environment is off", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + pickerLayer(pickFiles, { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }), + ), + ); + + assert.strictEqual(result, null); + assert.strictEqual(pickFiles.mock.calls.length, 0); + }), + ); }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 284b62ad31ac..81361db37303 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -182,6 +182,11 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const settings = yield* appSettings.get; + // A picked path only means something to a backend on this machine. + if (!settings.localEnvironmentEnabled) { + return null; + } // Three picker modes: // - targetEnvironmentId omitted: default to the primary picker. Keeps // the historical behavior unchanged for users who never enabled the @@ -200,7 +205,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ targetId !== undefined && targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); - const settings = yield* appSettings.get; // Fall back to the persisted wslDistro when the id is the // "wsl:default" sentinel; the orchestrator uses the same fallback // for the actual backend. @@ -246,6 +250,10 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) { const dialog = yield* ElectronDialog.ElectronDialog; const electronWindow = yield* ElectronWindow.ElectronWindow; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + if (!(yield* appSettings.get).localEnvironmentEnabled) { + return null; + } const paths = yield* dialog.pickFiles({ owner: yield* electronWindow.focusedMainOrFirst, defaultPath: Option.fromNullishOr(initialPath), diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 38435e286fa7..bfd1a6e679d9 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -18,7 +18,7 @@ import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts" import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; -import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; +import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); @@ -85,6 +85,42 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ); describe("WSL IPC", () => { + it.effect("does not probe WSL when local execution is disabled", () => + Effect.gen(function* () { + const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const state = yield* getWslState.handler(undefined).pipe( + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { + ...wsl, + isAvailable: Effect.die("must not probe WSL"), + listDistros: Effect.die("must not enumerate distros"), + }), + Effect.flatMap(decodeWslState), + ); + assert.deepEqual(state, { + enabled: true, + distro: "Ubuntu", + available: false, + wslOnly: true, + distros: [], + preflightError: null, + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: true, + }), + DesktopWslEnvironment.layerTest(), + makeWslBackendLayer(), + ), + ), + ), + ); + it.effect("stages dual-backend preferences before enabling without relaunching", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 1d0dc262baea..37cd992bb641 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -21,7 +21,7 @@ const readWslState: Effect.Effect< const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; - const available = yield* wslEnvironment.isAvailable; + const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); // Only enumerate distros when WSL is actually available — listDistros on a // non-WSL host would spawn wsl.exe and hit the timeout for nothing. const distros = available ? yield* wslEnvironment.listDistros : []; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 939b88c7d0d0..0d626a51955d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,7 +17,6 @@ import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -86,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap( }), ); +// The remote runs the exact release this app is on, from its self-contained +// archive, so it needs neither Node nor npm. Development points the remote at +// a source checkout instead so the two sides can be iterated together. const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], - settings: DesktopAppSettings.DesktopSettings, ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { @@ -97,24 +98,14 @@ const resolveDesktopSshCliRunner = ( nodeEngineRange: serverPackageJson.engines.node, }; } - return { - packageSpec: resolveRemoteT3CliPackageSpec({ - appVersion: environment.appVersion, - updateChannel: settings.updateChannel, - isDevelopment: environment.isDevelopment, - }), - nodeEngineRange: serverPackageJson.engines.node, - }; + return { archiveVersion: environment.appVersion }; }; const desktopSshEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settings = yield* DesktopAppSettings.DesktopAppSettings; return DesktopSshEnvironment.layer({ - resolveCliRunner: settings.get.pipe( - Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), - ), + resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), }); }), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d4edb7818180..453879d37afe 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -57,6 +57,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { return result as ReturnType; }, getClientPlatform: () => clientPlatform, + setNotificationBadge: (badge) => + ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), + onNotificationBadgeClear: (listener) => { + const handler = () => listener(); + ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); + return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); + }, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; @@ -70,6 +77,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), + getLocalEnvironmentEnabled: () => + ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, + setLocalEnvironmentEnabled: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..f7db3c277810 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -91,6 +91,24 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect( + "persists disabling and re-enabling local execution without clearing backend settings", + () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setWslBackendEnabled(true); + yield* settings.setWslDistro("Ubuntu"); + yield* settings.setServerExposureMode("network-accessible"); + const before = yield* settings.get; + assert.isTrue((yield* settings.setLocalEnvironmentEnabled(false)).changed); + assert.deepEqual(yield* settings.load, { ...before, localEnvironmentEnabled: false }); + assert.isFalse((yield* settings.setLocalEnvironmentEnabled(false)).changed); + yield* settings.setLocalEnvironmentEnabled(true); + assert.deepEqual(yield* settings.load, before); + }), + ), + ); it.effect("loads defaults when no settings file exists", () => withSettings( Effect.gen(function* () { @@ -106,6 +124,7 @@ describe("DesktopSettings", () => { DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -135,6 +154,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "gnome-libsecret", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -242,6 +262,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -298,6 +319,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -346,6 +368,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -374,6 +397,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -401,6 +425,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 3bd235018022..19fcf0e75962 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -25,6 +25,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly localEnvironmentEnabled: boolean; readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; @@ -73,6 +74,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + localEnvironmentEnabled: true, linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, @@ -94,6 +96,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), @@ -152,6 +155,9 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setLocalEnvironmentEnabled: ( + enabled: boolean, + ) => Effect.Effect; readonly setMainWindowBounds: ( bounds: DesktopWindowBounds, isMaximized: boolean, @@ -224,6 +230,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false, linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, @@ -247,6 +254,10 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.localEnvironmentEnabled !== defaults.localEnvironmentEnabled) { + document.localEnvironmentEnabled = settings.localEnvironmentEnabled; + } + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { document.linuxPasswordStore = settings.linuxPasswordStore; } @@ -370,6 +381,12 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting }; } +function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.localEnvironmentEnabled === enabled + ? settings + : { ...settings, localEnvironmentEnabled: enabled }; +} + function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } @@ -545,6 +562,10 @@ export const make = Effect.gen(function* () { persist((settings) => setWslOnly(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), ), + setLocalEnvironmentEnabled: (enabled) => + persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -586,6 +607,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setWslBackendEnabled(settings, enabled)), setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), + setLocalEnvironmentEnabled: (enabled) => + update((settings) => setLocalEnvironmentEnabled(settings, enabled)), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 5c1e01cb1471..53044dcf7e5d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { ...DEFAULT_CLIENT_SETTINGS, notificationMode: "notifications-and-sound", + inAppNotificationsEnabled: true, appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 3b135eb2508f..b0094f6867bc 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service< >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} export interface DesktopSshEnvironmentLayerOptions { - readonly resolveCliPackageSpec?: () => string; readonly resolveCliRunner?: Effect.Effect; } @@ -168,13 +167,10 @@ export const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshTunnel.SshEnvironmentManager.layer({ - ...(options.resolveCliPackageSpec === undefined + SshTunnel.SshEnvironmentManager.layer( + options.resolveCliRunner === undefined ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }), + : { resolveCliRunner: options.resolveCliRunner }, + ), ), ); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 51584e3c796d..c35b52e8343d 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -711,6 +711,7 @@ export const make = Effect.gen(function* () { const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( info.releaseNotes, info.version, + state.channel, ); yield* setState( reduceDesktopUpdateStateOnUpdateAvailable( diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 3ba2444dc185..57d185c0975e 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -21,6 +21,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { "**Full Changelog**: https://github.com/pingdotgg/t3code/compare/old...new", ].join("\n"), "0.0.36-nightly.20260828.1213", + "nightly", ); expect(result).toEqual({ @@ -50,6 +51,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { "

New Contributors

  • @human made their first contribution
" + "

Full Changelog

", "1.2.3", + "latest", ); expect(result).toEqual({ @@ -69,6 +71,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }, ], "1.2.4", + "latest", ); expect(result.releaseNotes).toEqual([ @@ -85,6 +88,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { { version: "1.2.1", note: "- Older release" }, ], "1.2.3", + "latest", ); expect(result).toEqual({ @@ -96,6 +100,42 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); + it("drops releases from other trains before grouping on nightly", () => { + // electron-updater's full changelog is "every version above the running + // one", and preview sorts above nightly, so the preview cuts come first. + const releaseNotes = [ + { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, + { version: "0.0.41-preview.20260913.1669", note: "- Maintainer test build" }, + { version: "0.0.41-nightly.20260914.1707", note: "- Nightly change 2" }, + { version: "0.0.41-nightly.20260914.1700", note: "- Nightly change 1" }, + ]; + + const result = normalizeDesktopUpdateReleaseNotes( + releaseNotes, + "0.0.41-nightly.20260914.1707", + "nightly", + ); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual([ + "0.0.41-nightly.20260914.1707", + "0.0.41-nightly.20260914.1700", + ]); + expect(result.omittedReleaseCount).toBe(0); + }); + + it("keeps only stable releases on the latest channel", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "0.0.42", note: "- Stable change" }, + { version: "0.0.42-nightly.20260915.1710", note: "- Nightly change" }, + ], + "0.0.42", + "latest", + ); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual(["0.0.42"]); + }); + it("counts valid groups before applying the six-release limit", () => { const releaseNotes = [ { version: "1.3.9", note: "- Change 9" }, @@ -108,7 +148,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { { version: "1.3.2", note: "- Change 2" }, ]; - const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9"); + const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9", "latest"); expect(result.releaseNotes.map(({ version }) => version)).toEqual([ "1.3.9", @@ -122,7 +162,11 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("decodes valid HTML entities", () => { - const result = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + const result = normalizeDesktopUpdateReleaseNotes( + "- Fix & polish 😀", + "1.0.0", + "latest", + ); expect(result).toEqual({ releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], omittedReleaseCount: 0, @@ -140,6 +184,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { null, ], "1.2.3", + "latest", ); expect(result).toEqual({ @@ -149,14 +194,18 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("returns an empty result for an invalid payload", () => { - expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({ + expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0", "latest")).toEqual({ releaseNotes: [], omittedReleaseCount: 0, }); }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - const result = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + const result = normalizeDesktopUpdateReleaseNotes( + "- Broken entity �", + "1.0.0", + "latest", + ); expect(result).toEqual({ releaseNotes: [{ version: "1.0.0", items: ["Broken entity �"], totalItems: 1 }], omittedReleaseCount: 0, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 3b2f32e646a1..3cab5f15e451 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,4 +1,6 @@ -import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; +import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -122,16 +124,27 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } +/** + * Turns electron-updater's release notes into the groups the popover shows. + * With `fullChangelog` on (nightly), electron-updater collects every GitHub + * release whose version is semver-greater than the running one, whatever + * train it belongs to; a maintainers' `-preview.` cut sorts above every + * `-nightly.` of the same base version and would lead the list. Only + * releases on the channel being followed are kept, the same test the + * updater applies to the offered version itself. + */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, + channel: DesktopUpdateChannel, ): NormalizedDesktopUpdateReleaseNotes { - const rawNotes = + const rawNotes = ( typeof releaseNotes === "string" ? [{ version: fallbackVersion, note: releaseNotes }] : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) - : []; + : [] + ).filter((entry) => resolveDefaultDesktopUpdateChannel(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { const { items, totalItems } = extractReleaseNoteItems(entry.note); diff --git a/apps/desktop/src/updates/updateChannels.test.ts b/apps/desktop/src/updates/updateChannels.test.ts new file mode 100644 index 000000000000..acf4155fab8e --- /dev/null +++ b/apps/desktop/src/updates/updateChannels.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isNightlyDesktopVersion, resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; + +describe("updateChannels", () => { + it("keeps preview builds branded as nightly but on the latest update channel", () => { + expect(isNightlyDesktopVersion("0.0.41-preview.20260911.7")).toBe(true); + expect(resolveDefaultDesktopUpdateChannel("0.0.41-preview.20260911.7")).toBe("latest"); + expect(resolveDefaultDesktopUpdateChannel("0.0.41-nightly.20260911.7")).toBe("nightly"); + }); + + it("only matches the first prerelease identifier", () => { + expect(isNightlyDesktopVersion("1.2.3-foo-preview.20260911.1")).toBe(false); + expect(isNightlyDesktopVersion("1.2.3")).toBe(false); + }); +}); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 731910e441fe..e7f9a2f8547d 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,11 +1,18 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; -const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; +// Preview builds are the maintainers' test train, cut by hand from unreleased +// branches to exercise the release flow. They share nightly's branding but +// are packaged without an update feed (see +// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the +// channel a preview install reports is cosmetic: it never checks for updates +// and no updater feed ever lists a preview release. +const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { - return NIGHTLY_VERSION_PATTERN.test(version); + return PRERELEASE_VERSION_PATTERN.test(version); } export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { - return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; + return NIGHTLY_VERSION_PATTERN.test(appVersion) ? "nightly" : "latest"; } diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index cd1404a50464..fbcbb349f9e7 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -196,6 +196,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { ), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 7bbb5c1da024..338a02b26a1f 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -253,6 +253,7 @@ function makeTestLayer(input: { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); @@ -629,6 +630,37 @@ describe("DesktopWindow", () => { }), ); + it.effect( + "opens and reopens the window without backend readiness when local execution is disabled", + () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions: [], + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }, + }); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.createMainIfBackendReady; + assert.equal(yield* Ref.get(createCount), 1); + yield* Ref.set(mainWindow, Option.none()); + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* Ref.set(mainWindow, Option.none()); + yield* desktopWindow.dispatchMenuAction("new-thread"); + assert.equal(yield* Ref.get(createCount), 3); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0a966ec36e4d..e19a75962126 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -84,7 +84,7 @@ export class DesktopWindow extends Context.Service< readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only - // mode), before the WSL backend that serves the renderer is ready. It is + // mode), before the WSL backend that acts as the primary is ready. It is // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; // Marks the primary backend as ready so `createMainIfBackendReady` and the @@ -838,9 +838,15 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); + // With the local environment disabled there is no backend to wait for: the + // renderer is served from bundled assets and only talks to remote environments. + const waitingForBackend = Effect.gen(function* () { + if (yield* Ref.get(backendReadyRef)) return false; + return (yield* desktopSettings.get).localEnvironmentEnabled; + }); + const createMainIfBackendReady = Effect.gen(function* () { - const backendReady = yield* Ref.get(backendReadyRef); - if (!backendReady) return; + if (yield* waitingForBackend) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) return; yield* createMain; @@ -898,7 +904,7 @@ export const make = Effect.gen(function* () { { reveal = true }: { readonly reveal?: boolean } = {}, ) { const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; - if (Option.isNone(existingWindow) && (!reveal || !(yield* Ref.get(backendReadyRef)))) return; + if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; if (targetWindow.isDestroyed()) return; const send = Effect.sync(() => { diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index ed8911d40075..c2daf352837f 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -83,6 +83,29 @@ const netLayer = Layer.succeed(NetService.NetService, { } satisfies NetService.NetService["Service"]); describe("DesktopWslBackend", () => { + it.effect("does not discover or start WSL when local execution is disabled", () => + Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + yield* backend.reconcile; + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provide(Layer.mock(DesktopBackendPool.DesktopBackendPool, {})), + Layer.provide(backendConfigurationLayer), + Layer.provide(serverExposureLayer), + Layer.provide(netLayer), + Layer.provide(Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {})), + Layer.provide( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + wslBackendEnabled: true, + }), + ), + ), + ), + ), + ); it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; const primary = makeStubInstance({ diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 605f4e7a477f..3f20e58aa680 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -188,6 +188,7 @@ export const layer = Layer.effect( const reconcileBody = Effect.gen(function* () { const settings = yield* appSettings.get; + if (!settings.localEnvironmentEnabled) return; const available = yield* wslEnvironment.isAvailable; const existing = yield* findExistingWslInstance; const existingId = Option.map(existing, (instance) => instance.id); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index e1188e1a3387..e28a9c6c7800 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -15,6 +15,7 @@ import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, + buildWslRuntimeProbeScript, DesktopWslDistroListError, formatMissingToolsReason, parseNodePath, @@ -74,7 +75,9 @@ const readField = (stdout: string, field: string) => { return line.slice(field.length + 1).trim(); }; -const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");'; +// Stands in for the release's self-contained `t3` executable: the install +// script only asks it for `--version`. +const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => ChildProcessSpawner.make(() => @@ -164,21 +167,22 @@ describe("WSL runtime cache", () => { expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); expect(script).toContain(' [ -f "$ready_marker" ] &&'); - expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&'); - expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&'); - expect(script).toContain(' node_pty_payload_present "$runtime_root"'); + expect(script).toContain(' runtime_entry_runs "$runtime_root" &&'); expect(script).toContain("if runtime_is_ready; then"); + expect(script).not.toContain("bin.mjs"); + expect(script).not.toContain("node-pty"); expect(script).toContain("trap 'exit 1' HUP INT TERM"); expect(script).toContain('exec 9> "$runtime_lock"'); expect(script).toContain("flock -x 9"); expect(script).not.toContain('rm -rf "$runtime_lock"'); expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); + // The release archive wraps everything in one `t3--linux-x64/` + // directory; stripping it puts the executable at `$runtime_root/t3`. expect(script).toContain( - "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"", + "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", ); - expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"'); - expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"'); + expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); expect(script).not.toContain('rm -rf "$runtime_root"'); @@ -248,46 +252,39 @@ describe("WSL runtime cache", () => { expect(deleted).toBeGreaterThan(kept); }); - it("treats a runtime whose native payload went missing as a cache miss", () => { + it("treats a runtime whose executable no longer runs as a cache miss", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - // A glob, not a mapped `uname -m`: this is a presence check, and the later - // native probe is what judges arch and loadability. - expect(script).toContain( - ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', - ); - // The marker the probe reads must sit beside the binary, or the runtime is - // just as unusable as one missing pty.node outright. - expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue'); + // The same proof the SSH runner and CLI installers use: executable, and + // `--version` exits 0. That is what decides arch and loadability, so no + // separate native probe is needed. + expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); - // Readiness gates the short-circuit, so a cache missing the payload + // Readiness gates the short-circuit, so a cache whose executable broke // reinstalls from the archive instead of being reused forever. - const payloadCheckDefined = script.indexOf("node_pty_payload_present() {"); + const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); const readinessDefined = script.indexOf("runtime_is_ready() {"); const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); - expect(payloadCheckDefined).toBeGreaterThan(-1); - expect(payloadCheckDefined).toBeLessThan(readinessDefined); + expect(entryCheckDefined).toBeGreaterThan(-1); + expect(entryCheckDefined).toBeLessThan(readinessDefined); expect(readinessDefined).toBeLessThan(readyShortCircuit); }); - // A truncated or half-written bin.mjs passes every presence check the cache - // had: the file exists, node-pty still loads, and launch then picks a server - // that exits before it becomes ready — forever, because nothing ever - // reinstalls. The digest the install records is what turns that into a miss. - it("re-hashes the server entry against the digest the install recorded", () => { + // A swapped or half-written `t3` can still exist and even still answer + // `--version`, and launch then runs something this install never verified. + // The digest the install records is what turns that into a miss. + it("re-hashes the executable against the digest the install recorded", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - expect(script).toContain( - ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, - ); + expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`); expect(script).toContain( ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', ); @@ -310,18 +307,18 @@ describe("WSL runtime cache", () => { expect(promoted).toBeGreaterThan(markerWritten); }); - it("refuses to mark an archive without a native payload as ready", () => { + it("refuses to mark an archive whose executable does not run as ready", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then'); + expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); // The extracted tree is rejected before the ready marker is written, so a // defective archive falls back to the mounted tree instead of caching. - const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"'); + const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); expect(payloadValidated).toBeGreaterThan(-1); @@ -351,7 +348,7 @@ describe("WSL runtime cache", () => { it("never deletes a runtime another backend is running from", () => { const script = buildWslRuntimePruneScript("1.2.3/x64"); - // The running backend's argv holds `/apps/server/dist/bin.mjs`, so + // The running backend's argv holds `/t3`, so // the process itself is the lease and exiting releases it. Nothing has to be // registered up front, which is what makes this cover backends already // running from an older version that knows nothing about pruning. @@ -394,7 +391,7 @@ describe("WSL runtime cache", () => { }); // Reading the generated script proves what it says, not what it does. A cache -// whose bin.mjs was truncated satisfied every assertion above and still got +// whose entry was truncated satisfied every assertion above and still got // reused, so these run the real script against a real archive in a throwaway // HOME and check the outcome. describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { @@ -410,13 +407,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed [ "set -eu", "work=$(mktemp -d)", - 'stage="$work/stage"', - 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"', - `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`, - `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`, - `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`, - `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`, - `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`, + // Mirrors the release archive: one top-level versioned directory that + // holds the executable and its native addons. + 'stage="$work/stage/t3-0.0.0-linux-x64"', + 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', + `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, + 'chmod +x "$stage/t3"', + `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`, + `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`, `printf 'work:%s\\n' "$work"`, `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`, ].join("\n"), @@ -443,12 +441,81 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed runtimeId, runtimeParent: `${work}/home/.t3/wsl-runtime`, runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, - serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`, + serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/t3`, installScript, install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), }; }; + const probeFixture = (fixture: ReturnType) => + runShell( + [ + `export HOME=${sh(`${fixture.work}/home`)}`, + 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"', + // Isolate login profiles and hide the host's Node/version managers. + // The resolver must discover the fixture's installation itself. + "bash() { (", + " command() {", + ' case "$*" in', + ' "-v node"|"-v mise"|"-v fnm"|"-v nodenv") return 1 ;;', + ' *) builtin command "$@" ;;', + " esac", + " }", + ' eval "$2"', + "); }", + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + it("discovers version-managed Node for providers with a standalone runtime", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const nodeBin = `${fixture.work}/home/.nvm/versions/node/v24.15.0/bin`; + const setup = runShell( + [ + "set -eu", + `mkdir -p ${sh(nodeBin)}`, + `printf '%s' ${sh('#!/bin/sh\nprintf "linux-node-provider\\n"\n')} > ${sh(`${nodeBin}/node`)}`, + `chmod +x ${sh(`${nodeBin}/node`)}`, + ].join("\n"), + ); + expect(setup.status, setup.stderr).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + const resolvedPath = parseResolvedPath(probe.stdout); + expect(resolvedPath?.split(":")).toContain(nodeBin); + const provider = runShell(`export PATH=${sh(resolvedPath ?? "")}\nnode provider.js`); + expect(provider.status, provider.stderr).toBe(0); + expect(provider.stdout).toBe("linux-node-provider\n"); + }); + + it("keeps standalone runtime readiness independent of Node availability", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).not.toBeNull(); + }); + + it("keeps the inherited PATH when bash is unavailable", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const probe = runShell( + [ + "bash() { return 127; }", + 'export PATH="/fixture/bin:/usr/bin:/bin"', + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).toBe("/fixture/bin:/usr/bin:/bin"); + }); + it("reuses a warm cache without touching the archive", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); @@ -462,7 +529,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); }); - it("reinstalls a cache whose server entry was truncated", () => { + it("reinstalls a cache whose executable was truncated", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); @@ -645,7 +712,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed `runtime_root=${sh(fixture.runtimeRoot)}`, `runtime_parent=${sh(fixture.runtimeParent)}`, 'rm "$runtime_root/.t3code-wsl-runtime-ready"', - 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + 'sh -c "sleep 30" "$runtime_root/t3" >/dev/null 2>&1 &', "active_pid=$!", "sleep 0.1", fixture.installScript(), @@ -672,7 +739,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed 'home="$work/home"', 'runtime_parent="$home/.t3/wsl-runtime"', 'mkdir -p "$runtime_parent"', - 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', + 'make_ready() { mkdir -p "$runtime_parent/$1"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', "make_ready sha256-current", "make_ready sha256-previous", "make_ready sha256-active", @@ -683,7 +750,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', - 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + 'sh -c "sleep 30" "$runtime_parent/sha256-active/t3" >/dev/null 2>&1 &', "active_pid=$!", "(", ' exec 9> "$runtime_parent/.sha256-locked.install.lock"', diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 79d2213d6990..95b217c622b1 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -66,6 +66,19 @@ export type EnsureWslNodePtyResult = readonly retryLimit?: number; }; +// Outcome of asking the staged self-contained runtime to prove itself. Any +// failure sends the launch to the mounted server tree; the caller decides what +// to do with the cache. +export type ProbeWslRuntimeResult = + | { + readonly ok: true; + readonly resolvedPath: string; + } + | { + readonly ok: false; + readonly reason: string; + }; + export class DesktopWslDistroListError extends Schema.TaggedError()( "DesktopWslDistroListError", { reason: Schema.String }, @@ -108,6 +121,13 @@ export class DesktopWslEnvironment extends Context.Service< readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect; // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; + // Proves a staged self-contained runtime can run (`/t3 --version`) + // and resolves the user's PATH, including version-managed Node for provider + // CLIs. Node is optional; the mounted tree still requires ensureNodePty. + readonly probeRuntime: ( + distro: string | null, + linuxAppRoot: string, + ) => Effect.Effect; readonly ensureNodePty: ( distro: string | null, linuxAppRoot: string, @@ -149,14 +169,15 @@ const TIMEOUT_RESULT: ShellResult = { const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"], + subject = "Node.js", ): string | null => { switch (failure) { case "timeout": - return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; + return `WSL backend preflight timed out while probing for ${subject}. WSL may be slow to start; retry, or check that the distro is healthy.`; case "spawn": - return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; + return `WSL backend preflight could not start wsl.exe to probe for ${subject}. Check that WSL is installed and the distro is accessible.`; case "process": - return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; + return `WSL backend preflight lost communication with wsl.exe while probing for ${subject}. Retry, or check that the distro is healthy.`; case null: return null; } @@ -255,7 +276,7 @@ const runWslShell = ( const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; -// Holds the sha256 of the runtime's server entry, written when the install +// Holds the sha256 of the runtime's `t3` executable, written when the install // promotes a verified tree. Presence alone only says an install once finished // here; the digest is what lets a later launch prove the entry still is what // that install wrote. @@ -279,35 +300,25 @@ export const buildWslRuntimeInstallScript = ( 'runtime_parent="$HOME/.t3/wsl-runtime"', `runtime_root="$runtime_parent/${safeRuntimeId}"`, `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, - // The native payload is the part of the tree the WSL backend actually - // dlopens, and the only part a user can plausibly break by hand. Checking - // node-pty's package.json alone let a runtime whose pty.node had gone - // missing stay cache-ready forever: every launch reused it and then failed - // the native probe, with no reinstall and no fallback. Match on the glob - // rather than a mapped `uname -m` so this stays a presence check; the probe - // is what decides whether the binary is the right arch and loadable. - "node_pty_payload_present() {", - ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', - ' [ -f "$candidate" ] || continue', - ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue', - " return 0", - " done", - " return 1", + // The runtime is a self-contained `t3` executable with Node inside, so the + // readiness proof is the same one the SSH runner and the CLI installers + // use: the file is executable and `t3 --version` exits 0. That covers the + // truncated-binary and wrong-arch cases without a separate native probe. + "runtime_entry_runs() {", + ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1', "}", - // Hashing the server entry is the only check that can tell a working cache - // from one whose bin.mjs was truncated or half-written: the file is still - // there, the native probe still passes, and launch then picks a server that - // exits before it can become ready, on every restart. Hashing the ~7MB - // entry measures in single-digit milliseconds inside the distro, once per - // launch, against a cold reinstall of a few hundred megabytes. + // Hashing the entry is what tells a working cache from one whose `t3` was + // swapped or half-written after install: the file is still there and may + // even still run, and launch then picks an executable that is not what + // this install verified. Hashing the executable measures in tens of + // milliseconds inside the distro, once per launch, against a cold + // reinstall of a few hundred megabytes. "runtime_server_entry_digest() {", - ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, + ` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`, "}", "runtime_is_ready() {", ' [ -f "$ready_marker" ] &&', - ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&', - ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&', - ' node_pty_payload_present "$runtime_root" &&', + ' runtime_entry_runs "$runtime_root" &&', // An empty or unreadable marker is a miss, not a pass: that is what a // runtime installed before the marker carried a digest looks like, and one // reinstall is the cheapest way to make it verifiable from then on. @@ -370,15 +381,14 @@ export const buildWslRuntimeInstallScript = ( `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }', "trap cleanup_runtime_install EXIT", - `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`, - 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"', - 'test -f "$runtime_tmp/node_modules/node-pty/package.json"', - - // Never write the ready marker over a tree that is missing the native - // payload. Failing here drops out to the mounted-tree fallback, which is + // The release archive has one top-level `t3--linux-/` + // directory; strip it so the executable lands at `$runtime_root/t3`. + `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`, + // Never write the ready marker over a tree whose executable does not run. + // Failing here drops out to the mounted-tree fallback, which is // recoverable; promoting it would mark the defect ready and cache it. - 'if ! node_pty_payload_present "$runtime_tmp"; then', - " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2", + 'if ! runtime_entry_runs "$runtime_tmp"; then', + " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2", " exit 1", "fi", // The archive's bytes were verified against archiveSha256 above, so the @@ -466,12 +476,12 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => { }; // Drops the ready marker so the next launch reinstalls the runtime from the -// archive. Readiness is a presence check by design, so a cached tree whose -// native payload is present but unloadable (truncated pty.node, a distro whose -// glibc the binary needs and the tree was copied from another machine) stays -// ready forever and fails the probe on every launch. Only the probe can see -// that, so the probe is what revokes the marker. The tree itself is left in -// place: the install script moves an unready root aside before extracting. +// archive. Readiness is decided inside the install script, so a cached tree +// that passes there but fails the launch-time probe (a distro whose glibc the +// executable needs, a tree copied from another machine) would stay ready +// forever and fail on every launch. Only the probe can see that, so the probe +// is what revokes the marker. The tree itself is left in place: the install +// script moves an unready root aside before extracting. export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); return [ @@ -488,22 +498,29 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { return runtimeRoot.startsWith("/") ? runtimeRoot : null; }; -const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; +// The mounted server tree carries no Linux pty.node unless the build put one +// there. Distinct from a binary that is present but will not load, which is a +// distro problem rather than a build problem. +const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4; const formatNodePtyProbeFailureReason = (exitCode: number): string | null => - exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE - ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild ` or install a build that includes WSL support." + exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE + ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support." : null; +// Captures the login-shell PATH as `resolvedPath:` so the launch can forward the +// user's PATH; the server spawns provider CLIs (`codex`, `claude`) by name. +const RESOLVED_PATH_LINE = `printf 'resolvedPath:%s\\n' "$PATH"`; + const NODE_PTY_PROBE_SCRIPT = ( linuxServerDir: string, ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" -printf 'resolvedPath:%s\\n' "$PATH" +${RESOLVED_PATH_LINE} cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // The WSL Node can't read inside app.asar, so confirm what the server needs is // unpacked on the real filesystem before reporting the backend healthy. Exit 3 -// marks this distinct from a node-pty prebuild problem so the caller can report +// marks this distinct from a node-pty binary problem so the caller can report // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at // launch (which, in wsl-only mode, would just fail to launch with no fallback). // @@ -517,26 +534,26 @@ const fs = require("node:fs"); const path = require("node:path"); const pkgDir = path.dirname(require.resolve("node-pty/package.json")); // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across -// Node versions — require() succeeding IS the real compatibility test. Compare -// only arch and node-pty version (a stale binary from a different node-pty), -// NOT process.versions.modules: that would reject a perfectly loadable prebuilt -// whenever the user's WSL Node ABI differs from the build's, defeating the -// whole point of shipping one prebuilt for all Node versions. -const expected = { - arch: process.arch, - nodePtyVersion: require("node-pty/package.json").version, -}; -const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch); -const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json"); -const binary = path.join(prebuildDir, "pty.node"); -if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE}); +// Node versions — require() succeeding IS the real compatibility test. Look in +// the same places node-pty's own loader does. +const candidates = [ + path.join(pkgDir, "build", "Release", "pty.node"), + path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"), +]; +if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE}); require("node-pty"); -const actual = JSON.parse(fs.readFileSync(marker, "utf8")); -for (const key of Object.keys(expected)) { - if (actual[key] !== expected[key]) process.exit(2); -} NODE`; +// Readiness proof for a staged self-contained runtime: the executable runs and +// reports its version. Provider CLIs may still need version-managed Node, so +// resolve it before capturing PATH without requiring it for runtime readiness. +// A distro without bash falls back to the PATH sh was started with. +export const buildWslRuntimeProbeScript = (linuxAppRoot: string) => + [ + `bash -lc ${shellQuote(`${buildWslNodeEnvPreamble()}${RESOLVED_PATH_LINE}`)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, + `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, + ].join("\n"); + const TOOLCHAIN_CHECK_SCRIPT = [ "for tool in node make g++ python3; do", ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', @@ -552,15 +569,8 @@ const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => "set -e", `cd ${shellQuote(linuxServerDir)}`, `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, - `arch=$(node -p "process.arch")`, - `modules=$(node -p "process.versions.modules")`, - `node_pty_version=$(node -p "require('node-pty/package.json').version")`, `cd "$pkg_dir"`, "npx --yes node-gyp rebuild", - `prebuild_dir="prebuilds/linux-$arch"`, - `mkdir -p "$prebuild_dir"`, - `cp build/Release/pty.node "$prebuild_dir/pty.node"`, - `printf '{"arch":"%s","modules":"%s","nodePtyVersion":"%s"}\\n' "$arch" "$modules" "$node_pty_version" > "$prebuild_dir/t3code-wsl-node-pty.json"`, `node -e 'require("node-pty")'`, ].join("\n"); @@ -660,6 +670,43 @@ export const formatMissingToolsReason = ( return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; }; +const probeWslRuntimeImpl = ( + distro: string | null, + linuxAppRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const probe = yield* runWslShell( + distro, + buildWslRuntimeProbeScript(linuxAppRoot), + PROBE_TIMEOUT, + { + resolveNode: false, + }, + ); + const transportFailureReason = formatWslShellTransportFailureReason( + probe.transportFailure, + "the staged runtime", + ); + if (transportFailureReason !== null) { + return { ok: false, reason: transportFailureReason } as const; + } + if (probe.exitCode !== 0) { + const trimmedTail = probe.stderr.trim().slice(-500); + return { + ok: false, + reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, + } as const; + } + const resolvedPath = parseResolvedPath(probe.stdout); + if (resolvedPath === null) { + return { + ok: false, + reason: "WSL login-shell PATH could not be resolved during backend preflight.", + } as const; + } + return { ok: true, resolvedPath } as const; + }); + const ensureNodePtyImpl = ( distro: string | null, linuxRepoRoot: string, @@ -1133,6 +1180,9 @@ export interface DesktopWslEnvironmentTestStub { ) => PrepareWslRuntimeResult; readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect; + // Defaults to success with a plain PATH: a staged runtime that was prepared + // is assumed to run unless the test says otherwise. + readonly probeRuntime?: (distro: string | null, linuxAppRoot: string) => ProbeWslRuntimeResult; readonly ensureNodePty?: ( distro: string | null, linuxAppRoot: string, @@ -1165,6 +1215,10 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, invalidateRuntime: (distro, runtimeId) => stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, + probeRuntime: (distro, linuxAppRoot) => + Effect.succeed( + stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" }, + ), ensureNodePty: (distro, linuxAppRoot, options) => Effect.succeed( stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { @@ -1259,6 +1313,10 @@ export const layer = Layer.effect( provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( Effect.withSpan("desktop.wsl.invalidateRuntime"), ), + probeRuntime: (distro, linuxAppRoot) => + provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe( + Effect.withSpan("desktop.wsl.probeRuntime"), + ), ensureNodePty: (distro, linuxAppRoot, options) => provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( Effect.withSpan("desktop.wsl.ensureNodePty"), diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 89c11fe6e18c..f3ec31ed34d9 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,9 +1,18 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; +import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; const repoEnv = loadRepoEnv(); + +// The main process is bundled the same way the server CLI is: every JS +// dependency is inlined and only packages Node must load from disk stay +// external. The packaged app then installs just those externals, instead of a +// full production install of apps/desktop's dependency tree next to a server +// bundle that already carries its own copy of the same libraries. +const isMainProcessExternal = (id: string) => + id === "electron" || id.startsWith("electron/") || isDesktopRuntimeExternalDependency(id); const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; const publicConfigDefine = { __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( @@ -55,7 +64,9 @@ export default defineConfig({ ], clean: true, deps: { - alwaysBundle: (id) => id.startsWith("@t3tools/"), + alwaysBundle: (id) => !id.startsWith("node:") && !isMainProcessExternal(id), + neverBundle: isMainProcessExternal, + onlyBundle: false, }, ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}), }, diff --git a/apps/marketing/.gitignore b/apps/marketing/.gitignore new file mode 100644 index 000000000000..254b88f1a73f --- /dev/null +++ b/apps/marketing/.gitignore @@ -0,0 +1,2 @@ +/public/install.sh +/public/install.ps1 diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 80a36fc54af4..511552d74fbd 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "astro dev", - "build": "astro build", + "stage:install-scripts": "node scripts/stage-install-scripts.mjs", + "dev": "node scripts/stage-install-scripts.mjs && astro dev", + "build": "node scripts/stage-install-scripts.mjs && astro build", "preview": "astro preview", "typecheck": "astro check" }, diff --git a/apps/marketing/scripts/stage-install-scripts.mjs b/apps/marketing/scripts/stage-install-scripts.mjs new file mode 100644 index 000000000000..80f458e8acaa --- /dev/null +++ b/apps/marketing/scripts/stage-install-scripts.mjs @@ -0,0 +1,15 @@ +// The CLI install scripts live in scripts/ at the repo root with the rest of +// the release tooling; the site serves them at /install.sh and /install.ps1. +// Copy them into public/ before every Astro build and dev server so the two +// never drift. The copies are gitignored. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const marketingDir = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); +const repoRoot = NodePath.dirname(NodePath.dirname(marketingDir)); +const publicDir = NodePath.join(marketingDir, "public"); +NodeFS.mkdirSync(publicDir, { recursive: true }); +for (const name of ["install.sh", "install.ps1"]) { + NodeFS.copyFileSync(NodePath.join(repoRoot, "scripts", name), NodePath.join(publicDir, name)); +} diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 08a1c008453e..95c6f513f665 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -132,6 +132,9 @@ const imageProps = {

Terminal

npx t3@nightly +

No Node.js? The preview build installs as a single download:

+ curl -fsSL https://t3.codes/install.sh | sh + irm https://t3.codes/install.ps1 | iex @@ -432,6 +435,12 @@ const imageProps = { letter-spacing: -0.01em; } + .cli-note { + color: var(--fg-muted); + font-size: 0.85rem; + margin-top: 0.5rem; + } + .cli-line { align-self: flex-start; font-family: var(--font-mono); diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cb90f3687184..669bdce8a72d 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -444,7 +444,7 @@ const mobileEndorsementRows = [ return assets.find((a) => a.name.endsWith("-arm64.dmg"))?.browser_download_url ?? null; } if (platform.os === "linux") { - return assets.find((a) => a.name.endsWith(".AppImage"))?.browser_download_url ?? null; + return assets.find((a) => a.name.endsWith("-x86_64.AppImage"))?.browser_download_url ?? null; } return null; } diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index e37be215f1a0..1ec71b1c7115 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -7,6 +7,24 @@ export const config: VercelConfig = { installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", + // `curl … | sh` needs the scripts served as plain text, uncompressed by + // content negotiation, and never cached past a deploy. + headers: [ + { + source: "/install.sh", + headers: [ + { key: "Content-Type", value: "text/x-shellscript; charset=utf-8" }, + { key: "Cache-Control", value: "public, max-age=300" }, + ], + }, + { + source: "/install.ps1", + headers: [ + { key: "Content-Type", value: "text/plain; charset=utf-8" }, + { key: "Cache-Control", value: "public, max-age=300" }, + ], + }, + ], redirects: [ { source: "/app", diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 5daaa64a7985..ddc022508d05 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -214,7 +214,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.1.1", + version: "1.2.0", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 2f686e3a1fa9..7a401fd7289d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -233,6 +233,31 @@ } } +/* ─── Clerk native profile ──────────────────────────────────────────── */ +/* Fixed palette for custom pages inside Clerk's native user profile. Mirrors + clerk-theme.json, which themes the SDK's own screens, so ours match them. + Kept out of the runtime palette above on purpose: custom themes must not + restyle Clerk's chrome. Keep in sync with clerk-theme.json. */ +@layer theme { + :root { + @variant light { + --color-clerk-page: #f2f2f7; + --color-clerk-foreground: #262626; + --color-clerk-foreground-muted: #737373; + --color-clerk-border: rgba(229, 229, 234, 0.06); + --color-clerk-danger: #dc2626; + } + + @variant dark { + --color-clerk-page: #0e0e0e; + --color-clerk-foreground: #f5f5f5; + --color-clerk-foreground-muted: #a3a3a3; + --color-clerk-border: rgba(42, 42, 42, 0.06); + --color-clerk-danger: #fca5a5; + } + } +} + /* ─── Typography ────────────────────────────────────────────────────── */ @theme { /* Keep these native family names aligned with app.config.ts. */ diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt new file mode 100644 index 000000000000..feca1133d9a3 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt @@ -0,0 +1,201 @@ +package expo.modules.t3agentnotifications + +import android.content.Context +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat + +internal data class ActivityRow(val status: String, val title: String, val project: String) + +/** + * One entry per relay phase. The status label matches the relay's row wording and the + * tint matches the web sidebar pills and the iOS Live Activity so a thread reads the + * same on every surface. The icon is always the T3 mark; the chip verb carries the state. + */ +internal enum class ActivityPhase( + val status: String, + val heading: String, + val chip: String, + val action: String, + val color: Int +) { + STARTING("Connecting", "Starting", "Working", "Open", R.color.agent_activity_working), + RUNNING("Working", "Working", "Working", "Open", R.color.agent_activity_working), + APPROVAL( + "Approval", + "Approval needed", + "Approve", + "Approve", + R.color.agent_activity_attention + ), + INPUT( + "Input", + "Question for you", + "Answer", + "Answer", + R.color.agent_activity_input + ), + STALE( + "Waiting", + "Waiting for an update", + "Waiting", + "Open", + R.color.agent_activity_waiting + ), + COMPLETED( + "Done", + "Finished", + "Done", + "Open", + R.color.agent_activity_done + ), + FAILED( + "Failed", + "Failed", + "Failed", + "Open", + R.color.agent_activity_failed + ); + + val needsUser get() = this == APPROVAL || this == INPUT + val finished get() = this == COMPLETED || this == FAILED + + companion object { + fun forStatus(status: String) = entries.firstOrNull { it.status == status } + } +} + +/** The relay orders rows and their deep link together; never reorder them in the client. */ +internal fun activityRows(data: Map) = (0..4).mapNotNull { + val parts = data["activity_line_$it"]?.split('\t', limit = 3) ?: return@mapNotNull null + if (parts.size != 3 || parts[1].isBlank()) { + null + } else { + ActivityRow(parts[0].take(40), parts[1].take(120), parts[2].take(120)) + } +} + +internal fun activityPhase(data: Map, rows: List): ActivityPhase? = + when (data["activity_phase"]?.takeIf { it.isNotBlank() }) { + "starting" -> ActivityPhase.STARTING + "running" -> ActivityPhase.RUNNING + "waiting_for_approval" -> ActivityPhase.APPROVAL + "waiting_for_input" -> ActivityPhase.INPUT + "stale" -> ActivityPhase.STALE + "completed" -> ActivityPhase.COMPLETED + "failed" -> ActivityPhase.FAILED + else -> rows.firstOrNull()?.let { ActivityPhase.forStatus(it.status) } + } + +/** + * Header carries the state, the title says what needs you (or which thread, when + * there is only one), and the body lists every thread with its status in front. + * System UI renders all of it, so the same builder serves the shade, the lock + * screen and the status bar chip. + */ +internal class ActivityPresentation(data: Map, private val active: Boolean) { + private val rows = activityRows(data) + private val hero = rows.firstOrNull() + val phase = activityPhase(data, rows) + private val activeCount = data["activity_active_count"]?.toIntOrNull()?.coerceAtLeast(0) + ?: rows.count { ActivityPhase.forStatus(it.status)?.finished != true } + private val attentionCount = data["activity_attention_count"]?.toIntOrNull()?.coerceAtLeast(0) + ?: rows.count { ActivityPhase.forStatus(it.status)?.needsUser == true } + private val failedCount = rows.count { it.status == ActivityPhase.FAILED.status } + val threadCount = + activeCount + rows.count { ActivityPhase.forStatus(it.status)?.finished == true } + private val singleProject = rows.map { it.project }.distinct().size == 1 + private val legacyBody = (0..4).mapNotNull { data["activity_line_$it"]?.take(300) } + .takeIf { it.isNotEmpty() }?.joinToString("\n") + ?: data["activity_body"].orEmpty().take(240) + + val summary = when { + hero == null -> data["activity_title"]?.takeIf { it.isNotBlank() }?.take(120) + ?: "Agent activity" + rows.size == 1 -> hero.title + attentionCount == 1 -> "1 needs you" + attentionCount > 1 -> "$attentionCount need you" + activeCount > 0 && failedCount > 0 -> "$failedCount failed" + activeCount > 0 -> "$activeCount working" + failedCount > 0 -> "Finished, $failedCount failed" + else -> "All finished" + } + + val chip = when { + !active -> null + phase == null -> data["activity_chip"]?.takeIf { it.isNotBlank() }?.take(7) ?: "Active" + phase == ActivityPhase.RUNNING && activeCount > 1 -> + "${if (activeCount > 9) "9+" else activeCount} live" + else -> phase.chip + } + + val action = if (active) phase?.action ?: "Open" else null + + fun applyTo(builder: NotificationCompat.Builder, context: Context) { + val tint = phase?.let { ContextCompat.getColor(context, it.color) } + builder.setSmallIcon(R.drawable.agent_activity_mark) + if (tint != null) builder.setColor(tint) + // Tint the summary only when it names an outcome or a request; a plain + // "3 working" stays neutral so the accent keeps meaning something. + val tintedSummary = tint != null && rows.size > 1 && phase != ActivityPhase.RUNNING && + phase != ActivityPhase.STARTING + builder.setContentTitle(if (tintedSummary) tinted(summary, tint!!) else summary) + if (rows.size > 1) { + builder.setSubText( + listOfNotNull( + hero!!.project.takeIf { singleProject && it.isNotBlank() }, + if (activeCount > 0) "$activeCount active" else "$threadCount threads" + ).joinToString(" · ") + ) + } + val body = body(context) + // The collapsed card gets the priority row; the expanded card gets them all. + val lineBreak = body.indexOf('\n') + val firstLine = if (lineBreak >= 0) body.subSequence(0, lineBreak) else body + builder.setContentText(firstLine).setStyle(NotificationCompat.BigTextStyle().bigText(body)) + // Thread update timestamps can change while an approval remains pending. + // Leave the timer hidden until the payload has a stable phase-entry timestamp. + builder.setShowWhen(false).setUsesChronometer(false) + } + + private fun body(context: Context): CharSequence = when { + hero == null -> legacyBody + // The title already names the thread; the body only needs its status and project. + rows.size == 1 -> statusLine(context, hero.copy(title = hero.project), "") + else -> SpannableStringBuilder().apply { + rows.forEachIndexed { index, row -> + if (index > 0) append("\n") + append(statusLine(context, row, row.project.takeUnless { singleProject }.orEmpty())) + } + } + } + + private fun statusLine(context: Context, row: ActivityRow, trailing: String): CharSequence = + SpannableStringBuilder().apply { + val status = ActivityPhase.forStatus(row.status) + val color = ContextCompat.getColor(context, status?.color ?: R.color.agent_activity_waiting) + append(tinted(row.status, color, bold = true)) + append(" ").append(row.title) + // Promoted cards drop text color, so the separator has to do the work of the dimming. + if (trailing.isNotBlank()) { + val start = length + append(" · ").append(trailing) + setSpan( + ForegroundColorSpan(ContextCompat.getColor(context, R.color.agent_activity_waiting)), + start, + length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + private fun tinted(text: String, color: Int, bold: Boolean = false) = + SpannableStringBuilder(text).apply { + setSpan(ForegroundColorSpan(color), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + if (bold) setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index d1b81661151c..35086bbd75f9 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -11,8 +11,6 @@ import android.content.Intent import android.content.SharedPreferences import android.net.Uri import android.os.Build -import android.text.TextPaint -import android.text.TextUtils import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.Lifecycle @@ -197,34 +195,32 @@ object AgentNotifications { active: Boolean, remainingMs: Long ) { - val body = data["activity_body"].orEmpty().take(240) val dismissIntent = PendingIntent.getBroadcast( context, ACTIVITY_ID, Intent(context, AgentActivityDismissReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val lines = (0..4).mapNotNull { - data["activity_line_$it"]?.let { line -> activityLine(context, line) } - } - // BigTextStyle remains eligible for Android Live Update promotion. - val style = NotificationCompat.BigTextStyle().bigText( - if (lines.isEmpty()) body else lines.joinToString("\n") - ) - val notification = base(context, ACTIVITY_CHANNEL) - .setContentTitle(data["activity_title"].orEmpty().take(120)) - .setContentText(body) - .setStyle(style) + val presentation = ActivityPresentation(data, active) + val openThread = contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID) + val builder = base(context, ACTIVITY_CHANNEL) .setOngoing(active).setOnlyAlertOnce(true).setSilent(true) .setTimeoutAfter(remainingMs) // Live Updates must remain uncolorized to qualify for promotion. .setColorized(false) .setRequestPromotedOngoing(active) - .setContentIntent(contentIntent(context, scheme, data["activity_path"], ACTIVITY_ID)) + .setShortCriticalText(presentation.chip) + .setContentIntent(openThread) .setDeleteIntent(dismissIntent) - .addAction(0, "Dismiss", dismissIntent) - .build() - manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, notification) + presentation.applyTo(builder, context) + // A finished card is no longer ongoing, so it swipes away and a tap opens + // the thread; buttons would only repeat that. + val action = presentation.action + if (action != null) { + if (openThread != null) builder.addAction(0, action, openThread) + builder.addAction(0, "Dismiss", dismissIntent) + } + manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, builder.build()) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { // Notification timeouts were added in API 26. One inexact alarm also // expires cards on Android 7, including when the app process has exited. @@ -253,32 +249,6 @@ object AgentNotifications { } } - private fun activityLine(context: Context, value: String): String { - val parts = value.split('\t', limit = 3) - if (parts.size != 3) return value.take(300) - val metrics = context.resources.displayMetrics - val paint = TextPaint().apply { textSize = 14 * metrics.scaledDensity } - val prefix = "${parts[0]}: " - val separator = " · " - // Reserve the system notification's icon and margins. Fit the two titles - // independently so large fonts/long names never hide the project or status. - // The shade uses a narrow column even when a headless service sees a - // foldable's wider display metrics. Keep rows inside that column too. - val width = (metrics.widthPixels - 152 * metrics.density) - .coerceIn(120 * metrics.density, 280 * metrics.density) - val available = (width - paint.measureText(prefix + separator)).coerceAtLeast(0f) - val projectWidth = paint.measureText(parts[2]).coerceAtMost(available * 0.4f) - val titleWidth = paint.measureText(parts[1]).coerceAtMost(available - projectWidth) - val title = TextUtils.ellipsize(parts[1], paint, titleWidth, TextUtils.TruncateAt.END) - val project = TextUtils.ellipsize( - parts[2], - paint, - available - titleWidth, - TextUtils.TruncateAt.END - ) - return "$prefix$title$separator$project" - } - private fun manager(context: Context) = context.getSystemService(NotificationManager::class.java) private fun channels(context: Context) { diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt index d394db56115f..246db274a195 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -1,5 +1,9 @@ package expo.modules.t3agentnotifications +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Build +import android.provider.Settings import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -21,5 +25,23 @@ class T3AgentNotificationsModule : Module() { Function("clear") { appContext.reactContext?.let { AgentNotifications.clear(it) } } + + Function("openLiveUpdateSettings") { + val context = appContext.reactContext + if (context == null || Build.VERSION.SDK_INT < 36) { + false + } else { + try { + context.startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_PROMOTION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + true + } catch (_: ActivityNotFoundException) { + false + } + } + } } } diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml new file mode 100644 index 000000000000..7aa01bac42c1 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/drawable/agent_activity_mark.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values-night/agent_activity_colors.xml b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values-night/agent_activity_colors.xml new file mode 100644 index 000000000000..e08b159d9f04 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values-night/agent_activity_colors.xml @@ -0,0 +1,9 @@ + + + #7DD3FC + #FCD34D + #A5B4FC + #94A3B8 + #6EE7B7 + #FCA5A5 + diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values/agent_activity_colors.xml b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values/agent_activity_colors.xml new file mode 100644 index 000000000000..262bbfc23fb7 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/res/values/agent_activity_colors.xml @@ -0,0 +1,9 @@ + + + #0284C7 + #D97706 + #4F46E5 + #64748B + #059669 + #DC2626 + diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 734fbbc2f3b2..3e9b3b24c193 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -294,26 +294,183 @@ class AgentNotificationsTest { } @Test - fun longRowsKeepStatusAndBothTitlesWithinTheNotificationWidth() { + fun severalThreadsListEveryRowWithItsStatusAndActionsFollowThePriorityThread() { lifecycle.currentState = Lifecycle.State.RESUMED - val raw = "Approval\t${"Long thread name ".repeat(10)}\t${"Project name ".repeat(10)}" + val title = "A long thread title that should wrap rather than disappear" + val data = update("attention", true) + mapOf( + "activity_line_0" to "Approval $title Project", + "activity_line_1" to "Working Another thread Other project", + "activity_phase" to "waiting_for_approval", + "activity_active_count" to "8", + "activity_attention_count" to "1", + ) + AgentNotifications.receive(context, data) + val card = manager.activeNotifications.single().notification + assertEquals("1 needs you", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertEquals("8 active", card.extras.getString(Notification.EXTRA_SUB_TEXT)) + assertEquals( + "Approval $title · Project\nWorking Another thread · Other project", + card.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString() + ) + assertEquals( + "Approval $title · Project", + card.extras.getCharSequence(Notification.EXTRA_TEXT).toString() + ) + assertEquals(listOf("Approve", "Dismiss"), card.actions.map { it.title.toString() }) + assertEquals( + "t3code-dev://threads/environment/thread", + shadowOf(card.actions[0].actionIntent).savedIntent.dataString + ) + assertEquals("Approve", card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + AgentNotifications.receive( context, - update("long-work", true) + (0..4).associate { "activity_line_$it" to raw } - ) - val lines = manager.activeNotifications.single().notification.extras.getString( - Notification.EXTRA_BIG_TEXT - )!!.split('\n') - assertEquals(5, lines.size) - for (line in lines) { - assertTrue(line.startsWith("Approval: ")) - assertTrue(line.contains(" · ")) - assertTrue(line.length < raw.length) - assertFalse(line.contains('\t')) - assertTrue(line.substringAfter(" · ").isNotBlank()) + data + mapOf( + "activity_phase" to "waiting_for_input", + "activity_attention_count" to "2", + "activity_path" to "/threads/another-environment/another-thread", + ) + ) + val next = manager.activeNotifications.single().notification + assertEquals("2 need you", next.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertEquals("Answer", next.actions[0].title.toString()) + assertEquals( + "t3code-dev://threads/another-environment/another-thread", + shadowOf(next.actions[0].actionIntent).savedIntent.dataString + ) + assertEquals("Answer", next.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + } + + @Test + fun waitingCardsIgnoreMutableThreadTimestampsEvenAfterRename() { + lifecycle.currentState = Lifecycle.State.RESUMED + val now = System.currentTimeMillis() + for (phase in listOf("waiting_for_approval", "waiting_for_input")) { + val status = if (phase == "waiting_for_approval") "Approval" else "Input" + for ((title, updatedAt) in listOf("Original" to now - 1200000L, "Renamed" to now)) { + AgentNotifications.receive( + context, + update("waiting", true) + mapOf( + "activity_line_0" to "$status\t$title\tProject", + "activity_phase" to phase, + "activity_since" to updatedAt.toString() + ) + ) + val card = manager.activeNotifications.single().notification + assertEquals(title, card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_WHEN)) + assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)) + } } } + @Test + fun aSingleThreadUsesItsTitleAndNeverShowsAProgressBar() { + lifecycle.currentState = Lifecycle.State.RESUMED + val data = update("work", true) + mapOf( + "activity_line_0" to "Working Update dashboard Project", + "activity_phase" to "running", + ) + AgentNotifications.receive(context, data) + val working = manager.activeNotifications.single().notification + assertEquals( + "Update dashboard", + working.extras.getCharSequence(Notification.EXTRA_TITLE).toString() + ) + assertEquals( + "Working Project", + working.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString() + ) + assertEquals(null, working.extras.getString(Notification.EXTRA_SUB_TEXT)) + assertEquals("Working", working.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + assertFalse(working.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)) + for (phase in listOf( + "waiting_for_approval", + "waiting_for_input", + "stale", + "failed", + "completed" + )) { + val active = phase != "failed" && phase != "completed" + AgentNotifications.receive( + context, + data + mapOf( + "activity_phase" to phase, + "active" to active.toString(), + "activity_expires_at" to (System.currentTimeMillis() + 900000).toString(), + ) + ) + val card = manager.activeNotifications.single().notification + assertEquals( + "android.app.Notification\$BigTextStyle", + card.extras.getString(Notification.EXTRA_TEMPLATE) + ) + assertEquals(active, NotificationCompat.isRequestPromotedOngoing(card)) + if (!active) { + assertEquals(null, card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + assertEquals(0, card.actions?.size ?: 0) + } + } + } + + @Test + fun olderRelayRowsStillSelectTheNativeStateAndAction() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive( + context, + update("input", true) + mapOf( + "activity_line_0" to "Input Choose an icon Project", + ) + ) + val card = manager.activeNotifications.single().notification + assertEquals("Choose an icon", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertEquals("Answer", card.actions.first().title.toString()) + assertEquals(2, card.actions.size) + assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)) + } + + @Test + fun multipleAgentsUseTheChipCountAndFinishedThreadsRemainInTheSummary() { + lifecycle.currentState = Lifecycle.State.RESUMED + val data = update("multiple", true) + mapOf( + "activity_line_0" to "Working Build feature Project", + "activity_line_1" to "Done Write tests Project", + "activity_phase" to "running", + "activity_active_count" to "2", + ) + AgentNotifications.receive(context, data) + val card = manager.activeNotifications.single().notification + assertEquals("2 live", card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + assertEquals("2 working", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + assertEquals("Project · 2 active", card.extras.getString(Notification.EXTRA_SUB_TEXT)) + assertEquals( + "Working Build feature\nDone Write tests", + card.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString() + ) + AgentNotifications.receive(context, data + ("activity_active_count" to "15")) + assertEquals( + "9+ live", + manager.activeNotifications.single().notification.extras + .getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) + ) + AgentNotifications.receive( + context, + data + mapOf( + "activity_line_0" to "Failed Build feature Project", + "activity_phase" to "failed", + "activity_active_count" to "0", + "active" to "false", + "activity_expires_at" to (System.currentTimeMillis() + 900000).toString(), + ) + ) + val finished = manager.activeNotifications.single().notification + assertEquals( + "Finished, 1 failed", + finished.extras.getCharSequence(Notification.EXTRA_TITLE).toString() + ) + assertEquals("Project · 2 threads", finished.extras.getString(Notification.EXTRA_SUB_TEXT)) + } + private fun assertTimeout(card: Notification, expected: LongRange) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { assertTrue(card.timeoutAfter in expected) @@ -399,6 +556,46 @@ class AgentNotificationsTest { assertEquals(Notification.VISIBILITY_PRIVATE, card.visibility) } + @Test + fun liveUpdateChipChangesWithActivityAndClearsOnCompletion() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, update("work", true)) + assertEquals( + "Active", + manager.activeNotifications.single().notification.extras + .getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) + ) + AgentNotifications.receive(context, update("input", true) + ("activity_chip" to "Review")) + val activeCard = manager.activeNotifications.single().notification + assertEquals( + "Review", + activeCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT) + ) + assertTrue(NotificationCompat.isRequestPromotedOngoing(activeCard)) + + AgentNotifications.receive( + context, + update("done", false) + mapOf( + "activity_chip" to "Review", + "activity_expires_at" to (System.currentTimeMillis() + 900000).toString() + ) + ) + val finishedCard = manager.activeNotifications.single().notification + assertEquals(null, finishedCard.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) + assertFalse(NotificationCompat.isRequestPromotedOngoing(finishedCard)) + assertFalse(finishedCard.flags and Notification.FLAG_ONGOING_EVENT != 0) + } + + @Test + fun blankTitleCannotMakeAnActivityIneligibleForPromotion() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, update("work", true) + ("activity_title" to " ")) + assertEquals( + "Agent activity", + manager.activeNotifications.single().notification.extras.getString(Notification.EXTRA_TITLE) + ) + } + @Test fun expiredMalformedAndFutureMessagesCannotDisplayOrPoisonLaterUpdates() { val invalid = update("invalid", true) diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h index 09ddd9c0fe86..625b7ca97c7b 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h @@ -102,6 +102,7 @@ static inline NSAttributedString *T3MarkdownTextAttachmentString( static UIFont *T3ContextChipFont(NSDictionary *payload) { CGFloat size = MAX(10, MIN(40, [payload[@"fontSize"] doubleValue])); + size *= payload[@"fontSizeMultiplier"] != nil ? [payload[@"fontSizeMultiplier"] doubleValue] : 1; return [UIFont fontWithName:@"DMSans-Medium" size:size] ?: [UIFont systemFontOfSize:size weight:UIFontWeightMedium]; } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index 6afd92eb94b5..e1cc7c2046b2 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -197,12 +197,19 @@ static void applyAttachments( } if (props.nativeId.rfind("t3-chip:", 0) == 0 && fragmentLength > 0) { const std::string uri = props.nativeId.substr(3); - NSDictionary *payload = T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]); + NSMutableDictionary *payload = + [T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]) mutableCopy]; + // Chips must scale with the paragraph or smaller Dynamic Type sizes clip them. + // Store the scaled payload so measurement and the rendered bitmap use the same font. + payload[@"fontSizeMultiplier"] = @(fontSizeMultiplier); + NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; + NSString *scaledUri = [@"chip:" stringByAppendingString: + [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]]; const CGFloat maxWidth = std::isfinite(layoutConstraints.maximumSize.width) ? layoutConstraints.maximumSize.width : 320; const CGSize size = T3ContextChipSize(payload, maxWidth); attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, 1, uri, false, + utf16Offset, 1, std::string(scaledUri.UTF8String), false, static_cast(size.width), static_cast(size.height), }); } else if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0fd35faf7528..18fb67319176 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -106,7 +106,7 @@ "expo-updates": "~57.0.19", "expo-video": "~57.0.3", "expo-web-browser": "~57.0.2", - "expo-widgets": "~57.0.15", + "expo-widgets": "57.0.15", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", diff --git a/apps/mobile/src/components/ProjectCloneBanner.tsx b/apps/mobile/src/components/ProjectCloneBanner.tsx new file mode 100644 index 000000000000..cca6a71e3f5e --- /dev/null +++ b/apps/mobile/src/components/ProjectCloneBanner.tsx @@ -0,0 +1,81 @@ +import { + projectCloneDisplayName, + projectCloneProgressSummary, + type ProjectCloneSnapshot, +} from "@t3tools/contracts"; +import { ActivityIndicator, Pressable, View } from "react-native"; + +import { cn } from "../lib/cn"; +import { AppText as Text } from "./AppText"; + +/** + * Live state of the clone that backs a freshly added project, shown above + * the composer while the draft waits for its files. Running clones offer + * Cancel; failed or cancelled ones offer Retry and Remove project. + */ +export function ProjectCloneBanner(props: { + readonly clone: ProjectCloneSnapshot; + readonly onCancel: () => void; + readonly onRetry: () => void; + readonly onRemove: () => void; +}) { + const { clone } = props; + const name = projectCloneDisplayName(clone); + if (clone.phase === "running") { + return ( + + + + + Cloning {name} + + + {projectCloneProgressSummary(clone)} + + + + + ); + } + const cancelled = clone.phase === "cancelled"; + return ( + + + {cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`} + + {clone.error ? ( + + {clone.error} + + ) : null} + + + + + + ); +} + +function BannerAction(props: { readonly label: string; readonly onPress: () => void }) { + return ( + + {props.label} + + ); +} diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx new file mode 100644 index 000000000000..04a562956c46 --- /dev/null +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -0,0 +1,74 @@ +import { Platform, Pressable, View } from "react-native"; +import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated"; +import { AppText as Text } from "./AppText"; +import { cn } from "../lib/cn"; + +export function SegmentedControl(props: { + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + /** The tab bar is full height; filters under it are shorter so it stays primary. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; +}) { + const compact = props.size === "compact"; + return ( + + option.value === props.selected), + ) * + 100) / + props.options.length + }%`, + }} + /> + {props.options.map((option) => { + const active = option.value === props.selected; + return ( + props.onSelect(option.value)} + className={cn( + "flex-1 items-center justify-center rounded-full", + compact ? "h-9" : "h-11", + )} + > + + {option.label} + + + ); + })} + + ); +} diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts index 5bb472d3a4fa..c8a2eedd4cb5 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts @@ -2,7 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ os: "android", - native: null as { configure?: ReturnType; clear?: ReturnType } | null, + version: 36, + openSettings: vi.fn(), + native: null as { + configure?: ReturnType; + clear?: ReturnType; + openLiveUpdateSettings?: ReturnType; + } | null, config: { scheme: ["t3code-preview"], extra: { iosPersonalTeamBuild: false } }, requireModule: vi.fn(), })); @@ -10,7 +16,11 @@ const mocks = vi.hoisted(() => ({ vi.mock("expo", () => ({ requireOptionalNativeModule: mocks.requireModule })); vi.mock("expo-constants", () => ({ default: { expoConfig: mocks.config } })); vi.mock("react-native", () => ({ + Linking: { openSettings: mocks.openSettings }, Platform: { + get Version() { + return mocks.version; + }, get OS() { return mocks.os; }, @@ -20,12 +30,39 @@ vi.mock("react-native", () => ({ beforeEach(() => { vi.resetModules(); mocks.os = "android"; + mocks.version = 36; + mocks.openSettings.mockReset().mockResolvedValue(undefined); mocks.native = { configure: vi.fn(), clear: vi.fn() }; mocks.config.extra.iosPersonalTeamBuild = false; mocks.requireModule.mockReset().mockImplementation(() => mocks.native); }); describe("Android native notification capability", () => { + it("opens the Live Update controls on supported Android builds", async () => { + mocks.native!.openLiveUpdateSettings = vi.fn(() => true); + const { openAndroidLiveUpdateSettings, supportsAndroidLiveUpdateSettings } = + await import("./androidNotifications"); + expect(supportsAndroidLiveUpdateSettings()).toBe(true); + await openAndroidLiveUpdateSettings(); + expect(mocks.native!.openLiveUpdateSettings).toHaveBeenCalledOnce(); + expect(mocks.openSettings).not.toHaveBeenCalled(); + mocks.version = 35; + expect(supportsAndroidLiveUpdateSettings()).toBe(false); + mocks.os = "ios"; + mocks.version = 36; + expect(supportsAndroidLiveUpdateSettings()).toBe(false); + }); + + it.each([undefined, vi.fn(() => false)])( + "falls back to app settings for older binaries or missing system activities (%j)", + async (openLiveUpdateSettings) => { + if (openLiveUpdateSettings) mocks.native!.openLiveUpdateSettings = openLiveUpdateSettings; + const { openAndroidLiveUpdateSettings } = await import("./androidNotifications"); + await openAndroidLiveUpdateSettings(); + expect(mocks.openSettings).toHaveBeenCalledOnce(); + }, + ); + it("uses the installed module and the build variant's deep-link scheme", async () => { const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } = await import("./androidNotifications"); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index a65ff1758e78..9ffe586ecb81 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -1,10 +1,11 @@ import Constants from "expo-constants"; import { requireOptionalNativeModule } from "expo"; -import { Platform } from "react-native"; +import { Linking, Platform } from "react-native"; interface AndroidAgentNotifications { configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; clear(): void; + openLiveUpdateSettings?(): boolean; } const native = @@ -33,3 +34,13 @@ export function configureAndroidAgentNotifications( export function clearAndroidAgentNotifications(): void { native?.clear?.(); } + +export function supportsAndroidLiveUpdateSettings(): boolean { + return Platform.OS === "android" && Number(Platform.Version) >= 36; +} + +export async function openAndroidLiveUpdateSettings(): Promise { + if (!native?.openLiveUpdateSettings?.()) { + await Linking.openSettings(); + } +} diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx new file mode 100644 index 000000000000..702aa5f5e8ef --- /dev/null +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -0,0 +1,273 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { MenuAction } from "@react-native-menu/menu"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { type ReactNode, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Pressable, + RefreshControl, + ScrollView, + Text, + View, +} from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { showConfirmDialog } from "../../components/ConfirmDialogHost"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + deregisterManagedRelayEnvironmentCommand, + useManagedRelayEnvironments, +} from "./managedRelayState"; + +const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); + +function linkedAtLabel(value: string): string { + const linkedAt = new Date(value); + return Number.isNaN(linkedAt.getTime()) + ? "Link date unavailable" + : `Linked ${linkedAtFormatter.format(linkedAt)}`; +} + +function endpointLabel(environment: RelayClientEnvironmentRecord): string { + return environment.endpoint.providerKind === "cloudflare_tunnel" + ? "Managed tunnel" + : "Activity publishing only"; +} + +function confirmDeregister(environment: RelayClientEnvironmentRecord, onConfirm: () => void) { + const title = "Deregister server?"; + const message = `“${environment.label}” will be removed from this account. T3 Connect access will be revoked, any managed tunnel will be removed, and a host space will become available. Local connections on your devices are not changed.`; + if (process.env.EXPO_OS === "ios") { + Alert.alert(title, message, [ + { text: "Cancel", style: "cancel" }, + { text: "Deregister", style: "destructive", onPress: onConfirm }, + ]); + return; + } + showConfirmDialog({ title, message, confirmText: "Deregister", destructive: true, onConfirm }); +} + +/** + * The "T3 Connect" custom page inside Clerk's native user profile: every + * environment registered to the signed-in account, with account-level + * deregistration. Mirrors the web UserButton page; connections on this device + * are managed in Settings instead. + */ +export function T3ConnectProfilePage() { + const environmentsState = useManagedRelayEnvironments(); + const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { + reportFailure: false, + }); + const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = + useState(null); + const mutationPendingRef = useRef(false); + // Deregistered rows stay in the cached list until the refresh lands, so hide + // them by the linkedAt they had. A re-link produces a new linkedAt and shows again. + const [removedEnvironments, setRemovedEnvironments] = useState<{ + readonly accountId: string | null; + readonly linkedAtById: ReadonlyMap; + }>({ accountId: null, linkedAtById: new Map() }); + + const handleDeregister = async (environment: RelayClientEnvironmentRecord) => { + const accountId = environmentsState.accountId; + if (!accountId || mutationPendingRef.current) return; + + mutationPendingRef.current = true; + setDeregisteringEnvironmentId(environment.environmentId); + const result = await deregisterEnvironment({ + accountId, + environmentId: environment.environmentId, + }); + mutationPendingRef.current = false; + setDeregisteringEnvironmentId(null); + + if (result._tag === "Success") { + setRemovedEnvironments((current) => { + const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []); + linkedAtById.set(environment.environmentId, environment.linkedAt); + return { accountId, linkedAtById }; + }); + environmentsState.refresh(); + return; + } + if (isAtomCommandInterrupted(result)) return; + + const cause = squashAtomCommandFailure(result); + const message = cause instanceof Error ? cause.message : "Could not deregister the server."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not deregister environment", { + environmentId: environment.environmentId, + message, + traceId, + cause, + }); + Alert.alert( + "Could not deregister server", + traceId ? `${message}\n\nTrace ID: ${traceId}` : message, + traceId + ? [ + { + text: "Copy trace ID", + onPress: () => copyTextWithHaptic(traceId, { target: "connection-trace-id" }), + }, + { text: "OK", style: "cancel" }, + ] + : undefined, + ); + }; + + const removedEnvironmentLinkedAt = + removedEnvironments.accountId === environmentsState.accountId + ? removedEnvironments.linkedAtById + : new Map(); + const environments = (environmentsState.data ?? []).filter( + (environment) => + removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt, + ); + const isInitialLoad = + !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error); + const errorTraceId = environmentsState.errorTraceId; + + return ( + + } + > + Registered servers + + {environmentsState.error ? ( + <> + + {errorTraceId ? ( + { + copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); + }} + /> + ) : null} + + ) : isInitialLoad ? ( + + + Loading environments + + ) : environments.length > 0 ? ( + environments.map((environment) => ( + + ) : ( + + confirmDeregister(environment, () => void handleDeregister(environment)) + } + > + + + + + + + ) + } + /> + )) + ) : ( + + )} + + + Connections on this device are managed in Settings. + + + ); +} + +const ENVIRONMENT_MENU_ACTIONS = [ + { id: "deregister", title: "Deregister", image: "trash", attributes: { destructive: true } }, +] satisfies MenuAction[]; + +// Layout primitives that mirror clerk-ios ClerkKitUI's profile rows so a custom +// page reads as one of Clerk's own screens. System font on purpose: Clerk's +// native views do not use the app's DM Sans. + +function ClerkSectionHeader(props: { readonly children: string }) { + return ( + + {props.children} + + ); +} + +function ClerkRow(props: { + readonly title: string; + readonly subtitle: string; + readonly accessory?: ReactNode; +}) { + return ( + + + + {props.title} + + + {props.subtitle} + + + {props.accessory} + + ); +} + +function ClerkButtonRow(props: { readonly label: string; readonly onPress: () => void }) { + return ( + + {props.label} + + ); +} diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 8c41d74841e7..375bda715e12 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -1,9 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, + deregisterManagedRelayEnvironment, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect } from "react"; @@ -19,6 +25,22 @@ export const managedRelayQueryManager = createManagedRelayQueryManager(managedRe cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), }); +const managedRelayMutationScheduler = createAtomCommandScheduler(); + +export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( + managedRelayAtomRuntime, + { + label: "mobile:managed-relay:deregister-environment", + scheduler: managedRelayMutationScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) => + input.accountId, + }, + execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input), + }, +); + const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null")); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5231228829d3..5724abb138c8 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -49,7 +49,7 @@ import * as Order from "effect/Order"; import { AsyncResult } from "effect/unstable/reactivity"; import { cn } from "../../lib/cn"; -import { useProjects, useServerConfigs } from "../../state/entities"; +import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; @@ -77,6 +77,8 @@ interface EnvironmentOption { readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; + /** Server runs clones in the background and streams progress; older servers block. */ + readonly supportsCloneTracking: boolean; } const environmentOptionOrder = Order.mapInput( @@ -366,6 +368,7 @@ function useEnvironmentOptions(): ReadonlyArray { connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, + supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true, }; }); return Arr.sort(options, environmentOptionOrder); @@ -575,6 +578,15 @@ export function AddProjectSourceScreen() { ); } +function openNewTaskDraft( + navigation: { dispatch: (action: ReturnType) => void }, + params: { environmentId: EnvironmentId; projectId: ProjectId; title: string; cloning?: "1" }, +) { + navigation.dispatch( + CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }), + ); +} + function useCreateProject(environment: EnvironmentOption | null) { const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); @@ -908,6 +920,10 @@ export function AddProjectDestinationScreen(props: { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); + const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { + reportFailure: false, + }); + const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); @@ -938,6 +954,48 @@ export function AddProjectDestinationScreen(props: { } setIsSubmitting(true); + if (environment.supportsCloneTracking) { + // The server creates the project and clones in the background; the + // draft screen shows progress and holds Start until the files land. + const projectId = ProjectId.make(uuidv4()); + const title = inferProjectTitleFromPath(resolved.path); + const startResult = await startProjectClone({ + environmentId: environment.environmentId, + input: { + projectId, + title, + createdAt: new Date().toISOString(), + remoteUrl, + destinationPath: resolved.path, + }, + }); + if (AsyncResult.isFailure(startResult)) { + setError(errorMessage(Cause.squash(startResult.cause))); + } else { + // The draft screen resolves its project from the client store, so it + // must not open before the create event has arrived (it would fall + // back to the project picker and lose the clone controls). Stay in + // the submitting state until then; the clone keeps running either way. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + } else { + openNewTaskDraft(navigation, { + environmentId: environment.environmentId, + projectId, + title, + cloning: "1", + }); + } + } + setIsSubmitting(false); + return; + } const cloneResult = await cloneRepository({ environmentId: environment.environmentId, input: { @@ -960,8 +1018,10 @@ export function AddProjectDestinationScreen(props: { environment, isBrowseNavigating, isSubmitting, + navigation, pathInput, remoteUrl, + startProjectClone, ]); return ( diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index 96d612f8c689..e6e23fd78be9 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -1,10 +1,21 @@ import { useAuth } from "@clerk/expo"; -import { AuthView, UserProfileView } from "@clerk/expo/native"; +import { AuthView, type UserProfileCustomPage, UserProfileView } from "@clerk/expo/native"; import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { T3ConnectProfilePage } from "../cloud/T3ConnectProfilePage"; + +// Custom rows in Clerk's native profile. Mirrors the web UserButton pages. +const USER_PROFILE_CUSTOM_PAGES = [ + { + path: "t3-connect", + label: "T3 Connect", + icon: "globe", + content: , + }, +] satisfies UserProfileCustomPage[]; export function SettingsAuthRouteScreen() { const navigation = useNavigation(); @@ -40,7 +51,11 @@ function ConfiguredSettingsAuthRouteScreen() { {isLoaded ? ( hasBeenSignedIn.current ? ( - + ) : ( ) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index d343f2dc8830..e67350f3d0f1 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -21,6 +21,10 @@ import { import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; +import { + openAndroidLiveUpdateSettings, + supportsAndroidLiveUpdateSettings, +} from "../agent-awareness/androidNotifications"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { @@ -541,7 +545,13 @@ function ConfiguredSettingsRouteScreen() { liveActivityStatus === "linking" } icon="bolt.circle" - label={Platform.OS === "android" ? "Ongoing Agent Activity" : "Live Activity Updates"} + label={ + Platform.OS === "android" + ? supportsAndroidLiveUpdateSettings() + ? "Agent Live Updates" + : "Ongoing Agent Activity" + : "Live Activity Updates" + } subtitle={agentAwarenessSubtitle} // Same gate: a saved preference is meaningless until the device // registration the relay needs to push updates has succeeded. @@ -552,6 +562,20 @@ function ConfiguredSettingsRouteScreen() { } onValueChange={handleLiveActivitiesChange} /> + {supportsAndroidLiveUpdateSettings() ? ( + { + void openAndroidLiveUpdateSettings().catch(() => { + Alert.alert( + "Couldn't open Settings", + "Open Android Settings, select T3 Code, then enable Live Updates in Notifications.", + ); + }); + }} + /> + ) : null} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx new file mode 100644 index 000000000000..7f5b69ed224a --- /dev/null +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -0,0 +1,268 @@ +import { + Button, + DatePicker, + Host, + HStack, + Menu, + Picker, + Popover, + Spacer, + Text, + VStack, +} from "@expo/ui/swift-ui"; +import { + background, + buttonStyle, + datePickerStyle, + font, + foregroundStyle, + frame, + padding, + pickerStyle, + presentationBackground, + shapes, + tag, +} from "@expo/ui/swift-ui/modifiers"; +import { + localSnoozeDate, + localSnoozeTime, + resolveCustomSnooze, + type CustomSnoozeInput, +} from "@t3tools/client-runtime/state/thread-settled"; +import { useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { AppText } from "../../components/AppText"; +import { SegmentedControl } from "../../components/SegmentedControl"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; + +const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); +const modes = [ + { value: "date", label: "Date and time" }, + { value: "duration", label: "Duration" }, +] as const; +const units = [ + { value: "minutes", label: "Minutes" }, + { value: "hours", label: "Hours" }, + { value: "days", label: "Days" }, +] as const; + +export function CustomSnoozeSheet(props: { + readonly onClose: () => void; + readonly onSnooze: (snoozedUntil: string) => void; +}) { + const [mode, setMode] = useState("date"); + const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); + const [amount, setAmount] = useState(2); + const [amountOpen, setAmountOpen] = useState(false); + const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); + const [error, setError] = useState(null); + const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences(); + const updateDate = (value: Date) => { + setDate(value); + setError(null); + }; + + const submit = () => { + const input: CustomSnoozeInput = + mode === "date" + ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) } + : { mode, amount: String(amount), unit }; + const snoozedUntil = resolveCustomSnooze(input, new Date()); + if (!snoozedUntil) { + setError( + mode === "date" ? "Choose a date and time in the future." : "Enter a positive duration.", + ); + return; + } + props.onSnooze(snoozedUntil); + props.onClose(); + }; + + return ( + + + + + + Cancel + + + Custom snooze + + + Snooze + + + { + setMode(value); + setError(null); + }} + role="tab" + /> + + + {mode === "date" ? "Until" : "Snooze for"} + + {mode === "date" ? ( + <> + + + + ) : ( + <> + + + + + + + { + setAmount(value); + setError(null); + }} + modifiers={[pickerStyle("wheel"), frame({ height: 160 })]} + > + {durationAmounts.map((value) => ( + + {String(value)} + + ))} + + + + + + + + ), + }; + } + const cancelled = activeProjectClone.phase === "cancelled"; + return { + id: `project-clone:${projectId}`, + variant: cancelled ? "warning" : "error", + icon: , + title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, + description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, + actions: ( + <> + + + + ), + }; + }, [ + activeProjectClone, + activeProjectRef, + cancelProjectClone, + removeClonedProject, + retryProjectClone, + runProjectCloneAction, + ]); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -2212,6 +2363,37 @@ export default function ChatView(props: ChatViewProps) { }, [retryEnvironment], ); + const disconnectDelayElapsed = useEnvironmentDisconnectDelay( + activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, + ); + const canDisconnectActiveEnvironment = + disconnectDelayElapsed && + activeEnvironment !== null && + activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && + !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); + const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); + const handleDisconnectActiveEnvironment = useCallback( + async (environmentId: EnvironmentId) => { + setDisconnectingEnvironment(true); + const result = await setEnvironmentEnabled({ environmentId, enabled: false }); + setDisconnectingEnvironment(false); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not disconnect server", + description: error instanceof Error ? error.message : "Failed to disconnect.", + }), + ); + } + return; + } + void navigate({ to: "/", replace: true }); + }, + [navigate, setEnvironmentEnabled], + ); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -2452,6 +2634,20 @@ export default function ChatView(props: ChatViewProps) { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; + const disconnectAction = + canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( + + ) : undefined; const environmentReconnecting = unavailableConnection !== null && (unavailableConnection.phase === "connecting" || @@ -2485,6 +2681,7 @@ export default function ChatView(props: ChatViewProps) { ), title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`, description: "Finishing an update", + actions: disconnectAction, }); } else { items.push({ @@ -2492,28 +2689,22 @@ export default function ChatView(props: ChatViewProps) { variant: unavailableConnection.phase === "error" ? "error" : "warning", icon: , title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - description: environmentReconnecting ? "Trying again" : "Reconnect to continue", actions: ( <> - - + {!environmentReconnecting ? ( + + ) : null} + {disconnectAction} ), }); @@ -2568,22 +2759,22 @@ export default function ChatView(props: ChatViewProps) { (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate) ? serverUpdateGuidance(versionMismatchSelfUpdate) : undefined, - actions: - updateInProgress || - !versionMismatch || + actions: updateInProgress ? ( + disconnectAction + ) : !versionMismatch || (versionMismatchSelfUpdate === "desktop-managed" && !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), + + ), ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) ? {} : { @@ -2607,7 +2798,9 @@ export default function ChatView(props: ChatViewProps) { activeEnvironmentUnavailableState, reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, - navigate, + canDisconnectActiveEnvironment, + disconnectingEnvironment, + handleDisconnectActiveEnvironment, setDismissedVersionMismatchKey, showVersionMismatchBanner, serverUpdateFailureDismissed, @@ -3294,6 +3487,78 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, ); const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); + // Live stages of a bootstrap worktree setup. The subscription follows the + // thread that was set up, not the route: a deleted bootstrap thread rotates + // the draft's thread id, and the failed card must survive that. + const worktreeSetupOwnerKey = draftId ?? routeThreadKey; + const worktreeSetupActive = + worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; + // The setup runs on the environment that received the dispatch, so both + // the subscription and cancel target that one even if the draft's machine + // picker changes underneath. + const worktreeSetupQuery = useEnvironmentQuery( + worktreeSetupActive + ? vcsEnvironment.worktreeSetup({ + environmentId: worktreeSetupRef.environmentId, + input: { threadId: worktreeSetupRef.threadId }, + }) + : null, + ); + const latestWorktreeSetup = worktreeSetupQuery.data; + useEffect(() => { + // The server drops finished snapshots after a grace period and emits null. + // Hold the last real snapshot so a settled card does not vanish. + if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); + }, [latestWorktreeSetup]); + const worktreeSetup = + worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId + ? heldWorktreeSetup + : null; + // A finished card is dropped once the agent's turn shows in the timeline: + // the card belongs to the send, and the agent takes over from there. An + // async setup script keeps the snapshot running past the handoff and its + // row leaves the moment the script exits cleanly; a failed script stays + // for the rest of the turn so the exit code and terminal remain reachable. + const worktreeSetupDoneAndTurnVisible = + worktreeSetup?.phase === "done" && + activeThread?.latestTurn?.startedAt != null && + (!isWorking || !worktreeSetup.stages.some((stage) => stage.status === "failed")); + useEffect(() => { + if (!worktreeSetupDoneAndTurnVisible) return; + setWorktreeSetupRef(null); + setHeldWorktreeSetup(null); + }, [worktreeSetupDoneAndTurnVisible]); + // The handoff entry only matters while the setup is still running: once it + // settles in any phase, a later mount of the thread must not adopt it. + const worktreeSetupSettledKey = + worktreeSetup && worktreeSetup.phase !== "running" && worktreeSetupRef + ? scopedThreadKey(scopeThreadRef(worktreeSetupRef.environmentId, worktreeSetupRef.threadId)) + : null; + useEffect(() => { + if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey); + }, [worktreeSetupSettledKey]); + const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { + reportFailure: false, + }); + const onCancelWorktreeSetup = useCallback(() => { + if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return; + void cancelWorktreeSetup({ + environmentId: worktreeSetupRef.environmentId, + input: { threadId: worktreeSetup.threadId }, + }); + }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]); + // The setup terminal belongs to the thread that was set up. A failed + // bootstrap deletes that thread and closes its terminals, so only offer the + // terminal while the setup thread is still the active one. + const onOpenWorktreeSetupTerminal = useMemo(() => { + if (!worktreeSetup || !activeThreadRef || worktreeSetup.threadId !== activeThreadRef.threadId) { + return null; + } + const setupThreadRef = activeThreadRef; + return (terminalId: string) => { + storeEnsureTerminal(setupThreadRef, terminalId, { open: true, active: true }); + }; + }, [activeThreadRef, storeEnsureTerminal, worktreeSetup]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3303,6 +3568,9 @@ export default function ChatView(props: ChatViewProps) { isWorking, draftHeroDockRequested, backgroundSubmissionPending, + // A cancelled or failed setup card stays on the draft's timeline; the + // hero headline would paint over it. + hasWorktreeSetupCard: worktreeSetup !== null, }); const [ attachDraftHeroTransitionGroupRef, @@ -5458,9 +5726,6 @@ export default function ChatView(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); - const createNewBranch = isLocalDraftThread - ? (draftThread?.createNewBranch ?? true) - : (pendingServerThreadCreateNewBranchByThreadId[activeThread?.id ?? ""] ?? true); const startFromOrigin = isLocalDraftThread ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode @@ -6106,10 +6371,12 @@ export default function ChatView(props: ChatViewProps) { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; + const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, ...usageLimitsItems, + ...projectCloneItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -6120,6 +6387,7 @@ export default function ChatView(props: ChatViewProps) { return [ ...feedbackBannerItems, ...usageLimitsItems, + ...projectCloneItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -6172,6 +6440,7 @@ export default function ChatView(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + projectCloneBannerItem, resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, @@ -6256,6 +6525,17 @@ export default function ChatView(props: ChatViewProps) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + const getShortcutContext = useCallback( + () => ({ + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen: Boolean(terminalUiState.terminalOpen), + previewFocus: isPreviewFocused(), + previewOpen: previewPanelOpen, + modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, + }), + [composerRef, previewPanelOpen, terminalUiState.terminalOpen], + ); + useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (preventRepeatedTerminalCloseShortcut(event, keybindings)) { @@ -6276,13 +6556,7 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && terminalFocusOwner === null) { return; } - const shortcutContext = { - terminalFocus: terminalFocusOwner !== null, - terminalOpen: Boolean(terminalUiState.terminalOpen), - previewFocus: isPreviewFocused(), - previewOpen: previewPanelOpen, - modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - }; + const shortcutContext = getShortcutContext(); if ( !shortcutContext.terminalFocus && @@ -6447,7 +6721,33 @@ export default function ChatView(props: ChatViewProps) { if (command === "modelPicker.toggle") { event.preventDefault(); event.stopPropagation(); - composerRef.current?.toggleModelPicker(); + if (!event.repeat) composerRef.current?.toggleModelPicker(); + return; + } + + if ( + command === "composer.host" || + command === "composer.effort" || + command === "composer.mode" || + command === "composer.workspace" + ) { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) composerRef.current?.openControl(command); + return; + } + + if (command === "composer.branch") { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) branchToolbarRef.current?.openBranchPicker(); + return; + } + + if (command === "composer.previousWorktree") { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree(); return; } @@ -6502,7 +6802,7 @@ export default function ChatView(props: ChatViewProps) { supportsSettlement, confirmAndUnpinThread, copyActiveThreadReference, - previewPanelOpen, + getShortcutContext, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -7088,7 +7388,7 @@ export default function ChatView(props: ChatViewProps) { const shouldCreateWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThreadBranch) { - setThreadError(threadIdForSend, "Select a branch before sending in New worktree mode."); + setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -7221,6 +7521,17 @@ export default function ChatView(props: ChatViewProps) { preparingWorktree: Boolean(baseBranchForWorktree), submissionIntent: resolvedSubmissionIntent, }); + setWorktreeSetupRef( + baseBranchForWorktree + ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey } + : null, + ); + if (baseBranchForWorktree) { + pendingWorktreeSetupByThreadKey.set( + scopedThreadKey(scopeThreadRef(environmentId, threadIdForSend)), + { environmentId, threadId: threadIdForSend }, + ); + } const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -7416,12 +7727,8 @@ export default function ChatView(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - ...(createNewBranch - ? { - branch: buildTemporaryWorktreeBranchName(randomHex), - ...(startFromOrigin ? { startFromOrigin: true } : {}), - } - : {}), + branch: buildTemporaryWorktreeBranchName(randomHex), + ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, } @@ -7503,7 +7810,6 @@ export default function ChatView(props: ChatViewProps) { envMode: sendEnvMode, branch: activeThreadBranch, startFromOrigin, - createNewBranch, }), ); if (nextDraft) { @@ -8312,16 +8618,69 @@ export default function ChatView(props: ChatViewProps) { ], ); - const onCreateNewBranchChange = (nextCreateNewBranch: boolean) => { - if (canOverrideServerThreadEnvMode && activeThread) { - setPendingServerThreadCreateNewBranchByThreadId((current) => ({ - ...current, - [activeThread.id]: nextCreateNewBranch, - })); - } else if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { createNewBranch: nextCreateNewBranch }); + // "Work locally" on the setup card: cancel the bootstrap and remember the + // draft. The cancelled dispatch deletes the half-made thread and puts the + // message back in the composer; the effect below then flips the draft to + // local mode and resends. The draft is a server thread for the whole + // setup (the bootstrap created it), so this keys off the route, not + // `isLocalDraftThread`. + const onWorktreeSetupWorkLocally = useCallback(() => { + if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running" || !draftId) { + return; } - }; + const target = { + environmentId: worktreeSetupRef.environmentId, + input: { threadId: worktreeSetup.threadId }, + }; + void (async () => { + const result = await cancelWorktreeSetup(target); + if (result._tag !== "Success" || !result.value.cancelled) return; + setWorkLocallyResendDraftId(draftId); + })(); + }, [cancelWorktreeSetup, draftId, worktreeSetup, worktreeSetupRef]); + const onSendRef = useRef(onSend); + onSendRef.current = onSend; + // Resend once the cancelled dispatch has settled and the composer is free. + // Every state that makes `onSend` bail and wait is part of the readiness + // check, so the flag survives a reconnect, a reverting checkpoint, or a + // feedback upload in between. What remains inside `onSend` are the checks + // that need the user to change something, and those should not auto retry. + const workLocallyResendReady = + workLocallyResendDraftId !== null && + workLocallyResendDraftId === draftId && + isLocalDraftThread && + !isSendBusy && + !isConnecting && + !isRevertingCheckpoint && + !threadDetailLoading && + clientSettingsHydrated && + !needsLoadBalancing && + !activeEnvironmentUnavailable && + !activePendingProgress && + !feedbackUploading; + useEffect(() => { + if ( + !workLocallyResendReady || + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) + ) { + return; + } + if (sendEnvMode !== "local") { + // The draft is back; switch it to the project checkout and let the next + // render resend. + setDraftThreadContext(composerDraftTarget, { envMode: "local", startFromOrigin: false }); + return; + } + setWorkLocallyResendDraftId(null); + void onSendRef.current(); + }, [ + composerDraftTarget, + routeThreadKey, + sendEnvMode, + setDraftThreadContext, + workLocallyResendReady, + ]); const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { if (canOverrideServerThreadEnvMode && activeThread) { @@ -8514,6 +8873,10 @@ export default function ChatView(props: ChatViewProps) { // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. { @@ -8763,6 +9126,10 @@ export default function ChatView(props: ChatViewProps) { isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} isCompacting={!paintOnlyDisplayedTimeline && isCompacting} activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} + worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} + onCancelWorktreeSetup={onCancelWorktreeSetup} + {...(draftId ? { onWorktreeSetupWorkLocally } : {})} + {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} listRef={legendListRef} timelineEntries={displayedTimeline.entries} latestTurn={paintOnlyDisplayedTimeline ? null : activeLatestTurn} @@ -8925,7 +9292,7 @@ export default function ChatView(props: ChatViewProps) { ? "Sending feedback" : threadDetailLoading ? "Messages loading" - : null + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} @@ -9030,13 +9397,12 @@ export default function ChatView(props: ChatViewProps) { {mountComposerContextStrip && (
getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), - [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + () => + getFilesystemBrowsePath( + query, + browseEnvironmentPlatform, + browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, + ), + [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; @@ -1553,6 +1561,14 @@ function OpenCommandPaletteDialog(props: { ); const openAddProjectFlow = useCallback(() => { + // With no environment at all there is nothing to browse, so the only + // useful next step is connecting one. + if (addProjectEnvironmentOptions.length === 0) { + setOpen(false); + void navigate({ to: "/settings/connections" }); + return; + } + if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) { pushPaletteView({ addonIcon: , @@ -1561,24 +1577,14 @@ function OpenCommandPaletteDialog(props: { return; } - const environmentId = defaultAddProjectEnvironmentId; - if (!environmentId) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to browse projects", - description: "No environment is available.", - }), - ); - return; - } - - void startAddProjectSourceSelection(environmentId); + void startAddProjectSourceSelection(defaultAddProjectEnvironmentId); }, [ addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, + navigate, pushPaletteView, + setOpen, startAddProjectSourceSelection, ]); @@ -1775,7 +1781,6 @@ function OpenCommandPaletteDialog(props: { "environment", ], title: "Add project", - disabled: defaultAddProjectEnvironmentId === null, icon: , keepOpen: true, run: async () => { @@ -2196,28 +2201,80 @@ function OpenCommandPaletteDialog(props: { return; } + // Older servers only offer the blocking clone: the palette has to wait + // for git so it can add the project afterwards. + if (browseEnvironment?.serverConfig?.environment.capabilities.projectCloneTracking !== true) { + setIsRemoteProjectCloning(true); + const cloneResult = await cloneRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { + remoteUrl: addProjectCloneFlow.remoteUrl, + destinationPath, + }, + }); + setIsRemoteProjectCloning(false); + if (cloneResult._tag === "Failure") { + if (!isAtomCommandInterrupted(cloneResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: errorMessage(squashAtomCommandFailure(cloneResult)), + }), + ); + } + return; + } + await handleAddProject(cloneResult.value.cwd); + return; + } + + // The server creates the project and clones in the background; progress + // shows in a toast and in the draft's composer banner, so the palette + // closes as soon as the clone is under way. Only problems found before + // git runs (bad destination, unknown repository) come back here. + const projectId = newProjectId(); setIsRemoteProjectCloning(true); - const cloneResult = await cloneRepository({ + const startResult = await startProjectClone({ environmentId: addProjectCloneFlow.environmentId, input: { + projectId, + title: inferProjectTitleFromPath(destinationPath), + createdAt: new Date().toISOString(), remoteUrl: addProjectCloneFlow.remoteUrl, destinationPath, }, }); setIsRemoteProjectCloning(false); - if (cloneResult._tag === "Failure") { - if (!isAtomCommandInterrupted(cloneResult)) { + if (startResult._tag === "Failure") { + if (!isAtomCommandInterrupted(startResult)) { toastManager.add( stackedThreadToast({ type: "error", title: "Clone failed", - description: errorMessage(squashAtomCommandFailure(cloneResult)), + description: errorMessage(squashAtomCommandFailure(startResult)), }), ); } return; } - await handleAddProject(cloneResult.value.cwd); + setOpen(false); + const projectRef = scopeProjectRef(addProjectCloneFlow.environmentId, projectId); + // The create event usually lands before this call returns; give the shell + // stream a moment so the draft opens with its project resolved instead of + // flashing the project picker. + await waitForProject(projectRef, 3_000).catch(() => null); + const navigationResult = await settlePromise(() => handleNewThread(projectRef)); + if (navigationResult._tag === "Failure") { + const error = squashAtomCommandFailure(navigationResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } } const browseTo = useCallback( diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx new file mode 100644 index 000000000000..d80ebf0d36fb --- /dev/null +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -0,0 +1,245 @@ +import { useEffect, useId, useState } from "react"; +import { Tabs } from "@base-ui/react/tabs"; +import { create } from "zustand"; +import { + localSnoozeDate, + localSnoozeTime, + resolveCustomSnooze, + type CustomSnoozeInput, +} from "@t3tools/client-runtime/state/thread-settled"; +import { Button } from "./ui/button"; +import { CalendarIcon } from "lucide-react"; +import { Calendar } from "./ui/calendar"; +import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { toggleVariants } from "./ui/toggle"; +import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select"; +import { + NumberField, + NumberFieldGroup, + NumberFieldInput, + NumberFieldDecrement, + NumberFieldIncrement, +} from "./ui/number-field"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogPanel, + DialogFooter, +} from "./ui/dialog"; + +type SnoozeChoice = { readonly snoozedUntil: string }; +type Request = { readonly resolve: (choice: SnoozeChoice | null) => void }; +const useRequest = create<{ request: Request | null }>(() => ({ request: null })); + +export function requestCustomSnooze(): Promise { + useRequest.getState().request?.resolve(null); + return new Promise((resolve) => useRequest.setState({ request: { resolve } })); +} + +function finish(choice: SnoozeChoice | null) { + const request = useRequest.getState().request; + useRequest.setState({ request: null }); + request?.resolve(choice); +} + +export function CustomSnoozeDialogHost() { + const request = useRequest((state) => state.request); + useEffect(() => () => finish(null), []); + return request ? : null; +} + +function CustomSnoozeDialog() { + const id = useId(); + const [initial] = useState(() => new Date(Date.now() + 3_600_000)); + const [mode, setMode] = useState("date"); + const [date, setDate] = useState(initial); + const [calendarOpen, setCalendarOpen] = useState(false); + const [time, setTime] = useState(localSnoozeTime(initial)); + const [amount, setAmount] = useState("2"); + const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); + const [error, setError] = useState(null); + const input: CustomSnoozeInput = + mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit }; + return ( + { + if (!open) finish(null); + }} + > + +
{ + event.preventDefault(); + const snoozedUntil = resolveCustomSnooze(input, new Date()); + if (!snoozedUntil) { + setError( + mode === "date" + ? "Choose a valid date and time in the future." + : "Enter a positive duration.", + ); + return; + } + finish({ snoozedUntil }); + }} + > + + Custom snooze + Choose when snoozed threads return to your inbox. + + + { + if (value === "date" || value === "duration") setMode(value); + setError(null); + }} + className="flex flex-col gap-4" + > + + {(["date", "duration"] as const).map((value) => ( + + {value === "date" ? "Date and time" : "Duration"} + + ))} + + + {mode === "date" ? ( +
+
+ + + + } + > + {date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + { + setDate(selected); + setCalendarOpen(false); + setError(null); + }} + /> + + +
+ +
+ ) : ( +
+ { + setAmount(value === null ? "" : String(value)); + setError(null); + }} + > + + + + + + + + +
+ )} +
+
+ {error && ( +

+ {error} +

+ )} +
+ + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/ProjectCloneToastCoordinator.tsx b/apps/web/src/components/ProjectCloneToastCoordinator.tsx new file mode 100644 index 000000000000..2c4a477a2f2e --- /dev/null +++ b/apps/web/src/components/ProjectCloneToastCoordinator.tsx @@ -0,0 +1,240 @@ +import { useParams } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + projectCloneDisplayName, + projectCloneProgressSummary, + type EnvironmentId, + type ProjectCloneSnapshot, + type ProjectId, +} from "@t3tools/contracts"; +import { useCallback, useEffect, useRef } from "react"; + +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; +import { useEnvironments } from "../state/environments"; +import { useEnvironmentProjectClones } from "../state/projectClones"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { useAtomCommand } from "../state/use-atom-command"; +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { toastManager } from "./ui/toast"; +import { stackedThreadToast } from "./ui/toastHelpers"; + +/** + * One toast per clone in flight, on every environment. The palette that + * started a clone closes right away, so this is where its progress lives: + * the toast updates in place as git reports stages, then settles into a + * success or failure state with the matching action. + */ +export function ProjectCloneToastCoordinator() { + const { environments } = useEnvironments(); + return environments.map((environment) => ( + + )); +} + +interface TrackedToast { + readonly toastId: ReturnType; + /** The last snapshot rendered, so an identical redraw does not touch the toast. */ + readonly renderedKey: string; + readonly phase: ProjectCloneSnapshot["phase"]; +} + +function renderKey(clone: ProjectCloneSnapshot): string { + return `${clone.phase}:${clone.stage}:${clone.percent ?? ""}:${clone.detail ?? ""}:${clone.error ?? ""}`; +} + +function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentId }) { + const clones = useEnvironmentProjectClones(environmentId); + const handleNewThread = useNewThreadHandler(); + const { draftId: routeDraftId } = useParams({ strict: false }); + const cancelClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { + reportFailure: false, + }); + const retryClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { + reportFailure: false, + }); + // The toast mirrors the server's clone state, so a request that never got + // there needs its own feedback. + const runCloneAction = useCallback( + async (title: string, action: () => Promise>) => { + const result = await action(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [], + ); + const removeClonedProject = useRemoveClonedProject(); + const toasts = useRef(new Map()); + + // Whether the user is already looking at this project's draft: the composer + // banner shows the same progress and actions there, so the toast steps + // aside and comes back if they navigate away mid-clone. + const isViewingProjectDraft = useCallback( + (projectId: ProjectId) => { + if (!routeDraftId) return false; + const draft = useComposerDraftStore.getState().getDraftSession(routeDraftId as DraftId); + return draft?.environmentId === environmentId && draft.projectId === projectId; + }, + [environmentId, routeDraftId], + ); + + const openProject = useCallback( + (projectId: ProjectId) => { + void handleNewThread(scopeProjectRef(environmentId, projectId)); + }, + [environmentId, handleNewThread], + ); + + useEffect(() => { + const seen = new Set(); + for (const clone of clones) { + seen.add(clone.projectId); + const key = renderKey(clone); + const tracked = toasts.current.get(clone.projectId); + const name = projectCloneDisplayName(clone); + // Handlers run later than this pass, so they look the toast up then. + const closeToast = () => { + const current = toasts.current.get(clone.projectId); + if (!current) return; + toastManager.close(current.toastId); + toasts.current.delete(clone.projectId); + }; + if (isViewingProjectDraft(clone.projectId)) { + closeToast(); + continue; + } + if (tracked?.renderedKey === key) continue; + + if (clone.phase === "running") { + const options = stackedThreadToast({ + type: "loading", + title: `Cloning ${name}`, + description: projectCloneProgressSummary(clone), + timeout: 0, + actionProps: { + children: "Cancel", + onClick: () => { + void runCloneAction("Failed to cancel clone", () => + cancelClone({ environmentId, input: { projectId: clone.projectId } }), + ); + }, + }, + data: { hideCopyButton: true }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "running" }); + } else { + const toastId = toastManager.add(options); + toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "running" }); + } + continue; + } + + if (clone.phase === "done") { + const options = stackedThreadToast({ + type: "success", + title: `Cloned ${name}`, + description: clone.destinationPath, + timeout: 8_000, + actionProps: { + children: "Open project", + onClick: () => { + closeToast(); + openProject(clone.projectId); + }, + }, + data: { hideCopyButton: true }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "done" }); + } else { + const toastId = toastManager.add(options); + toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "done" }); + } + continue; + } + + // Failed or cancelled: the project stays, pointing at an empty folder. + // Retry from here; the draft's composer banner offers the same. + const cancelled = clone.phase === "cancelled"; + const options = stackedThreadToast({ + type: cancelled ? "info" : "error", + title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, + description: cancelled ? clone.destinationPath : (clone.error ?? "The clone failed."), + timeout: 0, + actionProps: { + children: "Retry", + onClick: () => { + void runCloneAction("Failed to retry clone", () => + retryClone({ environmentId, input: { projectId: clone.projectId } }), + ); + }, + }, + data: { + ...(cancelled ? { hideCopyButton: true } : {}), + secondaryActionProps: { + children: "Remove project", + onClick: () => { + // The server drops the clone with the project, which closes + // this toast; a failed removal leaves it (and Retry) in place. + void removeClonedProject({ environmentId, projectId: clone.projectId }); + }, + }, + }, + }); + if (tracked) { + toastManager.update(tracked.toastId, options); + toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: clone.phase }); + } else { + const toastId = toastManager.add(options); + toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: clone.phase }); + } + } + + // A clone the server stopped tracking (done and expired, or its project + // was removed) takes its toast with it, unless it already settled into a + // timed success toast that dismisses itself. + for (const [projectId, tracked] of toasts.current) { + if (seen.has(projectId)) continue; + if (tracked.phase !== "done") toastManager.close(tracked.toastId); + toasts.current.delete(projectId); + } + }, [ + cancelClone, + clones, + environmentId, + isViewingProjectDraft, + openProject, + removeClonedProject, + retryClone, + runCloneAction, + ]); + + useEffect( + () => () => { + for (const tracked of toasts.current.values()) toastManager.close(tracked.toastId); + toasts.current.clear(); + }, + [], + ); + + return null; +} diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 854301bee7bc..b60cf3ae62c4 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -122,36 +122,28 @@ describe("ProjectFavicon", () => { testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; }); - it("shows a project-name icon when no favicon exists", () => { + it("shows the project monogram when no favicon exists", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }), }) as ReactElement<{ - readonly colorClassName?: string; - readonly emoji?: string; - readonly icon?: ComponentType<{ className?: string }>; + readonly projectName?: string; }>; - expect(element.props.icon).toBeDefined(); - expect(element.props.emoji).toBeUndefined(); - expect(element.props.colorClassName).toContain("text-cyan-600"); + expect(element.props.projectName).toBe("analytics-db"); }); - it("chooses a deterministic semantic icon", () => { + it("uses the same monogram fallback for every project category", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }), }) as ReactElement<{ - readonly colorClassName?: string; - readonly emoji?: string; - readonly icon?: ComponentType<{ className?: string }>; + readonly projectName?: string; }>; - expect(element.props.icon).toBeDefined(); - expect(element.props.emoji).toBeUndefined(); - expect(element.props.colorClassName).toContain("text-violet-600"); + expect(element.props.projectName).toBe("agent-runtime"); }); it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 15597e786c31..17006889fb9c 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,39 +1,15 @@ -import type { ProjectIconColor } from "@t3tools/contracts"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { - BotIcon, - BookOpenIcon, - BracesIcon, - CircuitBoardIcon, - CloudCogIcon, - Code2Icon, - DatabaseIcon, - FlaskConicalIcon, - FolderCodeIcon, - Gamepad2Icon, - Globe2Icon, - ImageIcon, - Layers3Icon, - MonitorIcon, - MusicIcon, - PackageIcon, - ServerIcon, - ShieldCheckIcon, - ShoppingBagIcon, - SmartphoneIcon, - TerminalIcon, - VideoIcon, -} from "lucide-react"; +import { FolderCodeIcon } from "lucide-react"; import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; import { lazy, Suspense, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { projectFaviconUrlAtom } from "../state/assets"; -import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; +import { deriveProjectIdentity } from "../projectIdentity"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; @@ -45,56 +21,6 @@ function DynamicProjectIconFallback() { return ; } -const PROJECT_ICONS: Record> = { - ai: BotIcon, - book: BookOpenIcon, - braces: BracesIcon, - circuit: CircuitBoardIcon, - cloud: CloudCogIcon, - code: Code2Icon, - database: DatabaseIcon, - desktop: MonitorIcon, - "folder-code": FolderCodeIcon, - game: Gamepad2Icon, - image: ImageIcon, - layers: Layers3Icon, - mobile: SmartphoneIcon, - music: MusicIcon, - package: PackageIcon, - security: ShieldCheckIcon, - server: ServerIcon, - shopping: ShoppingBagIcon, - terminal: TerminalIcon, - test: FlaskConicalIcon, - video: VideoIcon, - web: Globe2Icon, -}; - -const PROJECT_ICON_COLOR_BY_NAME: Record = { - ai: "violet", - book: "amber", - braces: "purple", - circuit: "teal", - cloud: "sky", - code: "blue", - database: "cyan", - desktop: "indigo", - "folder-code": "orange", - game: "emerald", - image: "pink", - layers: "fuchsia", - mobile: "lime", - music: "fuchsia", - package: "orange", - security: "teal", - server: "blue", - shopping: "rose", - terminal: "green", - test: "yellow", - video: "red", - web: "sky", -}; - // The slice of a project that decides its icon. Every surface must pass the // project record itself (or a snapshot spread from it) so the saved title, favicon // and icon override always travel together. Passing a display label as the title @@ -103,7 +29,6 @@ export type ProjectFaviconProject = Pick< EnvironmentProject, "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon" >; - export function ProjectFavicon(input: { project: ProjectFaviconProject; className?: string | undefined; @@ -118,7 +43,13 @@ export function ProjectFavicon(input: { }), ); if (project.projectIcon?.kind === "emoji") { - return ; + return ( + + ); } if (project.projectIcon?.kind === "lucide") { const colorClassName = projectIconColorClassName(project.projectIcon.color); @@ -139,25 +70,14 @@ export function ProjectFavicon(input: { ); } - const automaticIconName = input.fallbackIcon - ? null - : selectProjectIcon(project.title, project.workspaceRoot); - const FallbackIcon = - input.fallbackIcon ?? - (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined); - const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined; - const fallbackColorClassName = - automaticIconName?.kind === "lucide" - ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon]) - : undefined; + const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon; if (!src || isProjectFaviconFallbackUrl(src)) { return ( ); } @@ -174,23 +94,70 @@ export function ProjectFavicon(input: { src={src} className={input.className} fallbackIcon={FallbackIcon} - fallbackEmoji={fallbackEmoji} - fallbackColorClassName={fallbackColorClassName} + fallbackProjectName={project.title} /> ); } function ProjectFaviconFallback({ className, - colorClassName, icon: Icon, emoji, + projectName, }: { readonly className?: string | undefined; - readonly colorClassName?: string | undefined; - readonly icon?: ComponentType<{ className?: string }> | undefined; + readonly icon: ComponentType<{ className?: string }>; readonly emoji?: string | undefined; + readonly projectName?: string | undefined; }) { + if (projectName && projectName.trim().length > 0) { + const identity = deriveProjectIdentity(projectName); + // Wrapped like the emoji and Lucide branches so the monogram sits where an + // favicon would. Menu items, buttons and the like pull every bare svg + // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this + // tile has no such padding. + return ( + + ); + } + if (emoji) { return ( ; + return ; } function ProjectFaviconImage({ src, className, fallbackIcon: FallbackIcon, - fallbackEmoji, - fallbackColorClassName, + fallbackProjectName, }: { readonly src: string; readonly className?: string | undefined; - readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined; - readonly fallbackEmoji?: string | undefined; - readonly fallbackColorClassName?: string | undefined; + readonly fallbackIcon: ComponentType<{ className?: string }>; + readonly fallbackProjectName?: string | undefined; }) { const [displayedSrc, setDisplayedSrc] = useState(() => src.startsWith("data:image/") ? src : null, @@ -235,9 +199,8 @@ function ProjectFaviconImage({ {displayedSrc === null ? ( ) : null} {displayedSrc ? ( diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 304922909b0a..15cca412ba43 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -113,6 +113,7 @@ export default function ProjectScriptsControl({ command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, + waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, keybinding: null, previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index 1058dfcd8859..c757eca87aa4 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -253,6 +253,37 @@ describe("sidebar collision detection", () => { }); describe("sidebar drag projection", () => { + it.each([ + ["a1", "a2"], + ["a1", sidebarMarkerId("settled-header")], + ["z", "a2"], + ])("keeps sparse shelves at the bottom when dragging %s over %s", (active, over) => { + const items = [ + pinnedHeader, + divider, + thread("a1", "active"), + thread("a2", "active"), + marker("snoozed-header"), + thread("z", "snoozed"), + settledHeader, + thread("s", "settled"), + ]; + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: over === sidebarMarkerId("settled-header") ? ["a1", "s"] : ["s"], + settledExpanded: true, + boundaryLabelHeight: 24, + snoozedThreadCount: 1, + }); + const args = layout(items, active, over); + for (const rect of args.rects.slice(4)) { + rect.top += 400; + rect.bottom += 400; + } + const lastIndex = items.length - 1; + expect(strategy({ ...args, index: lastIndex })).toEqual(stationary); + }); + const pinned = [ pinnedHeader, thread("p1", "pinned"), diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 0aef9dec14b1..bfd112d18998 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -190,27 +190,54 @@ export function createSidebarSortingStrategy(input: { } marker("settled-header"); section("settled"); - const result = items.map(() => hidden); - let top = rects[0].top; - for (const item of projected) { + const heights = projected.map((item) => { const index = indices.get(sidebarListItemId(item)); const rect = index === undefined ? undefined : rects[index]; - if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top }; const fallback = item.kind === "thread" && (item.section === "pinned" || item.section === "active") ? cardHeight : slimHeight; const moved = item.kind === "thread" && item.key === active.key; - const height = - item.kind === "marker" && + return item.kind === "marker" && (item.marker === "pinned-header" || item.marker === "pinned-divider") - ? labelHeight - : item.kind === "marker" && item.marker.endsWith("placeholder") - ? slimHeight - : moved - ? fallback - : (rect?.height ?? fallback); - top += height + 1; + ? labelHeight + : item.kind === "marker" && item.marker.endsWith("placeholder") + ? slimHeight + : moved + ? fallback + : (rect?.height ?? fallback); + }); + const firstShelf = items.findIndex( + (item) => + item.kind === "marker" && + (item.marker === "snoozed-header" || item.marker === "settled-header"), + ); + const shelfRect = rects[firstShelf]; + const beforeShelf = rects[firstShelf - 1]; + const lastRect = rects.at(-1); + // Consume the shelf's auto margin as drag labels and resized rows need + // room, keeping the combined shelves at their measured bottom. + let shelfSpace = + shelfRect && beforeShelf && lastRect && shelfRect.top > beforeShelf.bottom + 1 + ? Math.max( + 0, + lastRect.bottom - rects[0].top - heights.reduce((sum, height) => sum + height + 1, -1), + ) + : 0; + const result = items.map(() => hidden); + let top = rects[0].top; + for (const [projectedIndex, item] of projected.entries()) { + if ( + item.kind === "marker" && + (item.marker === "snoozed-header" || item.marker === "settled-header") + ) { + top += shelfSpace; + shelfSpace = 0; + } + const index = indices.get(sidebarListItemId(item)); + const rect = index === undefined ? undefined : rects[index]; + if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top }; + top += heights[projectedIndex]! + 1; } result[activeIndex] = stationary; return result; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e7a55e16e0cd..6b0f3d7c11ef 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; @@ -439,7 +440,7 @@ function SidebarThreadTooltip({ function SnoozePopoverButton(props: { open: boolean; onOpenChange: (open: boolean) => void; - onSnooze: (preset: SnoozePreset) => void; + onSnooze: (preset: Pick) => void; timestampFormat: TimestampFormat; }) { const { open, onOpenChange, onSnooze, timestampFormat } = props; @@ -489,6 +490,19 @@ function SnoozePopoverButton(props: { ))} +
+ ); @@ -633,6 +647,7 @@ function SidebarDragBoundary(props: { function SidebarSectionHeader(props: { marker: "snoozed-header" | "settled-header"; label: string; + className?: string; // While dragging, the settled header reads at full strength and takes the // accent while the lifted row is over it. dragging?: boolean; @@ -671,7 +686,7 @@ function SidebarSectionHeader(props: { ) : ( diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 039f12d40873..78cfb7b8d8fa 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -40,6 +40,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls variant="ghost" className={size === "xs" ? "shrink-0" : "shrink-0 px-2"} aria-label="More composer controls" + data-composer-shortcut={ + props.traitsMenuContent ? "composer.mode composer.effort" : "composer.mode" + } /> } > diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx new file mode 100644 index 000000000000..c803d5693009 --- /dev/null +++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx @@ -0,0 +1,29 @@ +import { memo, useEffect, useState, type ReactNode } from "react"; + +import { createComposerImageThumbnail } from "../../lib/imageCompression"; + +/** Keep full-resolution image decoding out of composer rerenders. */ +export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ + file, + alt, + className, + fallback, +}: { + file: File; + alt: string; + className: string; + fallback: ReactNode; +}) { + const [preview, setPreview] = useState<{ file: File; src: string | null } | null>(null); + useEffect(() => { + let active = true; + void createComposerImageThumbnail(file).then((src) => { + if (active) setPreview({ file, src }); + }); + return () => { + active = false; + }; + }, [file]); + const src = preview?.file === file ? preview.src : null; + return src ? {alt} : fallback; +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5fbc686227d9..39cd8f8318a8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -10,6 +10,7 @@ import { ThreadId, TurnId, type OrchestrationThread, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { applyThreadDetailEvent, @@ -31,6 +32,7 @@ import { shouldPreserveAssistantLineBreaks, type MessagesTimelineRow, type MessagesTimelineRowsProjection, + WORKTREE_SETUP_ROW_ID, workEntryDisplayLabel, } from "./MessagesTimeline.logic"; import { @@ -1090,6 +1092,150 @@ describe("resolveAssistantMessageCopyState", () => { }); describe("deriveMessagesTimelineRows", () => { + it("shows the worktree setup card instead of the working placeholder", () => { + const snapshot: WorktreeSetupSnapshot = { + threadId: ThreadId.make("thread-setup"), + phase: "running", + startedAt: "2026-01-01T00:00:00Z", + endedAt: null, + branch: "feature", + baseRef: "main", + worktreePath: null, + setupScript: null, + stages: [], + error: null, + sequence: 3, + }; + const userEntry = { + id: "user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "user-1" as never, + role: "user", + text: "Build it", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + } as const; + const assistantEntry = { + id: "assistant-entry", + kind: "message", + createdAt: "2026-01-01T00:00:30Z", + message: { + id: "assistant-1" as never, + role: "assistant", + text: "On it", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: true, + }, + } as const; + const withoutMessages = deriveMessagesTimelineRows({ + timelineEntries: [], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: snapshot, + }); + expect(withoutMessages).toEqual([ + { + kind: "worktree-setup", + id: WORKTREE_SETUP_ROW_ID, + createdAt: "2026-01-01T00:00:00Z", + snapshot, + embedded: false, + }, + ]); + + // A failed setup never handed off, so the card stays under the send. + const withMessages = deriveMessagesTimelineRows({ + timelineEntries: [userEntry, assistantEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: { ...snapshot, phase: "failed" }, + }); + expect(withMessages.map((row) => row.kind)).toEqual([ + "message", + "worktree-setup", + "working", + "message", + ]); + + // Once the agent stage is done the setup script may still be running in + // the background: the turn owns the header and the script row follows it. + const stage = (id: "agent" | "setup-script", status: "done" | "running") => + ({ + id, + status, + startedAt: "2026-01-01T00:00:10Z", + endedAt: status === "done" ? "2026-01-01T00:00:11Z" : null, + percent: null, + detail: null, + tail: [], + }) as const; + const asyncSnapshot: WorktreeSetupSnapshot = { + ...snapshot, + stages: [stage("setup-script", "running"), stage("agent", "done")], + }; + const liveTurn = { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:11Z", + completedAt: null, + } as const; + const asyncRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + latestTurn: liveTurn, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(asyncRows.map((row) => row.kind)).toEqual([ + "message", + "working", + "worktree-setup", + "thinking", + ]); + expect(asyncRows[2]).toMatchObject({ kind: "worktree-setup", embedded: true }); + + // Dispatched but not yet visible as a turn: the full card stays put so + // nothing collapses during the handoff. + const handoffRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(handoffRows.map((row) => row.kind)).toEqual(["message", "worktree-setup"]); + expect(handoffRows[1]).toMatchObject({ kind: "worktree-setup", embedded: false }); + + // A script that already finished has nothing left to show once the turn is live. + const finishedRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + latestTurn: liveTurn, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: { + ...asyncSnapshot, + stages: [stage("setup-script", "done"), stage("agent", "done")], + }, + }); + expect(finishedRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]); + }); + it("keeps context compaction visible outside folded work", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8dcb539fb024..89bcf214783f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -28,7 +28,12 @@ import { type WorkLogEntry, } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; +import { + type MessageId, + type OrchestrationLatestTurn, + type TurnId, + type WorktreeSetupSnapshot, +} from "@t3tools/contracts"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; const TIMELINE_MINIMAP_ITEM_SPACING = 8; @@ -389,6 +394,14 @@ export type MessagesTimelineRow = kind: "thinking"; id: string; createdAt: string | null; + } + | { + kind: "worktree-setup"; + id: string; + createdAt: string | null; + snapshot: WorktreeSetupSnapshot; + /** The agent already started; render only the script row under the turn header. */ + embedded: boolean; }; export interface StableMessagesTimelineRowsState { @@ -857,6 +870,8 @@ export function deriveMessagesTimelineRows(input: { supportsConversationRollback: boolean; /** Task ids of subagents still working, used by the active tool indicator. */ liveAgentTaskIds?: ReadonlySet | undefined; + /** Live bootstrap progress. Renders a stage card under the first user message. */ + worktreeSetup?: WorktreeSetupSnapshot | null; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -1247,9 +1262,66 @@ export function deriveMessagesTimelineRows(input: { }); } + // Until the agent's turn is live, the setup card takes the place of the + // working and thinking placeholders. It stays after a failed or cancelled + // setup so the outcome and its actions remain visible until the thread + // state moves on. "Live" means the turn is in the timeline, not just that + // the server dispatched it: the card must not collapse in the gap between. + const setupHandedOff = + input.worktreeSetup !== null && + input.worktreeSetup !== undefined && + worktreeSetupAgentStarted(input.worktreeSetup) && + input.latestTurn?.startedAt != null; + if (input.worktreeSetup && !setupHandedOff) { + const setupRow = { + kind: "worktree-setup", + id: WORKTREE_SETUP_ROW_ID, + createdAt: input.worktreeSetup.startedAt, + snapshot: input.worktreeSetup, + embedded: false, + } as const; + // Sit directly under the first user message: a finished snapshot can + // outlive the first assistant reply, and it belongs to the send, not the + // end of the thread. + const firstUserRowIndex = nextRows.findIndex( + (row) => row.kind === "message" && row.message.role === "user", + ); + if (firstUserRowIndex >= 0) { + nextRows.splice(firstUserRowIndex + 1, 0, setupRow); + } else { + nextRows.push(setupRow); + } + return attachTrailingToolGroupsToAssistant(nextRows); + } + if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } + // An async setup script outlives the handoff. The turn owns the header, so + // the script's row sits first under it, ahead of the agent's own work. A + // script that already finished (or never ran) has nothing left to show. + const setupScriptStage = input.worktreeSetup?.stages.find((stage) => stage.id === "setup-script"); + if ( + input.worktreeSetup && + setupHandedOff && + (setupScriptStage?.status === "running" || setupScriptStage?.status === "failed") + ) { + const setupRow = { + kind: "worktree-setup", + id: WORKTREE_SETUP_ROW_ID, + createdAt: input.worktreeSetup.startedAt, + snapshot: input.worktreeSetup, + embedded: true, + } as const; + const workingRowIndex = nextRows.findIndex((row) => row.kind === "working"); + if (workingRowIndex >= 0) { + nextRows.splice(workingRowIndex + 1, 0, setupRow); + } else { + // The turn already finished (or has not been dispatched yet): the row + // trails the reply so a still-running script stays visible after it. + nextRows.push(setupRow); + } + } if (input.isWorking && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", @@ -1261,6 +1333,13 @@ export function deriveMessagesTimelineRows(input: { return attachTrailingToolGroupsToAssistant(nextRows); } +export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; + +/** True once the bootstrap handed off to the agent (async setup script may still run). */ +function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { + return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); +} + type MessagesTimelineRowsInput = Parameters[0]; export interface MessagesTimelineRowsProjection { @@ -1363,6 +1442,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "working": case "thinking": return a.createdAt === (b as typeof a).createdAt; + case "worktree-setup": + return a.snapshot === (b as typeof a).snapshot; case "assistant-meta": { const bm = b as typeof a; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6991f4432594..0987b852e716 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -791,7 +791,6 @@ describe("MessagesTimeline", () => { expect(markup).toContain(" { expect(markup).not.toContain(" { + const entries = [buildUserTimelineEntry("Hello")]; + const working = renderToStaticMarkup( + , + ); + expect(working).toContain('data-maintain-scroll-at-end-animated="true"'); + + const idle = renderToStaticMarkup( + , + ); + expect(idle).toContain('data-maintain-scroll-at-end-animated="false"'); + }); + + it("snaps to the end while a thread switch settles, even mid-turn", async () => { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const flushFrame = () => + act(() => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(0)); + }); + // A work entry renders without the DOM globals that message rows need + // under react-test-renderer. + const entries = [ + { + id: "entry-settle-work", + kind: "work" as const, + createdAt: MESSAGE_CREATED_AT, + entry: { + id: "work-settle", + createdAt: MESSAGE_CREATED_AT, + toolCallId: "call-settle", + label: "Run lint", + tone: "tool" as const, + itemType: "command_execution" as const, + command: "pnpm lint", + toolLifecycleStatus: "completed" as const, + }, + }, + ]; + const animatedAttr = (renderer: ReactTestRenderer) => + renderer.root.findByProps({ "data-testid": "legend-list" }).props[ + "data-maintain-scroll-at-end-animated" + ]; + let renderer!: ReactTestRenderer; + try { + act(() => { + renderer = create( + , + ); + }); + expect(animatedAttr(renderer)).toBe(true); + + act(() => { + renderer.update( + , + ); + }); + expect(animatedAttr(renderer)).toBe(false); + + // Two frames later the switch has settled and gliding resumes. + flushFrame(); + flushFrame(); + expect(animatedAttr(renderer)).toBe(true); + } finally { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + it("keeps reserved end space when tool work starts while reading history", () => { const turnId = TurnId.make("turn-with-active-tool"); const firstEntry = buildUserTimelineEntry("Run the command."); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 568a86721e4c..b6383724e036 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -19,6 +19,7 @@ import { type ServerProviderSkill, type ToolActivityIcon, type TurnId, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; @@ -188,6 +189,7 @@ import { } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { ContextChipPopover as UserMessageContextPopover, ContextChipShell, @@ -227,6 +229,7 @@ import { import { createContextPresentationRegistry } from "../contextPresentationRegistry"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -274,6 +277,9 @@ interface TimelineRowSharedState { agentPanelModel: AgentPanelModel; expandedSpawnEntryIds: ReadonlySet; onOpenAgents: () => void; + onCancelWorktreeSetup: (() => void) | null; + onWorktreeSetupWorkLocally: (() => void) | null; + onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; } interface TimelineRowActivityState { @@ -348,6 +354,13 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { layout: true, }, } as const satisfies MaintainScrollAtEndOptions; +// Streamed text lands a paragraph at a time. A smooth scroll to the end +// turns each landing into a short glide instead of a jump. Thread switches +// and layout settles keep the instant variant so nothing visibly travels. +const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = { + ...TIMELINE_MAINTAIN_SCROLL_AT_END, + animated: true, +} as const satisfies MaintainScrollAtEndOptions; // --------------------------------------------------------------------------- // Props (public API) @@ -366,6 +379,11 @@ interface MessagesTimelineProps { isPreparingWorktree?: boolean; isCompacting?: boolean; activeTurnStartedAt: string | null; + /** Live bootstrap progress for this thread, or null when none is tracked. */ + worktreeSetup?: WorktreeSetupSnapshot | null; + onCancelWorktreeSetup?: () => void; + onWorktreeSetupWorkLocally?: () => void; + onOpenWorktreeSetupTerminal?: (terminalId: string) => void; listRef: React.RefObject; timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; @@ -425,6 +443,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ citationHistoryLoading = false, onCiteAssistantText, isWorking, + worktreeSetup = null, + onCancelWorktreeSetup, + onWorktreeSetupWorkLocally, + onOpenWorktreeSetupTerminal, isPreparingWorktree = false, isCompacting = false, activeTurnStartedAt, @@ -470,14 +492,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ new Set(), ); const listIdentityKey = displayThreadKey ?? routeThreadKey; + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const listIdentityRef = useRef(listIdentityKey); const previousLatestTurnRef = useRef(latestTurn); + // The list stays mounted across thread switches. Its first end pins on the + // new thread must snap, not glide, even if that thread is mid-turn. + const [settlingListIdentity, setSettlingListIdentity] = useState(null); let paintedExpandedTurnIds = expandedTurnIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds; if (listIdentityRef.current !== listIdentityKey) { listIdentityRef.current = listIdentityKey; previousLatestTurnRef.current = latestTurn; + setSettlingListIdentity(listIdentityKey); paintedExpandedTurnIds = new Set(); paintedExpandedWorkGroupIds = new Set(); paintedExpandedSpawnEntryIds = new Set(); @@ -522,6 +549,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, []); + useEffect(() => { + if (settlingListIdentity === null) return; + // Two frames covers the fresh-data layout pass and the initial end pin. + let second: number | null = null; + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => { + setSettlingListIdentity((current) => (current === settlingListIdentity ? null : current)); + }); + }); + return () => { + cancelAnimationFrame(first); + if (second !== null) cancelAnimationFrame(second); + }; + }, [settlingListIdentity]); + const suspendEndScrollMaintenanceForDisclosure = useCallback( (anchorKey: string, collapsed = false) => { disclosureAnchorKeyRef.current = anchorKey; @@ -664,6 +706,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, + worktreeSetup, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -685,6 +728,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, + worktreeSetup, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -877,6 +921,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL, expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, onOpenAgents, + onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, + onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, + onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, }), [ readyCitationRequest, @@ -904,6 +951,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ agentPanelModel, paintedExpandedSpawnEntryIds, onOpenAgents, + onCancelWorktreeSetup, + onWorktreeSetupWorkLocally, + onOpenWorktreeSetupTerminal, ], ); const activityState = useMemo( @@ -977,7 +1027,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ !liveFollowEnabled || disclosureToggleSettling ? false - : TIMELINE_MAINTAIN_SCROLL_AT_END + : isWorking && !prefersReducedMotion && settlingListIdentity === null + ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ citationPositioning ? false : maintainVisibleContentPosition @@ -1356,7 +1408,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "work" || row.kind === "work-live" || row.kind === "work-toggle" || - row.kind === "thinking" + row.kind === "thinking" || + row.kind === "worktree-setup" ? "pb-2" : "pb-4", (row.kind === "message" && row.message.role === "assistant") || @@ -1391,10 +1444,36 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} {row.kind === "thinking" ? : null} + {row.kind === "worktree-setup" ? : null}
); }); +function WorktreeSetupTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + const terminalId = row.snapshot.setupScript?.terminalId ?? null; + const openTerminal = ctx.onOpenWorktreeSetupTerminal; + const onOpenTerminal = useMemo( + () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null), + [openTerminal, terminalId], + ); + return ( + + ); +} + function ContextCompactionTimelineRow({ row, }: { @@ -1446,6 +1525,10 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { } label={file.name} preload="visible" + onOpen={() => { + const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file); + if (preview) ctx.onImageExpand(preview); + }} className="block aspect-[4/3] w-full" videoClassName="aspect-auto size-full rounded-lg border border-border/80" stateClassName="aspect-auto min-h-full rounded-lg border border-border/80 bg-black text-white" @@ -4063,6 +4146,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { className={cn( "flex flex-col rounded-md px-0.5 transition-colors", isExpandedToolGroupEntry ? "py-0" : "py-0.5", + expanded && "mb-1", canExpand && "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", )} diff --git a/apps/web/src/components/chat/ModelPickerContent.test.ts b/apps/web/src/components/chat/ModelPickerContent.test.ts index 5b9cfd0ff5f2..50db51bfa068 100644 --- a/apps/web/src/components/chat/ModelPickerContent.test.ts +++ b/apps/web/src/components/chat/ModelPickerContent.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { + adjacentModelPickerProvider, resolveModelPickerSelectedModel, shouldIncludeModelPickerOption, shouldOfferModelPickerSetup, @@ -213,3 +214,74 @@ describe("shouldOfferModelPickerSetup", () => { ).toBe(true); }); }); + +describe("adjacentModelPickerProvider", () => { + const codex = entry("ready", "codex"); + const claude = entry("ready", "claudeAgent"); + const unavailable = entry("error"); + const input = { + entries: [codex, unavailable, claude], + disabledInstanceIds: undefined, + selectableUnavailableInstanceIds: undefined, + }; + + it("wraps through favorites and ready instances, skipping unavailable providers", () => { + expect( + adjacentModelPickerProvider({ ...input, selectedInstanceId: codex.instanceId, direction: 1 }), + ).toBe(claude.instanceId); + expect( + adjacentModelPickerProvider({ ...input, selectedInstanceId: "favorites", direction: -1 }), + ).toBe(claude.instanceId); + expect( + adjacentModelPickerProvider({ + ...input, + selectedInstanceId: claude.instanceId, + direction: 1, + }), + ).toBe("favorites"); + }); + + it("keeps thread locks and the selected unavailable catalog", () => { + expect( + adjacentModelPickerProvider({ + ...input, + disabledInstanceIds: new Set([claude.instanceId]), + selectedInstanceId: codex.instanceId, + direction: 1, + }), + ).toBe("favorites"); + expect( + adjacentModelPickerProvider({ + ...input, + selectableUnavailableInstanceIds: new Set([unavailable.instanceId]), + selectedInstanceId: codex.instanceId, + direction: 1, + }), + ).toBe(unavailable.instanceId); + }); + + it("handles an empty catalog and a removed selection in either direction", () => { + expect( + adjacentModelPickerProvider({ + ...input, + entries: [], + selectedInstanceId: codex.instanceId, + direction: -1, + }), + ).toBe("favorites"); + expect( + adjacentModelPickerProvider({ + ...input, + selectedInstanceId: unavailable.instanceId, + direction: 1, + }), + ).toBe("favorites"); + expect( + adjacentModelPickerProvider({ + ...input, + selectedInstanceId: unavailable.instanceId, + direction: -1, + }), + ).toBe(claude.instanceId); + }); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 63dea7844c46..5ee3743a33ee 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -113,6 +113,34 @@ export function shouldOfferModelPickerSetup( ); } +export function adjacentModelPickerProvider(input: { + entries: ReadonlyArray; + selectedInstanceId: ProviderInstanceId | "favorites"; + direction: 1 | -1; + disabledInstanceIds: ReadonlySet | undefined; + selectableUnavailableInstanceIds: ReadonlySet | undefined; +}) { + const providers: Array = [ + "favorites", + ...input.entries + .filter( + (entry) => + !input.disabledInstanceIds?.has(entry.instanceId) && + (isProviderInstancePickerReady(entry) || + input.selectableUnavailableInstanceIds?.has(entry.instanceId)), + ) + .map((entry) => entry.instanceId), + ]; + const index = providers.indexOf(input.selectedInstanceId); + return providers[ + index < 0 + ? input.direction === 1 + ? 0 + : providers.length - 1 + : (index + input.direction + providers.length) % providers.length + ]!; +} + const EMPTY_MODEL_JUMP_LABELS = new Map(); function ModelListSeparator() { @@ -687,6 +715,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { platform: navigator.platform, context: modelJumpShortcutContext, }); + if (command === "modelPicker.previousProvider" || command === "modelPicker.nextProvider") { + event.preventDefault(); + event.stopPropagation(); + const next = adjacentModelPickerProvider({ + entries: sidebarInstanceEntries, + selectedInstanceId, + direction: command === "modelPicker.nextProvider" ? 1 : -1, + disabledInstanceIds: lockedDisabledInstanceIds, + selectableUnavailableInstanceIds, + }); + setSearchQuery(""); + handleSelectInstance(next); + return; + } const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); if (jumpIndex === null) { return; @@ -710,7 +752,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return () => { window.removeEventListener("keydown", onWindowKeyDown, true); }; - }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); + }, [ + handleModelSelect, + handleSelectInstance, + keybindings, + lockedDisabledInstanceIds, + modelJumpModelKeys, + modelJumpShortcutContext, + selectableUnavailableInstanceIds, + selectedInstanceId, + sidebarInstanceEntries, + ]); useLayoutEffect(() => { setShowTopScrollFade(false); @@ -737,6 +789,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { setSearchQuery(e.target.value)} onKeyDown={(e) => { + if ( + showSidebar && + !e.altKey && + !e.ctrlKey && + !e.metaKey && + ((e.key === "ArrowLeft" && !e.shiftKey && searchQuery.length === 0) || + (e.key === "Tab" && e.shiftKey)) + ) { + const sidebar = e.currentTarget + .closest("[data-model-picker-content]") + ?.querySelector("[data-model-picker-sidebar]"); + const button = + sidebar?.querySelector( + 'button[aria-pressed="true"]:not(:disabled)', + ) ?? sidebar?.querySelector("button:not(:disabled)"); + if (button) { + e.preventDefault(); + e.stopPropagation(); + button.focus(); + return; + } + } if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index dd53270268ba..30f9e3ce809b 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,3 +1,4 @@ +import { Toolbar } from "@base-ui/react/toolbar"; import { type ProviderInstanceId } from "@t3tools/contracts"; import { memo, useLayoutEffect, useRef, useState } from "react"; import { SparklesIcon, StarIcon } from "lucide-react"; @@ -43,6 +44,7 @@ const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { selectedInstanceId: ProviderInstanceId | "favorites"; onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void; + onFocusSearch: () => void; /** * Instance entries to render as rail buttons. Each entry becomes one icon * keyed by `instanceId`, so the default built-in Codex and a user-authored @@ -87,7 +89,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); return ( -
+ { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + if (event.key === "ArrowRight") { + event.preventDefault(); + props.onFocusSearch(); + return; + } + }} + >
{selectedIndicatorTop !== null ? ( @@ -107,16 +122,17 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { handleSelect("favorites")} type="button" aria-label="Favorites" + aria-pressed={props.selectedInstanceId === "favorites"} > - + } /> (current === entry.instanceId ? null : current)) } disabled={isDisabled} + focusableWhenDisabled={!isDisabled} + aria-pressed={isSelected} type="button" aria-label={ isUnavailable || isContextDisabled @@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ) : null} - + ); const trigger = isDisabled ? ( @@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { })}
-
+ ); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 47ce59efde52..0a863e0bd446 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -618,6 +618,7 @@ export const TraitsPicker = memo(function TraitsPicker({
diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index 5d5c280bb81c..afe0dbc31c5e 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,16 +1,12 @@ -import { useAuth, useClerk, useUser } from "@clerk/react"; -import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; +import { useAuth, useClerk } from "@clerk/react"; +import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; import { useCallback, useEffect, useRef, useState } from "react"; import { buildConnectCliClerkAuthorizeUrl, connectCliSignInRedirectUrl, - readConnectCliAuthState, - readConnectCliCallbackResult, - rememberConnectCliAuthState, } from "../../cloud/connectCliAuth"; import { isElectron } from "../../env"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; import { resolveClerkSignInProps } from "../clerk/authRedirect"; import { Button } from "../ui/button"; @@ -45,10 +41,10 @@ const invalidLinkMessage = { } as const; /** - * /connect: the URL the CLI prints for both flows. Waits for a Clerk session, - * then forwards the CLI's PKCE request to Clerk's authorize endpoint — with a - * loopback redirect URI when the request carries a port, so the code returns - * straight to the waiting CLI, and the hosted callback page otherwise. + * /connect: the URL the CLI prints for the loopback flow. Waits for a Clerk + * session, then forwards the CLI's PKCE request to Clerk's authorize endpoint + * with the loopback redirect URI so the code returns straight to the waiting + * CLI. Headless hosts use Clerk's device authorization page instead. */ export function ConnectCliAuthorizeSurface() { const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); @@ -61,9 +57,6 @@ export function ConnectCliAuthorizeSurface() { if (!request) { return; } - // Clerk redirects to the authorize endpoint itself once sign-in completes, - // so the callback's state check has to be armed before handing off. - rememberConnectCliAuthState(request.state); clerk.openSignIn( resolveClerkSignInProps( connectCliSignInRedirectUrl(request, window.location.href), @@ -88,7 +81,6 @@ export function ConnectCliAuthorizeSurface() { return; } redirecting.current = true; - rememberConnectCliAuthState(request.state); window.location.assign(authorizeUrl); }, [isLoaded, isSignedIn, openSignIn, request]); @@ -103,11 +95,7 @@ export function ConnectCliAuthorizeSurface() { return ( ); } - -/** - * /connect/callback: Clerk's redirect target. Shows the one-time code the - * user enters in the waiting terminal. - */ -export function ConnectCliCallbackSurface() { - const [result] = useState(readConnectCliCallbackResult); - const [expectedState] = useState(readConnectCliAuthState); - const { user } = useUser(); - const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "authentication code" }); - - if (!result) { - return ( - - - - ); - } - - // Fail closed: the legitimate callback always lands in the same browser - // that visited /connect (which recorded the state), so a missing or - // mismatched state means this page was reached some other way — the CSRF - // shape the state parameter exists to stop. Refuse to display a code. - if (expectedState === null || expectedState !== result.state) { - return ( - - - - ); - } - - const accountLabel = user?.primaryEmailAddress?.emailAddress ?? user?.username ?? null; - const authCode = encodeConnectAuthCode(result); - - return ( - - - -
-
- - One-time authorization code - - expires shortly -
- - {authCode} - -
- -
- -
- -

- Only enter this code in a terminal session you started yourself. Anyone holding it can link - their machine to your T3 Connect account while it is valid. -

-
- ); -} diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 6f2b15e7a73e..9bf65102bf70 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -1,4 +1,4 @@ -import { RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { cn } from "../../lib/utils"; @@ -15,6 +15,8 @@ interface MediaVideoPlayerProps { readonly revision?: string | null | undefined; readonly preload?: "visible" | "metadata" | undefined; readonly autoPlay?: boolean | undefined; + /** Presents a still thumbnail whose full surface opens the video in a viewer. */ + readonly onOpen?: (() => void) | undefined; readonly className?: string | undefined; readonly videoClassName?: string | undefined; /** Styles the loading and failure panels, which otherwise assume an inline light surface. */ @@ -34,6 +36,7 @@ export function MediaVideoPlayer({ revision = null, preload = "visible", autoPlay = false, + onOpen, className, videoClassName, stateClassName, @@ -131,7 +134,7 @@ export function MediaVideoPlayer({ style={style} data-markdown-copy={copyMarkdown} > - {failed ? ( + {failed && !onOpen ? ( - ) : src !== null ? ( + ) : src !== null && !failed ? (