diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 431f2be7049..6a17b920d5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,12 +245,46 @@ jobs: clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ steps.public_config.outputs.relay_url }} env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3code-relay... + + - id: relay_state + name: Read production relay tracing config + shell: bash + run: | + vp run --filter t3code-relay deploy \ + --stage prod \ + --read-state \ + --github-output \ + --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" + + - name: Upload relay client tracing config + uses: actions/upload-artifact@v7 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing.env + if-no-files-found: error + retention-days: 1 + - id: public_config name: Resolve production relay public config shell: bash @@ -337,6 +371,20 @@ jobs: cache: true run-install: true + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -620,6 +668,20 @@ jobs: - --filter=@t3tools/web... - --filter=@t3tools/scripts... + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -782,6 +844,20 @@ jobs: - --filter=@t3tools/scripts... - --filter=@t3tools/web... + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -828,6 +904,9 @@ jobs: --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ + --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ + --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ + --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \ --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 339f7963702..bba35c8de8b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,13 +20,16 @@ "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "41.5.0", - "electron-updater": "^6.6.2" + "electron-updater": "^6.6.2", + "playwright-core": "1.60.0", + "react-grab": "^0.1.32" }, "devDependencies": { "@effect/vitest": "catalog:", "@types/node": "catalog:", "cross-env": "^10.1.0", "electron-builder": "26.8.1", + "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, "productName": "T3 Code (Alpha)" diff --git a/apps/desktop/scripts/build-preview-annotation-css.mjs b/apps/desktop/scripts/build-preview-annotation-css.mjs new file mode 100644 index 00000000000..c45f81268a6 --- /dev/null +++ b/apps/desktop/scripts/build-preview-annotation-css.mjs @@ -0,0 +1,40 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compile } from "tailwindcss"; + +const directory = dirname(fileURLToPath(import.meta.url)); +const appRoot = join(directory, ".."); +const sourcePath = join(appRoot, "src", "preview", "Annotation.css"); +const preloadPath = join(appRoot, "src", "preview", "PickPreload.ts"); +const outputPath = join(appRoot, "src", "preview", "AnnotationStyles.generated.ts"); +const require = createRequire(import.meta.url); +const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); + +const [annotationSource, preloadSource, themeSource, preflightSource] = await Promise.all([ + readFile(sourcePath, "utf8"), + readFile(preloadPath, "utf8"), + readFile(join(tailwindRoot, "theme.css"), "utf8"), + readFile(join(tailwindRoot, "preflight.css"), "utf8"), +]); + +const candidates = new Set( + Array.from(preloadSource.matchAll(/!?-?[A-Za-z0-9_:@/.[\]()%,-]+/g), (match) => match[0]), +); +const compilerInput = [ + themeSource, + preflightSource, + annotationSource.replace('@import "tailwindcss";', "@tailwind utilities;"), +].join("\n"); +const compiler = await compile(compilerInput, { base: appRoot }); +const css = compiler.build([...candidates]); +const encodedCss = `'${css + .replaceAll("\\", "\\\\") + .replaceAll("'", "\\'") + .replaceAll("\r", "\\r") + .replaceAll("\n", "\\n")}'`; +const moduleSource = `// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.\nexport const previewAnnotationStyles =\n ${encodedCss};\n`; + +await writeFile(outputPath, moduleSource); diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 2a2e52449be..58ccfe90eb9 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,8 +1,13 @@ import { spawn, spawnSync } from "node:child_process"; import { watch } from "node:fs"; +import * as NodeOS from "node:os"; import { join } from "node:path"; -import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs"; +import { + desktopDir, + resolveDevProtocolClient, + resolveElectronLaunchCommand, +} from "./electron-launcher.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); @@ -29,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. +const hostPlatform = NodeOS.platform(); await waitForResources({ baseDir: desktopDir, @@ -53,7 +60,7 @@ const expectedExits = new WeakSet(); const watchers = []; function killChildTreeByPid(pid, signal) { - if (process.platform === "win32" || typeof pid !== "number") { + if (hostPlatform === "win32" || typeof pid !== "number") { return; } @@ -61,7 +68,7 @@ function killChildTreeByPid(pid, signal) { } function cleanupStaleDevApps() { - if (process.platform === "win32") { + if (hostPlatform === "win32") { return; } @@ -79,7 +86,8 @@ function startApp() { const launchArgs = devProtocolClient ? electronArgs : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; - const app = spawn(resolveElectronPath(), launchArgs, { + const electronCommand = resolveElectronLaunchCommand(launchArgs); + const app = spawn(electronCommand.electronPath, electronCommand.args, { cwd: desktopDir, env: childEnv, stdio: "inherit", @@ -189,7 +197,7 @@ function startWatchers() { } function killChildTree(signal) { - if (process.platform === "win32") { + if (hostPlatform === "win32") { return; } diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 8f20001bbb0..52b6dd5cc6e 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -14,6 +14,7 @@ import { writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; +import * as NodeOS from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; @@ -30,9 +31,11 @@ export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 10; +const LAUNCHER_VERSION = 11; const defaultIconPath = join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime. +const hostPlatform = NodeOS.platform(); function resolveDevelopmentProtocolCallbackPort() { const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); @@ -295,7 +298,11 @@ function buildMacLauncher(electronBinaryPath) { } rmSync(targetAppBundlePath, { recursive: true, force: true }); - cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); + // verbatimSymlinks keeps the framework's relative symlinks intact + // (e.g. Resources -> Versions/Current/Resources). Without it cpSync + // rewrites them to absolute paths into node_modules, which escape the + // bundle and crash sandboxed helper processes (icudtl.dat not found). + cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true, verbatimSymlinks: true }); patchMainBundleInfoPlist(targetAppBundlePath, iconPath); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { @@ -307,21 +314,54 @@ function buildMacLauncher(electronBinaryPath) { return targetBinaryPath; } +function isLinuxSetuidSandboxConfigured(electronBinaryPath) { + if (hostPlatform !== "linux") { + return true; + } + + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + try { + const sandboxStat = statSync(sandboxPath); + return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; + } catch { + return false; + } +} + +function resolveLinuxSandboxArgs(electronBinaryPath) { + if (isLinuxSetuidSandboxConfigured(electronBinaryPath)) { + return []; + } + + console.warn( + "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.", + ); + return ["--no-sandbox"]; +} + export function resolveElectronPath() { ensureElectronRuntime(); const require = createRequire(import.meta.url); const electronBinaryPath = require("electron"); - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return electronBinaryPath; } return buildMacLauncher(electronBinaryPath); } +export function resolveElectronLaunchCommand(args = []) { + const electronPath = resolveElectronPath(); + return { + electronPath, + args: [...resolveLinuxSandboxArgs(electronPath), ...args], + }; +} + export function resolveDevProtocolClient() { - if (process.platform !== "darwin" || !isDevelopment) { + if (hostPlatform !== "darwin" || !isDevelopment) { return null; } diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index 2df47d3c62b..0a13506d341 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -1,13 +1,17 @@ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; +import { arch, platform, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; const require = createRequire(import.meta.url); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. +const hostPlatform = platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. +const hostArch = arch(); function getPlatformPath() { - switch (process.platform) { + switch (hostPlatform) { case "darwin": return "Electron.app/Contents/MacOS/Electron"; case "freebsd": @@ -17,12 +21,12 @@ function getPlatformPath() { case "win32": return "electron.exe"; default: - throw new Error(`Electron builds are not available on platform: ${process.platform}`); + throw new Error(`Electron builds are not available on platform: ${hostPlatform}`); } } function ensureExecutable(filePath) { - if (process.platform !== "win32") { + if (hostPlatform !== "win32") { chmodSync(filePath, 0o755); } } @@ -39,7 +43,7 @@ function repairPathFile(electronDir, platformPath) { function getRequiredRuntimePaths(electronDir, platformPath) { const paths = [join(electronDir, "dist", platformPath)]; - if (process.platform === "darwin") { + if (hostPlatform === "darwin") { paths.push( join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), join( @@ -58,7 +62,7 @@ function getRequiredRuntimePaths(electronDir, platformPath) { } function isMachO(filePath) { - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return true; } @@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) { } function invalidRuntimePaths(electronDir, platformPath) { - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return []; } @@ -111,16 +115,16 @@ function runChecked(command, args) { function installElectronRuntime(electronDir, version) { const tempDir = mkdtempSync(join(tmpdir(), "t3-electron-")); - const zipPath = join(tempDir, `electron-v${version}-${process.platform}-${process.arch}.zip`); + const zipPath = join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); try { runChecked("curl", [ "-fsSL", - `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${process.platform}-${process.arch}.zip`, + `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${hostPlatform}-${hostArch}.zip`, "-o", zipPath, ]); - if (process.platform === "darwin") { + if (hostPlatform === "darwin") { runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]); } else { runChecked("python3", [ diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index fdbe69b7780..48a2e168a2b 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -1,15 +1,16 @@ import { spawn } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); -const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); console.log("\nLaunching Electron smoke test..."); -const child = spawn(electronBin, [mainJs], { +const electronCommand = resolveElectronLaunchCommand([mainJs]); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 375dbfe575f..d959b4ab1f0 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,11 +1,12 @@ import { spawn } from "node:child_process"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { +const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.cjs"]); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 052a25e4b97..4da1ce63bdf 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -176,7 +176,7 @@ const bootstrap = Effect.gen(function* () { ); } - yield* installDesktopIpcHandlers; + yield* installDesktopIpcHandlers(); yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index a9c1d62e685..3b5a15e435f 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -49,7 +49,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; - if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { + if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") { const developmentDockIconPath = environment.developmentDockIconPath; const developmentDockIconExists = yield* fileSystem .exists(developmentDockIconPath) diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index ee732bf830c..92da3f887ac 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -59,6 +59,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); assert.equal(environment.logDir, "/tmp/t3/dev/logs"); + assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts"); assert.equal(environment.rootDir, "/repo"); assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); @@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.isDevelopment, false); assert.equal(environment.stateDir, "/tmp/t3/userdata"); 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"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 2144593057e..9c3ebbaa949 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -50,6 +50,7 @@ export interface DesktopEnvironmentShape { readonly savedEnvironmentRegistryPath: string; readonly serverSettingsPath: string; readonly logDir: string; + readonly browserArtifactsDir: string; readonly rootDir: string; readonly appRoot: string; readonly backendEntryPath: string; @@ -184,6 +185,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), serverSettingsPath: path.join(stateDir, "settings.json"), logDir: path.join(stateDir, "logs"), + browserArtifactsDir: path.join(stateDir, "browser-artifacts"), rootDir, appRoot, backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 005c86e0868..cb25043ff44 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export interface ElectronMenuPosition { readonly x: number; @@ -79,109 +80,113 @@ const normalizePosition = ( ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); -export const layer = Layer.sync(ElectronMenu, () => { - let destructiveMenuIconCache: Option.Option | undefined; +export const layer = Layer.effect( + ElectronMenu, + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + let destructiveMenuIconCache: Option.Option | undefined; - const getDestructiveMenuIcon = (): Option.Option => { - if (process.platform !== "darwin") { - return Option.none(); - } - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache; - } - - try { - const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ - width: 12, - height: 12, - }); - destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); - } catch { - destructiveMenuIconCache = Option.none(); - } - - return destructiveMenuIconCache; - }; - - const buildTemplate = ( - entries: readonly ContextMenuItem[], - complete: (selectedItemId: Option.Option) => void, - ): Electron.MenuItemConstructorOptions[] => { - const template: Electron.MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; + const getDestructiveMenuIcon = (): Option.Option => { + if (platform !== "darwin") { + return Option.none(); } - - const itemOption: Electron.MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children, complete); - } else { - itemOption.click = () => complete(Option.some(item.id)); + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (Option.isSome(destructiveIcon)) { - itemOption.icon = destructiveIcon.value; - } + + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); } - template.push(itemOption); - } + return destructiveMenuIconCache; + }; - return template; - }; - - return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.sync(() => { - Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), - popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), - showContextMenu: (input) => - Effect.callback>((resume) => { - const normalizedItems = normalizeContextMenuItems(input.items); - if (normalizedItems.length === 0) { - resume(Effect.succeed(Option.none())); - return; + + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } + } + + template.push(itemOption); + } - let completed = false; - const complete = (selectedItemId: Option.Option) => { - if (completed) { + return template; + }; + + return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.sync(() => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }), + popupTemplate: (input) => + Effect.sync(() => { + if (input.template.length === 0) { + return; + } + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + }), + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); return; } - completed = true; - resume(Effect.succeed(selectedItemId)); - }; - const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); - const popupOptions = Option.match(popupPosition, { - onNone: (): Electron.PopupOptions => ({ - window: input.window, - callback: () => complete(Option.none()), - }), - onSome: (position): Electron.PopupOptions => ({ - window: input.window, - x: position.x, - y: position.y, - callback: () => complete(Option.none()), - }), - }); - menu.popup(popupOptions); - }), - }); -}); + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { + return; + } + completed = true; + resume(Effect.succeed(selectedItemId)); + }; + + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + }), + }); + }), +); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index d41a8326e63..35c1fbc5faa 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ readonly cause: unknown; @@ -37,6 +38,7 @@ export class ElectronWindow extends Context.Service>(Option.none()); const liveMain = Ref.get(mainWindowRef).pipe( @@ -98,7 +100,7 @@ const make = Effect.gen(function* () { window.show(); } - if (process.platform === "darwin") { + if (platform === "darwin") { Electron.app.focus({ steal: true }); } diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 40f84054878..a6c8428efa9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -48,9 +48,11 @@ import { setTheme, showContextMenu, } from "./methods/window.ts"; +import * as PreviewIpc from "./methods/preview.ts"; -export const installDesktopIpcHandlers = Effect.gen(function* () { +export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; + yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getLocalEnvironmentBootstrap); @@ -92,4 +94,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); -}).pipe(Effect.withSpan("desktop.ipc.installHandlers")); + for (const previewMethod of PreviewIpc.methods) { + yield* ipc.handle(previewMethod); + } +}); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 1ded238c663..c5dabe0930f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -39,3 +39,38 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; +export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; +export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; +export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate"; +export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back"; +export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward"; +export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh"; +export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; +export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; +export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; +export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; +export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; +export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; +export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; +export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; +export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; +export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; +export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; +export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; +export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; +export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; +export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; +export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; +export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; +export const PREVIEW_AUTOMATION_TYPE_CHANNEL = "desktop:preview-automation-type"; +export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-press"; +export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; +export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; +export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; +export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; +export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; +export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; +export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; +export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; +export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts new file mode 100644 index 00000000000..92336cc7362 --- /dev/null +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -0,0 +1,54 @@ +import { it as effectIt } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import * as PreviewManager from "../../preview/Manager.ts"; +import * as PreviewIpc from "./preview.ts"; + +const { fromPartition } = vi.hoisted(() => ({ + fromPartition: vi.fn(() => { + throw new Error("Session can only be received when app is ready"); + }), +})); + +vi.mock("electron", () => ({ + BrowserWindow: { + getAllWindows: vi.fn(() => []), + }, + session: { + fromPartition, + }, + webContents: { + fromId: vi.fn(() => null), + }, +})); + +describe("preview IPC methods", () => { + beforeEach(() => { + fromPartition.mockClear(); + }); + + it("does not access the Electron session while the module loads", async () => { + await expect(import("./preview.ts")).resolves.toBeDefined(); + expect(fromPartition).not.toHaveBeenCalled(); + }); + + effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => + Effect.map( + PreviewIpc.registerWebview + .handler({ tabId: "tab-1", webContentsId: 0 }) + .pipe(Effect.provideService(PreviewManager.PreviewManager, null as never), Effect.exit), + (exit) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error) && Schema.isSchemaError(error.value)).toBe(true); + expect(fromPartition).not.toHaveBeenCalled(); + }, + ), + ); +}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts new file mode 100644 index 00000000000..8adae374ad0 --- /dev/null +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -0,0 +1,377 @@ +import { + DesktopPreviewAnnotationThemeInputSchema, + DesktopPreviewArtifactInputSchema, + DesktopPreviewAutomationClickInputSchema, + DesktopPreviewAutomationEvaluateInputSchema, + DesktopPreviewAutomationPressInputSchema, + DesktopPreviewAutomationScrollInputSchema, + DesktopPreviewAutomationTypeInputSchema, + DesktopPreviewAutomationWaitForInputSchema, + DesktopPreviewConfigInputSchema, + DesktopPreviewNavigateInputSchema, + DesktopPreviewRecordingArtifactSchema, + DesktopPreviewRecordingSaveInputSchema, + DesktopPreviewRegisterWebviewInputSchema, + DesktopPreviewScreenshotArtifactSchema, + DesktopPreviewTabInputSchema, + DesktopPreviewWebviewConfigSchema, + PreviewAnnotationPayloadSchema, + PreviewAutomationSnapshot, + PreviewAutomationStatus, +} from "@t3tools/contracts"; +import { BrowserWindow } from "electron"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { pathToFileURL } from "node:url"; + +import * as PreviewManager from "../../preview/Manager.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const broadcast = (channel: string, ...args: ReadonlyArray): void => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(channel, ...args); + } + } +}; + +export const installPreviewEventForwarding = Effect.fn( + "desktop.ipc.preview.installEventForwarding", +)(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.subscribeStateChanges((tabId, state) => { + broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); + }); + yield* manager.subscribeRecordingFrames((frame) => { + broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); + }); + yield* manager.subscribePointerEvents((event) => { + broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); + }); +}); + +export const createTab = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.createTab(tabId); + }), +}); + +export const closeTab = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.closeTab")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.closeTab(tabId); + }), +}); + +export const registerWebview = makeIpcMethod({ + channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, + payload: DesktopPreviewRegisterWebviewInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.registerWebview")(function* ({ tabId, webContentsId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.registerWebview(tabId, webContentsId); + }), +}); + +export const navigate = makeIpcMethod({ + channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL, + payload: DesktopPreviewNavigateInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.navigate")(function* ({ tabId, url }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.navigate(tabId, url); + }), +}); + +const tabMethod = ( + channel: string, + name: string, + invoke: ( + manager: PreviewManager.PreviewManagerShape, + tabId: string, + ) => Effect.Effect, +) => + makeIpcMethod({ + channel, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn(name)(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* invoke(manager, tabId); + }), + }); + +export const goBack = tabMethod( + IpcChannels.PREVIEW_GO_BACK_CHANNEL, + "desktop.ipc.preview.goBack", + (manager, tabId) => manager.goBack(tabId), +); +export const goForward = tabMethod( + IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, + "desktop.ipc.preview.goForward", + (manager, tabId) => manager.goForward(tabId), +); +export const refresh = tabMethod( + IpcChannels.PREVIEW_REFRESH_CHANNEL, + "desktop.ipc.preview.refresh", + (manager, tabId) => manager.refresh(tabId), +); +export const zoomIn = tabMethod( + IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, + "desktop.ipc.preview.zoomIn", + (manager, tabId) => manager.zoomIn(tabId), +); +export const zoomOut = tabMethod( + IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, + "desktop.ipc.preview.zoomOut", + (manager, tabId) => manager.zoomOut(tabId), +); +export const resetZoom = tabMethod( + IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, + "desktop.ipc.preview.resetZoom", + (manager, tabId) => manager.resetZoom(tabId), +); +export const hardReload = tabMethod( + IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, + "desktop.ipc.preview.hardReload", + (manager, tabId) => manager.hardReload(tabId), +); +export const openDevTools = tabMethod( + IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, + "desktop.ipc.preview.openDevTools", + (manager, tabId) => manager.openDevTools(tabId), +); +export const cancelPickElement = tabMethod( + IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, + "desktop.ipc.preview.cancelPickElement", + (manager, tabId) => manager.cancelPickElement(tabId), +); +export const startRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + "desktop.ipc.preview.startRecording", + (manager, tabId) => manager.startRecording(tabId), +); +export const stopRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, + "desktop.ipc.preview.stopRecording", + (manager, tabId) => manager.stopRecording(tabId), +); + +export const clearCookies = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.clearCookies(); + }), +}); + +export const clearCache = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.clearCache(); + }), +}); + +export const getPreviewConfig = makeIpcMethod({ + channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, + payload: DesktopPreviewConfigInputSchema, + result: DesktopPreviewWebviewConfigSchema, + handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.getBrowserSession(environmentId); + return { + partition: yield* manager.getBrowserPartition(environmentId), + webPreferences: PREVIEW_WEBVIEW_PREFERENCES, + preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, + }; + }), +}); + +export const setAnnotationTheme = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, + payload: DesktopPreviewAnnotationThemeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setAnnotationTheme")(function* ({ theme }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setAnnotationTheme(theme); + }), +}); + +export const pickElement = makeIpcMethod({ + channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.NullOr(PreviewAnnotationPayloadSchema), + handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.pickElement(tabId); + }), +}); + +export const captureScreenshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: DesktopPreviewScreenshotArtifactSchema, + handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.captureScreenshot(tabId); + }), +}); + +export const revealArtifact = makeIpcMethod({ + channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, + payload: DesktopPreviewArtifactInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.revealArtifact")(function* ({ path }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.revealArtifact(path); + }), +}); + +export const copyArtifactToClipboard = makeIpcMethod({ + channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, + payload: DesktopPreviewArtifactInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.copyArtifactToClipboard")(function* ({ path }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.copyArtifactToClipboard(path); + }), +}); + +export const automationStatus = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: PreviewAutomationStatus, + handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationStatus(tabId); + }), +}); + +export const automationSnapshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: PreviewAutomationSnapshot, + handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationSnapshot(tabId); + }), +}); + +export const automationClick = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, + payload: DesktopPreviewAutomationClickInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationClick")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationClick(tabId, input); + }), +}); + +export const automationType = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, + payload: DesktopPreviewAutomationTypeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationType")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationType(tabId, input); + }), +}); + +export const automationPress = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, + payload: DesktopPreviewAutomationPressInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationPress")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationPress(tabId, input); + }), +}); + +export const automationScroll = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, + payload: DesktopPreviewAutomationScrollInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationScroll")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationScroll(tabId, input); + }), +}); + +export const automationEvaluate = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, + payload: DesktopPreviewAutomationEvaluateInputSchema, + result: Schema.Unknown, + handler: Effect.fn("desktop.ipc.preview.automationEvaluate")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationEvaluate(tabId, input); + }), +}); + +export const automationWaitFor = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, + payload: DesktopPreviewAutomationWaitForInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationWaitFor")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationWaitFor(tabId, input); + }), +}); + +export const saveRecording = makeIpcMethod({ + channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, + payload: DesktopPreviewRecordingSaveInputSchema, + result: DesktopPreviewRecordingArtifactSchema, + handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ tabId, mimeType, data }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.saveRecording(tabId, mimeType, data); + }), +}); + +export const methods = [ + createTab, + closeTab, + registerWebview, + navigate, + goBack, + goForward, + refresh, + zoomIn, + zoomOut, + resetZoom, + hardReload, + openDevTools, + clearCookies, + clearCache, + getPreviewConfig, + setAnnotationTheme, + pickElement, + cancelPickElement, + captureScreenshot, + revealArtifact, + copyArtifactToClipboard, + automationStatus, + automationSnapshot, + automationClick, + automationType, + automationPress, + automationScroll, + automationEvaluate, + automationWaitFor, + startRecording, + stopRecording, + saveRecording, +] as const; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9356eef441b..3ed0b9b5cf0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -9,6 +9,7 @@ import * as Option from "effect/Option"; 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" }; @@ -44,6 +45,8 @@ import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as PreviewBrowserSession from "./preview/BrowserSession.ts"; +import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; const desktopEnvironmentLayer = Layer.unwrap( @@ -51,11 +54,13 @@ const desktopEnvironmentLayer = Layer.unwrap( const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( Effect.flatMap((app) => app.metadata), ); + const platform = yield* HostProcessPlatform; + const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, homeDirectory: NodeOS.homedir(), - platform: process.platform, - processArch: process.arch, + platform, + processArch, ...metadata, }); }), @@ -127,7 +132,15 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( Layer.provideMerge(desktopFoundationLayer), ); -const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); +const desktopPreviewLayer = PreviewManager.layer.pipe( + Layer.provideMerge(PreviewBrowserSession.layer), + Layer.provideMerge(desktopFoundationLayer), +); + +const desktopWindowLayer = DesktopWindow.layer.pipe( + Layer.provideMerge(desktopServerExposureLayer), + Layer.provideMerge(desktopPreviewLayer), +); const desktopBackendLayer = DesktopBackendManager.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 84f7580cb07..ce12f19bf72 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,4 +1,9 @@ -import type { DesktopBridge } from "@t3tools/contracts"; +import type { + DesktopBridge, + DesktopPreviewPointerEvent, + DesktopPreviewRecordingFrame, + DesktopPreviewTabState, +} from "@t3tools/contracts"; import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -141,4 +146,97 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + preview: { + createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }), + closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }), + registerWebview: (tabId, webContentsId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }), + navigate: (tabId, url) => + ipcRenderer.invoke(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, { tabId, url }), + goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }), + goForward: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, { tabId }), + refresh: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_REFRESH_CHANNEL, { tabId }), + zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }), + zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), + resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), + hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), + openDevTools: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), + clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), + clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), + getPreviewConfig: (environmentId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), + setAnnotationTheme: (theme) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), + pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), + cancelPickElement: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), + captureScreenshot: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), + revealArtifact: (path) => + ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), + copyArtifactToClipboard: (path) => + ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), + recording: { + startScreencast: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), + stopScreencast: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId }), + save: (tabId, mimeType, data) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, { + tabId, + mimeType, + data, + }), + onFrame: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { + if (typeof frame !== "object" || frame === null) return; + listener(frame as DesktopPreviewRecordingFrame); + }; + ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); + }, + }, + automation: { + status: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), + snapshot: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), + click: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), + type: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, { tabId, input }), + press: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, { tabId, input }), + scroll: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, { tabId, input }), + evaluate: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, { tabId, input }), + waitFor: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, { tabId, input }), + }, + onStateChange: (listener) => { + const wrappedListener = ( + _event: Electron.IpcRendererEvent, + tabId: unknown, + state: unknown, + ) => { + if (typeof tabId !== "string" || typeof state !== "object" || state === null) return; + listener(tabId, state as DesktopPreviewTabState); + }; + ipcRenderer.on(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); + }, + onPointerEvent: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, pointerEvent: unknown) => { + if (typeof pointerEvent !== "object" || pointerEvent === null) return; + listener(pointerEvent as DesktopPreviewPointerEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); + }, + }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts new file mode 100644 index 00000000000..84e6abb29ee --- /dev/null +++ b/apps/desktop/src/preview-pick-preload.ts @@ -0,0 +1 @@ +import "./preview/PickPreload.ts"; diff --git a/apps/desktop/src/preview/Annotation.css b/apps/desktop/src/preview/Annotation.css new file mode 100644 index 00000000000..89676a22d58 --- /dev/null +++ b/apps/desktop/src/preview/Annotation.css @@ -0,0 +1,68 @@ +@import "tailwindcss"; + +@theme inline { + --font-sans: var(--t3-font-sans); + --font-mono: var(--t3-font-mono); + --color-background: var(--t3-background); + --color-foreground: var(--t3-foreground); + --color-popover: var(--t3-popover); + --color-popover-foreground: var(--t3-popover-foreground); + --color-primary: var(--t3-primary); + --color-primary-foreground: var(--t3-primary-foreground); + --color-muted: var(--t3-muted); + --color-muted-foreground: var(--t3-muted-foreground); + --color-accent: var(--t3-accent); + --color-accent-foreground: var(--t3-accent-foreground); + --color-border: var(--t3-border); + --color-input: var(--t3-input); + --color-ring: var(--t3-ring); + --radius-sm: calc(var(--t3-radius) - 4px); + --radius-md: calc(var(--t3-radius) - 2px); + --radius-lg: var(--t3-radius); + --radius-xl: calc(var(--t3-radius) + 4px); + --radius-2xl: calc(var(--t3-radius) + 8px); +} + +:host { + --t3-font-sans: + "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + --t3-font-mono: + "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + --t3-radius: 0.625rem; + --t3-background: white; + --t3-foreground: oklch(0.269 0 0); + --t3-popover: white; + --t3-popover-foreground: oklch(0.269 0 0); + --t3-primary: oklch(0.488 0.217 264); + --t3-primary-foreground: white; + --t3-muted: rgb(0 0 0 / 4%); + --t3-muted-foreground: oklch(0.556 0 0); + --t3-accent: rgb(0 0 0 / 4%); + --t3-accent-foreground: oklch(0.269 0 0); + --t3-border: rgb(0 0 0 / 8%); + --t3-input: rgb(0 0 0 / 10%); + --t3-ring: oklch(0.488 0.217 264); + color: var(--t3-foreground); + font-family: var(--t3-font-sans); +} + +* { + box-sizing: border-box; + border-color: var(--t3-border); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); + outline-offset: 1px; +} diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts new file mode 100644 index 00000000000..5b6b73c8ba7 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -0,0 +1,3 @@ +// Generated by scripts/build-preview-annotation-css.mjs. Do not edit. +export const previewAnnotationStyles = + '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts new file mode 100644 index 00000000000..5526e5e0e54 --- /dev/null +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { fromPartition, sessions } = vi.hoisted(() => ({ + fromPartition: vi.fn(), + sessions: new Map< + string, + { + readonly clearCache: ReturnType; + readonly clearStorageData: ReturnType; + readonly getUserAgent: ReturnType; + readonly setPermissionRequestHandler: ReturnType; + readonly setUserAgent: ReturnType; + } + >(), +})); + +vi.mock("electron", () => ({ + session: { + fromPartition, + }, +})); + +import * as BrowserSession from "./BrowserSession.ts"; + +const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer)); + +describe("BrowserSession", () => { + beforeEach(() => { + sessions.clear(); + fromPartition.mockReset(); + fromPartition.mockImplementation((partition: string) => { + const browserSession = { + clearCache: vi.fn(() => Promise.resolve()), + clearStorageData: vi.fn(() => Promise.resolve()), + getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"), + setPermissionRequestHandler: vi.fn(), + setUserAgent: vi.fn(), + }; + sessions.set(partition, browserSession); + return browserSession; + }); + }); + + it.effect("derives deterministic partitions and memoizes sessions", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + + const partition = yield* browserSessions.getPartition("scope-a"); + const first = yield* browserSessions.getSession("scope-a"); + const second = yield* browserSessions.getSession("scope-a"); + + assert.strictEqual(partition, "persist:t3code-preview-f051bb2c68cb7b2fe969"); + assert.strictEqual(first, second); + assert.strictEqual(fromPartition.mock.calls.length, 1); + }).pipe(Effect.provide(layer)), + ); + + it.effect("clears storage and cache for every created session", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + yield* browserSessions.getSession("scope-a"); + yield* browserSessions.getSession("scope-b"); + + yield* browserSessions.clearCookies(); + yield* browserSessions.clearCache(); + + assert.strictEqual(sessions.size, 2); + for (const browserSession of sessions.values()) { + assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); + assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [ + { + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }, + ]); + assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); + } + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts new file mode 100644 index 00000000000..ead28c12f9b --- /dev/null +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -0,0 +1,107 @@ +import type { Session } from "electron"; +import { session } from "electron"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; + +export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")<{ + readonly operation: string; + readonly cause: unknown; +}> { + override get message() { + return `Desktop preview browser session operation failed: ${this.operation}`; + } +} + +export interface BrowserSessionShape { + readonly getPartition: (scope?: string) => Effect.Effect; + readonly isPartition: (partition: string) => boolean; + readonly getSession: (scope?: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; +} + +export class BrowserSession extends Context.Service()( + "@t3tools/desktop/preview/BrowserSession", +) {} + +const make = Effect.gen(function* BrowserSessionMake() { + const crypto = yield* Crypto.Crypto; + const sessionsRef = yield* SynchronizedRef.make>(new Map()); + + const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") { + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(scope)) + .pipe( + Effect.mapError((cause) => new BrowserSessionError({ operation: "getPartition", cause })), + ); + return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; + }); + + const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { + const partition = yield* getPartition(scope); + return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { + const existing = sessions.get(partition); + if (existing) return Effect.succeed([existing, sessions] as const); + return Effect.try({ + try: () => { + const browserSession = session.fromPartition(partition); + const userAgent = browserSession + .getUserAgent() + .replace(/Electron\/[\d.]+ /, "") + .replace(/\s*t3code\/[\d.]+/, ""); + browserSession.setUserAgent(userAgent); + browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { + const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; + callback(allowed.includes(permission)); + }); + const next = new Map(sessions); + next.set(partition, browserSession); + return [browserSession, next] as const; + }, + catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }), + }); + }); + }); + + return BrowserSession.of({ + getPartition, + isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), + getSession, + clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { + const sessions = yield* SynchronizedRef.get(sessionsRef); + yield* Effect.all( + [...sessions.values()].map((browserSession) => + Effect.tryPromise({ + try: () => + browserSession.clearStorageData({ + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }), + catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }), + }), + ), + { concurrency: "unbounded", discard: true }, + ); + }), + clearCache: Effect.fn("BrowserSession.clearCache")(function* () { + const sessions = yield* SynchronizedRef.get(sessionsRef); + yield* Effect.all( + [...sessions.values()].map((browserSession) => + Effect.tryPromise({ + try: () => browserSession.clearCache(), + catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }), + }), + ), + { concurrency: "unbounded", discard: true }, + ); + }), + }); +}).pipe(Effect.withSpan("BrowserSession.make")); + +export const layer = Layer.effect(BrowserSession, make); diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts new file mode 100644 index 00000000000..00616c6a476 --- /dev/null +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -0,0 +1,6 @@ +export const START_PICK_CHANNEL = "preview:start-pick"; +export const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; +export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; +export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; +export const HUMAN_INPUT_CHANNEL = "preview:human-input"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts new file mode 100644 index 00000000000..d7252d3f8d9 --- /dev/null +++ b/apps/desktop/src/preview/Manager.test.ts @@ -0,0 +1,423 @@ +import { it as effectIt } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import type * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; +import { beforeEach, describe, expect, vi } from "vite-plus/test"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as BrowserSession from "./BrowserSession.ts"; +import * as PreviewManager from "./Manager.ts"; + +const { createFromPath, fromId, mkdir, showItemInFolder, webviewSend, writeFile, writeImage } = + vi.hoisted(() => ({ + createFromPath: vi.fn(() => ({ isEmpty: () => false })), + fromId: vi.fn(() => null), + mkdir: vi.fn((_path: string) => undefined), + showItemInFolder: vi.fn(), + webviewSend: vi.fn(), + writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), + writeImage: vi.fn(), + })); + +vi.mock("electron", () => ({ + clipboard: { + writeImage, + }, + nativeImage: { + createFromPath, + }, + shell: { + showItemInFolder, + }, + session: { + fromPartition: vi.fn(), + }, + webContents: { + fromId, + }, +})); + +const browserSessionLayer = Layer.succeed( + BrowserSession.BrowserSession, + BrowserSession.BrowserSession.of({ + getPartition: () => Effect.succeed("persist:t3code-preview-test"), + isPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getSession: () => Effect.die("unexpected getSession"), + clearCookies: () => Effect.void, + clearCache: () => Effect.void, + }), +); + +const environmentLayer = Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", + } as DesktopEnvironment.DesktopEnvironmentShape), +); + +const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: (path) => + Effect.sync(() => { + mkdir(path); + }), + writeFile: (path, data) => + Effect.sync(() => { + writeFile(path, data); + }), +}); + +const layer = PreviewManager.layer.pipe( + Layer.provideMerge(browserSessionLayer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(fileSystemLayer), + Layer.provideMerge(Path.layer), +); + +const withManager = ( + use: ( + manager: PreviewManager.PreviewManagerShape, + ) => Effect.Effect, +) => + Effect.gen(function* () { + const manager = yield* PreviewManager.PreviewManager; + return yield* use(manager); + }).pipe(Effect.provide(layer), Effect.scoped); + +describe("PreviewManager", () => { + beforeEach(() => { + fromId.mockClear(); + mkdir.mockClear(); + writeFile.mockClear(); + showItemInFolder.mockClear(); + writeImage.mockClear(); + createFromPath.mockClear(); + webviewSend.mockClear(); + }); + + effectIt.effect("reports an unregistered webview as temporarily unavailable", () => + withManager((manager) => + Effect.gen(function* () { + expect(yield* manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + + yield* manager.createTab("tab_1"); + + expect(yield* manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + expect(fromId).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("captures a PNG screenshot into browser artifacts", () => + withManager((manager) => + Effect.gen(function* () { + const png = Buffer.from("preview-png"); + const capturePage = vi.fn(async () => ({ toPNG: () => png })); + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com:8443/path?query=value", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + expect(webviewSend).toHaveBeenCalledWith( + "preview:annotation-theme", + expect.objectContaining({ + colorScheme: "light", + primary: "oklch(0.488 0.217 264)", + }), + ); + + const artifact = yield* manager.captureScreenshot("tab_1"); + + expect(capturePage).toHaveBeenCalledOnce(); + expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts"); + expect(writeFile).toHaveBeenCalledWith(artifact.path, png); + expect(artifact).toMatchObject({ + tabId: "tab_1", + mimeType: "image/png", + sizeBytes: png.byteLength, + }); + expect(artifact.path).toMatch( + /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, + ); + }), + ), + ); + + effectIt.effect("keeps element picking active during subframe navigation", () => + withManager((manager) => + Effect.gen(function* () { + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isFocused: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + once: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + + listeners.get("did-start-navigation")?.({}, "about:blank", false, false); + yield* Effect.yieldNow; + expect(pick.pollUnsafe()).toBeUndefined(); + + listeners.get("did-start-navigation")?.({}, "https://example.com/next", false, true); + expect(yield* Fiber.join(pick)).toBeNull(); + }), + ), + ); + + effectIt.effect("reveals only files inside the configured browser artifact directory", () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); + + expect(showItemInFolder).toHaveBeenCalledWith( + "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", + ); + const exit = yield* Effect.exit(manager.revealArtifact("/tmp/t3/dev/settings.json")); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error.cause).toMatchObject({ + message: "Preview artifact path is outside the configured artifact directory.", + }); + }), + ), + ); + + effectIt.effect("copies screenshot artifacts to the system clipboard", () => + withManager((manager) => + Effect.gen(function* () { + const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; + + yield* manager.copyArtifactToClipboard(artifactPath); + + expect(createFromPath).toHaveBeenCalledWith(artifactPath); + expect(writeImage).toHaveBeenCalledOnce(); + const exit = yield* Effect.exit( + manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error.cause).toMatchObject({ + message: "Preview artifact path is outside the configured artifact directory.", + }); + }), + ), + ); + + effectIt.effect("emits the resolved pointer target before dispatching an automation click", () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const activity: string[] = []; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + activity.push("mousePressed"); + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.subscribePointerEvents((event) => activity.push(event.phase)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + yield* Fiber.join(click); + + expect(activity).toEqual(["move", "click", "mousePressed"]); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + }), + ), + ); + + effectIt.effect("still interrupts agent control for a different human pointer event", () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const sendCommand = vi.fn(async (method: string) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent") { + humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + const exit = yield* Fiber.await(click); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error.cause).toMatchObject({ + name: "PreviewAutomationControlInterruptedError", + }); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts new file mode 100644 index 00000000000..2c4096e8cfb --- /dev/null +++ b/apps/desktop/src/preview/Manager.ts @@ -0,0 +1,2308 @@ +/** + * Desktop side of the in-app browser preview. + * + * Hosts per-tab Chromium WebContents references (the actual + * elements live in the renderer; we only attach listeners and forward state + * here). Single layer-scoped browser session partition. + */ +import type { + DesktopPreviewAnnotationTheme, + DesktopPreviewPointerEvent, + PreviewAnnotationPayload, + PreviewAnnotationRect, + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, + DesktopPreviewScreenshotArtifact, + PreviewAutomationClickInput, + PreviewAutomationActionEvent, + PreviewAutomationConsoleEntry, + PreviewAutomationEvaluateInput, + PreviewAutomationPressInput, + PreviewAutomationNetworkEntry, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { + type BrowserWindow, + type Session, + clipboard, + nativeImage, + shell, + webContents, +} from "electron"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as BrowserSession from "./BrowserSession.ts"; +import { + ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_THEME_CHANNEL, + CANCEL_PICK_CHANNEL, + ELEMENT_PICKED_CHANNEL, + HUMAN_INPUT_CHANNEL, + START_PICK_CHANNEL, +} from "./GuestProtocol.ts"; +import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; +import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; + +export type PreviewNavStatus = + | { kind: "Idle" } + | { kind: "Loading"; url: string; title: string } + | { kind: "Success"; url: string; title: string } + | { + kind: "LoadFailed"; + url: string; + title: string; + code: number; + description: string; + }; + +export interface PreviewTabState { + tabId: string; + webContentsId: number | null; + navStatus: PreviewNavStatus; + canGoBack: boolean; + canGoForward: boolean; + zoomFactor: number; + controller: "human" | "agent" | "none"; + updatedAt: string; +} + +/** Discrete zoom levels mirroring Chrome's preset list. */ +const ZOOM_LEVELS: ReadonlyArray = [ + 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, +]; + +const DEFAULT_ZOOM_FACTOR = 1.0; +const ZOOM_EPSILON = 0.001; +const MAX_EVALUATION_BYTES = 64_000; +const MAX_VISIBLE_TEXT_LENGTH = 20_000; +const MAX_INTERACTIVE_ELEMENTS = 200; +const MAX_SCREENSHOT_WIDTH = 1280; +const DIAGNOSTIC_BUFFER_LIMIT = 200; +const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; +const AGENT_CURSOR_MOVE_MS = 160; +const AGENT_CURSOR_CLICK_LEAD_MS = 40; +const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { + colorScheme: "light", + radius: "0.625rem", + background: "white", + foreground: "oklch(0.269 0 0)", + popover: "white", + popoverForeground: "oklch(0.269 0 0)", + primary: "oklch(0.488 0.217 264)", + primaryForeground: "white", + muted: "rgb(0 0 0 / 4%)", + mutedForeground: "oklch(0.556 0 0)", + accent: "rgb(0 0 0 / 4%)", + accentForeground: "oklch(0.269 0 0)", + border: "rgb(0 0 0 / 8%)", + input: "rgb(0 0 0 / 10%)", + ring: "oklch(0.488 0.217 264)", + fontSans: "system-ui, sans-serif", + fontMono: "ui-monospace, monospace", +}; + +const artifactSiteSlug = (rawUrl: string): string => { + try { + const url = new URL(rawUrl); + const slug = url.hostname + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) + .replace(/-+$/g, ""); + return slug || "site"; + } catch { + return "site"; + } +}; + +interface CdpEvaluationResult { + readonly result?: { + readonly value?: unknown; + readonly description?: string; + }; + readonly exceptionDetails?: { + readonly text?: string; + readonly exception?: { readonly description?: string }; + }; +} + +const automationError = ( + tag: + | "PreviewAutomationExecutionError" + | "PreviewAutomationInvalidSelectorError" + | "PreviewAutomationResultTooLargeError" + | "PreviewAutomationTimeoutError" + | "PreviewAutomationControlInterruptedError", + message: string, + detail?: unknown, +): Error & { detail?: unknown } => { + const error = new Error(message) as Error & { detail?: unknown }; + error.name = tag; + if (detail !== undefined) error.detail = detail; + return error; +}; + +const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { + if (typeof value !== "object" || value === null) return null; + const rect = value as Record; + const x = rect["x"]; + const y = rect["y"]; + const width = rect["width"]; + const height = rect["height"]; + if ( + typeof x !== "number" || + !Number.isFinite(x) || + typeof y !== "number" || + !Number.isFinite(y) || + typeof width !== "number" || + !Number.isFinite(width) || + typeof height !== "number" || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null; + } + return { + x: Math.max(0, Math.floor(x)), + y: Math.max(0, Math.floor(y)), + width: Math.max(1, Math.ceil(width)), + height: Math.max(1, Math.ceil(height)), + }; +}; + +const captureAnnotationScreenshot = ( + wc: Electron.WebContents, + cropRect: PreviewAnnotationRect | null, +): Effect.Effect => + Effect.tryPromise({ + try: () => + wc.capturePage( + cropRect + ? { + x: cropRect.x, + y: cropRect.y, + width: cropRect.width, + height: cropRect.height, + } + : undefined, + ), + catch: (cause) => new PreviewManagerError({ operation: "captureAnnotationScreenshot", cause }), + }).pipe( + Effect.map((image) => { + const size = image.getSize(); + return { + dataUrl: image.toDataURL(), + width: size.width, + height: size.height, + cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, + }; + }), + ); + +const findZoomStep = (current: number): number => { + const index = ZOOM_LEVELS.findIndex( + (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current, + ); + if (index < 0) return ZOOM_LEVELS.length - 1; + return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1; +}; + +const nextZoomLevel = (current: number, direction: "in" | "out"): number => { + const step = findZoomStep(current); + if (direction === "in") { + return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current; + } + return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; +}; + +type Listener = (tabId: string, state: PreviewTabState) => void; +type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; + +type PreviewInputSignal = + | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } + | { readonly kind: "key"; readonly key: string; readonly code: string }; + +interface ManagedListeners { + readonly scope: Scope.Closeable; +} + +interface PickSession { + readonly cancel: Effect.Effect; +} + +interface BrowserControlSession { + readonly webContentsId: number; + readonly semaphore: Semaphore.Semaphore; + readonly scope: Scope.Closeable; + readonly onMessage: ( + event: Electron.Event, + method: string, + params: Record, + ) => void; +} + +interface BrowserDiagnostics { + readonly consoleEntries: ReadonlyArray; + readonly networkEntries: ReadonlyArray; + readonly requests: ReadonlyMap; +} + +type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; + +interface ExpectedAgentInput { + readonly signal: PreviewInputSignal; + readonly expiresAt: number; +} + +const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ + key: string; + meta: boolean; + shift: boolean; + control: boolean; +}> = Object.freeze([ + // mod+shift+J → preview.toggle + { key: "j", meta: true, shift: true, control: false }, + // mod+K → command palette + { key: "k", meta: true, shift: false, control: false }, + // mod+, → settings (macOS convention) + { key: ",", meta: true, shift: false, control: false }, + // mod+W → close tab/panel + { key: "w", meta: true, shift: false, control: false }, +]); + +const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { + if (typeof value !== "object" || value === null || !("kind" in value)) return false; + if (value.kind === "pointer") { + return ( + "x" in value && + typeof value.x === "number" && + "y" in value && + typeof value.y === "number" && + "button" in value && + typeof value.button === "number" + ); + } + return ( + value.kind === "key" && + "key" in value && + typeof value.key === "string" && + "code" in value && + typeof value.code === "string" + ); +}; + +const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => { + if (left.kind !== right.kind) return false; + if (left.kind === "pointer" && right.kind === "pointer") { + return ( + Math.abs(left.x - right.x) <= 1 && + Math.abs(left.y - right.y) <= 1 && + left.button === right.button + ); + } + return ( + left.kind === "key" && + right.kind === "key" && + left.key === right.key && + left.code === right.code + ); +}; + +const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( + artifactDirectory: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentScope = yield* Scope.Scope; + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + const resolvedArtifactDirectory = path.resolve(artifactDirectory); + const playwrightInstallExpression = yield* Effect.cached( + playwrightInjectedRuntimeInstallExpression().pipe( + Effect.mapError( + (cause) => + new PreviewManagerError({ + operation: "ensurePlaywrightInjected", + cause, + }), + ), + ), + ); + + const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); + const mainWindowRef = yield* Ref.make>(Option.none()); + const tabsRef = yield* SynchronizedRef.make>(new Map()); + const attachedRef = yield* Ref.make>(new Map()); + const listenersRef = yield* Ref.make>(new Set()); + const pointerEventListenersRef = yield* Ref.make>(new Set()); + const recordingFrameListenersRef = yield* Ref.make>( + new Set(), + ); + const pickSessionsRef = yield* Ref.make>(new Map()); + const controlSessionsRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); + const diagnosticsRef = yield* Ref.make>(new Map()); + const expectedAgentInputsRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const controlEpochRef = yield* Ref.make>(new Map()); + const actionTimelineRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const actionSequenceRef = yield* Ref.make(0); + const pointerSequenceRef = yield* Ref.make(0); + const recordingTabIdRef = yield* Ref.make>(Option.none()); + + const fail = (operation: string, cause: unknown): PreviewManagerError => + new PreviewManagerError({ operation, cause }); + const attempt = (operation: string, evaluate: () => A) => + Effect.try({ try: evaluate, catch: (cause) => fail(operation, cause) }); + const attemptPromise = (operation: string, evaluate: () => PromiseLike) => + Effect.tryPromise({ try: evaluate, catch: (cause) => fail(operation, cause) }); + const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const currentMillis = Clock.currentTimeMillis; + const encodeJson = (operation: string, value: unknown) => + encodeUnknownJson(value).pipe(Effect.mapError((cause) => fail(operation, cause))); + const nextCounter = (ref: Ref.Ref) => + Ref.modify(ref, (value) => [value, value + 1] as const); + const replaceMap = ( + source: ReadonlyMap, + update: (copy: Map) => void, + ): ReadonlyMap => { + const copy = new Map(source); + update(copy); + return copy; + }; + + const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { + const listeners = yield* Ref.get(listenersRef); + yield* Effect.forEach( + listeners, + (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore), + { discard: true }, + ); + }); + + const update = Effect.fn("PreviewManager.update")(function* ( + tabId: string, + patch: Partial, + ) { + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (!current) return [Option.none(), tabs] as const; + const state: PreviewTabState = { ...current, ...patch, updatedAt }; + return [ + Option.some(state), + replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }), + ] as const; + }); + if (Option.isSome(next)) yield* emit(tabId, next.value); + }); + + const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( + tabId: string, + ) { + const tabs = yield* SynchronizedRef.get(tabsRef); + const tab = tabs.get(tabId); + if (!tab) return yield* fail("requireWebContents", new PreviewTabNotFoundError(tabId)); + if (tab.webContentsId == null) { + return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError(tabId)); + } + const wc = webContents.fromId(tab.webContentsId); + if (!wc) { + return yield* fail( + "requireWebContents", + new PreviewWebContentsNotFoundError(tabId, tab.webContentsId), + ); + } + return wc; + }); + + const resolveArtifactPath = (artifactPath: string) => + attempt("resolveArtifactPath", () => { + const resolvedPath = path.resolve(artifactPath); + const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + return null; + } + return resolvedPath; + }).pipe( + Effect.flatMap((resolvedPath) => + resolvedPath === null + ? Effect.fail( + fail( + "resolveArtifactPath", + new Error("Preview artifact path is outside the configured artifact directory."), + ), + ) + : Effect.succeed(resolvedPath), + ), + ); + + const tabIdForWebContents = Effect.fn("PreviewManager.tabIdForWebContents")(function* ( + webContentsId: number, + ) { + const tabs = yield* SynchronizedRef.get(tabsRef); + return ( + Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null + ); + }); + + const pushBounded = (buffer: ReadonlyArray, entry: A): ReadonlyArray => + [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); + + const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* ( + webContentsId: number, + method: string, + params: Record, + ) { + const timestamp = yield* currentIso; + yield* Ref.update(diagnosticsRef, (allDiagnostics) => { + const current = allDiagnostics.get(webContentsId); + if (!current) return allDiagnostics; + const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; + const next = (() => { + if (method === "Runtime.consoleAPICalled") { + const args = Array.isArray(params["args"]) ? params["args"] : []; + const text = args + .map((arg) => { + if (typeof arg !== "object" || arg === null) return String(arg); + const value = arg as Record; + return String(value["value"] ?? value["description"] ?? ""); + }) + .join(" "); + return { + ...current, + consoleEntries: pushBounded(current.consoleEntries, { + level: typeof params["type"] === "string" ? params["type"] : "log", + text, + timestamp, + source: "console", + }), + }; + } + if (method === "Runtime.exceptionThrown") { + const details = + typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null + ? (params["exceptionDetails"] as Record) + : {}; + return { + ...current, + consoleEntries: pushBounded(current.consoleEntries, { + level: "error", + text: String(details["text"] ?? "Uncaught exception"), + timestamp, + source: "exception", + }), + }; + } + if (method === "Log.entryAdded") { + const entry = + typeof params["entry"] === "object" && params["entry"] !== null + ? (params["entry"] as Record) + : {}; + return { + ...current, + consoleEntries: pushBounded(current.consoleEntries, { + level: typeof entry["level"] === "string" ? entry["level"] : "info", + text: String(entry["text"] ?? ""), + timestamp, + source: typeof entry["source"] === "string" ? entry["source"] : "log", + }), + }; + } + if (method === "Network.requestWillBeSent" && requestId) { + const request = + typeof params["request"] === "object" && params["request"] !== null + ? (params["request"] as Record) + : {}; + return { + ...current, + requests: replaceMap(current.requests, (copy) => { + copy.set(requestId, { + url: String(request["url"] ?? ""), + method: String(request["method"] ?? "GET"), + }); + }), + }; + } + if (method === "Network.responseReceived" && requestId) { + const request = current.requests.get(requestId); + const response = + typeof params["response"] === "object" && params["response"] !== null + ? (params["response"] as Record) + : {}; + const status = typeof response["status"] === "number" ? response["status"] : null; + return request && status !== null && status >= 400 + ? { + ...current, + networkEntries: pushBounded(current.networkEntries, { + ...request, + status, + failed: true, + timestamp, + }), + } + : current; + } + if (method === "Network.loadingFailed" && requestId) { + const request = current.requests.get(requestId); + return { + ...current, + requests: replaceMap(current.requests, (copy) => { + copy.delete(requestId); + }), + networkEntries: request + ? pushBounded(current.networkEntries, { + ...request, + status: null, + failed: true, + errorText: String(params["errorText"] ?? "Network request failed"), + timestamp, + }) + : current.networkEntries, + }; + } + if (method === "Network.loadingFinished" && requestId) { + return { + ...current, + requests: replaceMap(current.requests, (copy) => { + copy.delete(requestId); + }), + }; + } + return current; + })(); + return replaceMap(allDiagnostics, (copy) => { + copy.set(webContentsId, next); + }); + }); + }); + + const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* ( + webContentsId: number, + ) { + const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [ + sessions.get(webContentsId), + replaceMap(sessions, (copy) => { + copy.delete(webContentsId); + }), + ]); + if (control) { + yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore); + return; + } + yield* Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.delete(webContentsId); + }), + ); + }); + + const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( + wc: Electron.WebContents, + ) { + return yield* SynchronizedRef.modifyEffect(controlSessionsRef, (sessions) => { + const existing = sessions.get(wc.id); + if (existing) return Effect.succeed([existing, sessions] as const); + if (wc.isDevToolsOpened()) { + return Effect.fail( + fail( + "ensureControlSession", + automationError( + "PreviewAutomationExecutionError", + "Close preview DevTools before using agent browser control.", + ), + ), + ); + } + if (wc.debugger.isAttached()) { + return Effect.fail( + fail( + "ensureControlSession", + automationError( + "PreviewAutomationExecutionError", + "Preview control cannot attach because another debugger owns this page.", + ), + ), + ); + } + const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { + const semaphore = yield* Semaphore.make(1); + const scope = yield* Scope.fork(parentScope, "sequential"); + const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(function* ( + method: string, + params: Record, + ) { + if (method === "Page.screencastFrame") { + const sessionId = params["sessionId"]; + if (typeof sessionId === "number") { + yield* attemptPromise("ackScreencastFrame", () => + wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + ).pipe(Effect.ignore); + } + const tabId = yield* tabIdForWebContents(wc.id); + const metadata = + typeof params["metadata"] === "object" && params["metadata"] !== null + ? (params["metadata"] as Record) + : {}; + if (tabId && typeof params["data"] === "string") { + const receivedAt = yield* currentIso; + const listeners = yield* Ref.get(recordingFrameListenersRef); + const frame: DesktopPreviewRecordingFrame = { + tabId, + data: params["data"], + width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt, + }; + yield* Effect.forEach( + listeners, + (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), + { discard: true }, + ); + } + } + yield* captureDiagnosticMessage(wc.id, method, params); + }); + const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { + runFork(handleDebuggerMessage(method, params)); + }; + yield* Scope.addFinalizer( + scope, + Effect.all( + [ + Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.delete(wc.id); + }), + ), + attempt("detachControlSession", () => { + wc.debugger.off("message", onMessage); + if (wc.debugger.isAttached()) wc.debugger.detach(); + }).pipe(Effect.ignore), + ], + { discard: true }, + ), + ); + const control: BrowserControlSession = { + webContentsId: wc.id, + semaphore, + scope, + onMessage, + }; + const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { + yield* Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.set(wc.id, { + consoleEntries: [], + networkEntries: [], + requests: new Map(), + }); + }), + ); + yield* attempt("attachDebuggerListeners", () => { + wc.debugger.on("message", onMessage); + wc.debugger.attach("1.3"); + }); + yield* Effect.all( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( + (method) => + attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), + ), + { concurrency: "unbounded", discard: true }, + ); + return [ + control, + replaceMap(sessions, (copy) => { + copy.set(wc.id, control); + }), + ] as const; + }); + return yield* initialize().pipe( + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + ); + }); + return createControlSession(); + }); + }); + + const pushAction = (tabId: string, event: PreviewAutomationActionEvent) => + Ref.update(actionTimelineRef, (timelines) => + replaceMap(timelines, (copy) => { + copy.set(tabId, [...(timelines.get(tabId) ?? []), event].slice(-200)); + }), + ); + const replaceAction = (tabId: string, event: PreviewAutomationActionEvent) => + Ref.update(actionTimelineRef, (timelines) => { + const timeline = timelines.get(tabId); + if (!timeline) return timelines; + return replaceMap(timelines, (copy) => { + copy.set( + tabId, + timeline.map((candidate) => (candidate.id === event.id ? event : candidate)), + ); + }); + }); + + type SendCommand = ( + method: string, + commandParams?: Record, + ) => Effect.Effect; + + const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* ( + tabId: string, + wc: Electron.WebContents, + action: string, + use: (send: SendCommand) => Effect.Effect, + ) { + const sequence = yield* nextCounter(actionSequenceRef); + const startedAt = yield* currentIso; + const millis = yield* currentMillis; + const actionEvent: PreviewAutomationActionEvent = { + id: `browser-action-${millis.toString(36)}-${sequence.toString(36)}`, + action, + status: "running", + startedAt, + }; + yield* pushAction(tabId, actionEvent); + const epoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; + const control = yield* ensureControlSession(wc); + const execute = Effect.fn("PreviewManager.executeControlAction")(function* () { + yield* update(tabId, { controller: "agent" }); + const send: SendCommand = Effect.fn("PreviewManager.sendCommand")( + function* (method, commandParams) { + const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; + if (before !== epoch) { + return yield* fail( + action, + automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ), + ); + } + const result = yield* attemptPromise(action, () => + wc.debugger.sendCommand(method, commandParams), + ); + const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; + if (after !== epoch) { + return yield* fail( + action, + automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ), + ); + } + return result; + }, + ); + return yield* use(send); + }); + const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( + exit: Exit.Exit, + ) { + const completedAt = yield* currentIso; + if (exit._tag === "Success") { + yield* replaceAction(tabId, { + ...actionEvent, + status: "succeeded", + completedAt, + }); + } else { + const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); + const underlying = error instanceof PreviewManagerError ? error.cause : error; + const interrupted = + underlying instanceof Error && + underlying.name === "PreviewAutomationControlInterruptedError"; + yield* replaceAction(tabId, { + ...actionEvent, + status: interrupted ? "interrupted" : "failed", + completedAt, + error: underlying instanceof Error ? underlying.message : String(underlying), + }); + } + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.has(tabId)) yield* update(tabId, { controller: "none" }); + }); + return yield* control.semaphore.withPermit(execute().pipe(Effect.onExit(finalize))); + }); + + const evaluateWithDebugger = ( + send: SendCommand, + expression: string, + returnByValue: boolean, + awaitPromise = true, + ): Effect.Effect => + send("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue, + userGesture: true, + }).pipe( + Effect.flatMap((rawResponse) => { + const response = rawResponse as CdpEvaluationResult; + return response.exceptionDetails + ? Effect.fail( + fail( + "evaluate", + automationError( + "PreviewAutomationExecutionError", + response.exceptionDetails.exception?.description ?? + response.exceptionDetails.text ?? + "JavaScript evaluation failed.", + ), + ), + ) + : Effect.succeed(response.result?.value as A); + }), + ); + + const automationLocator = (input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null); + + const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* ( + send: SendCommand, + ) { + const installed = yield* evaluateWithDebugger( + send, + "Boolean(globalThis.__t3PlaywrightInjected)", + true, + ); + if (installed) return; + const expression = yield* playwrightInstallExpression; + yield* evaluateWithDebugger(send, expression, true); + }); + + const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( + tabId: string, + ) { + const session = (yield* Ref.get(pickSessionsRef)).get(tabId); + if (session) yield* session.cancel; + }); + + const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* ( + webContentsId: number, + ) { + const managed = yield* Ref.modify(attachedRef, (attached) => [ + attached.get(webContentsId), + replaceMap(attached, (copy) => { + copy.delete(webContentsId); + }), + ]); + if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); + }); + + const isAppShortcut = (input: Electron.Input): boolean => + input.type === "keyDown" && + APP_FORWARDED_SHORTCUTS.some( + (shortcut) => + shortcut.key.toLowerCase() === input.key.toLowerCase() && + shortcut.meta === input.meta && + shortcut.shift === input.shift && + shortcut.control === input.control, + ); + + const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => { + const url = wc.getURL(); + const title = wc.getTitle(); + if (url === "" || url === "about:blank") return { kind: "Idle" }; + if (wc.isLoading()) return { kind: "Loading", url, title }; + return { kind: "Success", url, title }; + }; + + const consumeExpectedAgentInput = Effect.fn("PreviewManager.consumeExpectedAgentInput")( + function* (tabId: string, signal: PreviewInputSignal) { + const now = yield* currentMillis; + return yield* Ref.modify(expectedAgentInputsRef, (allExpected) => { + const pending = (allExpected.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); + const matched = index >= 0; + const nextPending = matched + ? pending.filter((_, pendingIndex) => pendingIndex !== index) + : pending; + return [ + matched, + replaceMap(allExpected, (copy) => { + if (nextPending.length === 0) copy.delete(tabId); + else copy.set(tabId, nextPending); + }), + ] as const; + }); + }, + ); + + const expectAgentInput = Effect.fn("PreviewManager.expectAgentInput")(function* ( + tabId: string, + signal: PreviewInputSignal, + ) { + const now = yield* currentMillis; + yield* Ref.update(expectedAgentInputsRef, (allExpected) => + replaceMap(allExpected, (copy) => { + const pending = (allExpected.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + copy.set(tabId, [...pending, { signal, expiresAt: now + 1_000 }]); + }), + ); + }); + + const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* ( + tabId: string, + wc: Electron.WebContents, + ) { + const scope = yield* Scope.fork(parentScope, "sequential"); + const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () { + if (wc.isDestroyed()) return; + yield* update(tabId, { + navStatus: computeNavStatus(wc), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + }); + }); + const sync = () => runFork(syncState()); + const failed = (_event: Event, code: number, description: string): void => { + if (code === -3) return; + runFork( + update(tabId, { + navStatus: { + kind: "LoadFailed", + url: wc.getURL(), + title: wc.getTitle(), + code, + description, + }, + }), + ); + }; + const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( + rawSignal?: unknown, + ) { + if (isPreviewInputSignal(rawSignal) && (yield* consumeExpectedAgentInput(tabId, rawSignal))) { + return; + } + yield* Ref.update(controlEpochRef, (epochs) => + replaceMap(epochs, (copy) => { + copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); + }), + ); + yield* update(tabId, { controller: "human" }); + yield* Effect.sleep(750); + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.get(tabId)?.controller === "human") { + yield* update(tabId, { controller: "none" }); + } + }); + const humanInput = (_event: unknown, rawSignal?: unknown): void => { + runFork(handleHumanInput(rawSignal)); + }; + const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( + event: Electron.Event, + input: Electron.Input, + ) { + const mainWindow = yield* Ref.get(mainWindowRef); + if (!isAppShortcut(input) || Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { + return; + } + event.preventDefault(); + mainWindow.value.webContents.sendInputEvent({ + type: "keyDown", + keyCode: input.key, + modifiers: [ + ...(input.meta ? (["meta"] as const) : []), + ...(input.shift ? (["shift"] as const) : []), + ...(input.control ? (["control"] as const) : []), + ...(input.alt ? (["alt"] as const) : []), + ], + }); + }); + const beforeInput = (event: Electron.Event, input: Electron.Input): void => { + runFork(forwardShortcut(event, input)); + }; + yield* Scope.addFinalizer( + scope, + attempt("detachListeners", () => { + wc.off("did-navigate", sync); + wc.off("did-navigate-in-page", sync); + wc.off("page-title-updated", sync); + wc.off("did-start-loading", sync); + wc.off("did-stop-loading", sync); + wc.off("did-fail-load", failed as never); + wc.off("before-input-event", beforeInput); + wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + }).pipe(Effect.ignore), + ); + const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { + yield* attempt("attachListeners", () => { + wc.on("did-navigate", sync); + wc.on("did-navigate-in-page", sync); + wc.on("page-title-updated", sync); + wc.on("did-start-loading", sync); + wc.on("did-stop-loading", sync); + wc.on("did-fail-load", failed as never); + wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.setWindowOpenHandler(({ url }) => { + runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); + return { action: "deny" }; + }); + wc.on("before-input-event", beforeInput); + }); + yield* Ref.update(attachedRef, (attached) => + replaceMap(attached, (copy) => { + copy.set(wc.id, { scope }); + }), + ); + }); + yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); + }); + + const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* ( + window: BrowserWindow, + ) { + yield* Ref.set(mainWindowRef, Option.some(window)); + }); + + const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) { + const updatedAt = yield* currentIso; + const state = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const existing = tabs.get(tabId); + if (existing) return [existing, tabs] as const; + const initial: PreviewTabState = { + tabId, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", + updatedAt, + }; + return [ + initial, + replaceMap(tabs, (copy) => { + copy.set(tabId, initial); + }), + ] as const; + }); + yield* emit(tabId, state); + return state; + }); + + const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) return; + yield* cancelPickElement(tabId); + if (tab.webContentsId != null) { + yield* Effect.all( + [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)], + { concurrency: 2, discard: true }, + ); + } + const updatedAt = yield* currentIso; + const closed: PreviewTabState = { + ...tab, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", + updatedAt, + }; + yield* SynchronizedRef.update(tabsRef, (tabs) => + replaceMap(tabs, (copy) => { + copy.delete(tabId); + }), + ); + yield* emit(tabId, closed); + }); + + const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( + tabId: string, + webContentsId: number, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) { + return yield* fail("registerWebview", new PreviewTabNotFoundError(tabId)); + } + const wc = webContents.fromId(webContentsId); + const mainWindow = yield* Ref.get(mainWindowRef); + if ( + !wc || + wc.getType() !== "webview" || + (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) + ) { + return yield* fail( + "registerWebview", + new PreviewWebContentsNotFoundError(tabId, webContentsId), + ); + } + const attached = yield* Ref.get(attachedRef); + const annotationTheme = yield* Ref.get(annotationThemeRef); + if (tab.webContentsId === webContentsId && attached.has(webContentsId)) { + yield* attempt("registerWebview.sendTheme", () => + wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), + ); + return; + } + if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { + yield* Effect.all( + [ + detachControlSession(tab.webContentsId), + detachListeners(tab.webContentsId), + cancelPickElement(tabId), + ], + { concurrency: 3, discard: true }, + ); + } + yield* attachListeners(tabId, wc); + runFork(ensureControlSession(wc).pipe(Effect.ignore)); + if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { + yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe( + Effect.ignore, + ); + } + yield* update(tabId, { + webContentsId, + navStatus: computeNavStatus(wc), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + zoomFactor: tab.zoomFactor, + }); + yield* attempt("registerWebview.sendTheme", () => + wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), + ); + }); + + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { + const wc = yield* requireWebContents(tabId); + const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl)); + if (wc.getURL() === url) { + yield* attempt("navigate.reload", () => wc.reload()); + return; + } + yield* attemptPromise("navigate.loadURL", () => wc.loadURL(url)); + }); + + const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* ( + operation: string, + tabId: string, + use: (wc: Electron.WebContents) => void, + ) { + const wc = yield* requireWebContents(tabId); + yield* attempt(operation, () => use(wc)); + }); + + const goBack = (tabId: string) => + withWebContents("goBack", tabId, (wc) => { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + }); + const goForward = (tabId: string) => + withWebContents("goForward", tabId, (wc) => { + if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); + }); + const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); + const hardReload = (tabId: string) => + withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); + + const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { + const wc = yield* requireWebContents(tabId); + if (wc.isDevToolsOpened()) { + yield* attempt("openDevTools.focus", () => wc.devToolsWebContents?.focus()); + return; + } + yield* detachControlSession(wc.id); + yield* attempt("openDevTools", () => { + wc.once("devtools-closed", () => { + if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); + }); + wc.openDevTools({ mode: "detach" }); + }); + }); + + const setAnnotationTheme = Effect.fn("PreviewManager.setAnnotationTheme")(function* ( + theme: DesktopPreviewAnnotationTheme, + ) { + yield* Ref.set(annotationThemeRef, theme); + const tabs = yield* SynchronizedRef.get(tabsRef); + yield* Effect.forEach( + tabs.values(), + (tab) => { + if (tab.webContentsId == null) return Effect.void; + const wc = webContents.fromId(tab.webContentsId); + return !wc || wc.isDestroyed() + ? Effect.void + : attempt("setAnnotationTheme", () => wc.send(ANNOTATION_THEME_CHANNEL, theme)).pipe( + Effect.ignore, + ); + }, + { discard: true }, + ); + }); + + const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) { + const wc = yield* requireWebContents(tabId); + yield* cancelPickElement(tabId); + const annotationTheme = yield* Ref.get(annotationThemeRef); + return yield* Effect.callback( + (resume) => { + const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { + yield* attempt("pickElement.cleanup", () => { + wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); + wc.off("destroyed", onDestroyed); + wc.off("did-start-navigation", onNavigated); + }).pipe(Effect.ignore); + yield* Ref.update(pickSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.delete(tabId); + }), + ); + }); + const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( + payload: PreviewAnnotationPayload | null, + ) { + const active = (yield* Ref.get(pickSessionsRef)).get(tabId); + if (!active || active.cancel !== cancel) return; + yield* cleanup(); + resume(Effect.succeed(payload)); + }); + const settle = (payload: PreviewAnnotationPayload | null) => { + runFork(settlePick(payload)); + }; + const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { + yield* cleanup(); + const tabs = yield* SynchronizedRef.get(tabsRef); + const activeTab = tabs.get(tabId); + if (activeTab?.webContentsId != null) { + const activeWc = webContents.fromId(activeTab.webContentsId); + if (activeWc && !activeWc.isDestroyed()) { + yield* attempt("cancelPickElement", () => activeWc.send(CANCEL_PICK_CHANNEL)).pipe( + Effect.ignore, + ); + } + } + resume(Effect.succeed(null)); + }); + const cancel = cancelPickSession(); + const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { + const payload = args[0]; + if (!isPreviewAnnotationPayload(payload)) { + settle(null); + return; + } + const cropRect = normalizeCaptureRect(args[1]); + runFork( + captureAnnotationScreenshot(wc, cropRect).pipe( + Effect.matchEffect({ + onFailure: () => Effect.sync(() => settle(payload)), + onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), + }), + Effect.ensuring( + attempt("pickElement.captureComplete", () => { + if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); + }).pipe(Effect.ignore), + ), + ), + ); + }; + const onDestroyed = () => settle(null); + const onNavigated = ( + _event: Electron.Event, + _url: string, + _isInPlace: boolean, + isMainFrame: boolean, + ) => { + if (isMainFrame) settle(null); + }; + const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { + yield* attempt("pickElement.register", () => { + wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + wc.once("destroyed", onDestroyed); + wc.once("did-start-navigation", onNavigated); + if (!wc.isFocused()) wc.focus(); + wc.send(START_PICK_CHANNEL, annotationTheme); + }); + yield* Ref.update(pickSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.set(tabId, { cancel }); + }), + ); + }); + runFork( + registerPickElement().pipe( + Effect.catch((error: PreviewManagerError) => { + resume(Effect.fail(error)); + return cleanup(); + }), + ), + ); + return cancel; + }, + ); + }); + + const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( + tabId: string, + transform: (current: number) => number, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) return; + const next = transform(tab.zoomFactor); + if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return; + if (tab.webContentsId != null) { + const wc = webContents.fromId(tab.webContentsId); + if (wc && !wc.isDestroyed()) { + yield* attempt("applyZoom", () => wc.setZoomFactor(next)); + } + } + yield* update(tabId, { zoomFactor: next }); + }); + + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( + tabId: string, + ) { + const wc = yield* requireWebContents(tabId); + const [createdAt, millis, image] = yield* Effect.all([ + currentIso, + currentMillis, + attemptPromise("captureScreenshot.capturePage", () => wc.capturePage()), + ]); + const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`; + const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`); + const data = image.toPNG(); + yield* fileSystem + .makeDirectory(resolvedArtifactDirectory, { recursive: true }) + .pipe(Effect.mapError((cause) => fail("captureScreenshot.makeDirectory", cause))); + yield* fileSystem + .writeFile(artifactPath, data) + .pipe(Effect.mapError((cause) => fail("captureScreenshot.writeFile", cause))); + return { + id, + tabId, + path: artifactPath, + mimeType: "image/png" as const, + sizeBytes: data.byteLength, + createdAt, + }; + }); + + const startScreencast = Effect.fn("PreviewManager.startScreencast")(function* ( + send: SendCommand, + ) { + yield* send("Page.enable"); + yield* send("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1600, + maxHeight: 1200, + everyNthFrame: 1, + }); + }); + + const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { + const recordingTabId = yield* Ref.get(recordingTabIdRef); + if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { + return yield* fail( + "startRecording", + new Error("Only one browser recording can be active per window."), + ); + } + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "recording.start", startScreencast); + yield* Ref.set(recordingTabIdRef, Option.some(tabId)); + }); + + const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { + const recordingTabId = yield* Ref.get(recordingTabIdRef); + if (Option.isNone(recordingTabId) || recordingTabId.value !== tabId) return; + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "recording.stop", (send) => + send("Page.stopScreencast").pipe(Effect.asVoid), + ); + yield* Ref.set(recordingTabIdRef, Option.none()); + }); + + const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) { + const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]); + const id = `browser-recording-${millis.toString(36)}`; + const extension = mimeType.includes("mp4") ? "mp4" : "webm"; + const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`); + yield* fileSystem + .makeDirectory(resolvedArtifactDirectory, { recursive: true }) + .pipe(Effect.mapError((cause) => fail("saveRecording.makeDirectory", cause))); + yield* fileSystem + .writeFile(artifactPath, data) + .pipe(Effect.mapError((cause) => fail("saveRecording.writeFile", cause))); + return { + id, + tabId, + path: artifactPath, + mimeType, + sizeBytes: data.byteLength, + createdAt, + }; + }); + + const automationStatus = Effect.fn("PreviewManager.automationStatus")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || tab.webContentsId == null) { + const navStatus = tab?.navStatus; + return { + available: false, + visible: true, + tabId, + url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, + title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, + loading: navStatus?.kind === "Loading", + }; + } + const wc = webContents.fromId(tab.webContentsId); + return !wc || wc.isDestroyed() + ? { + available: false, + visible: true, + tabId, + url: null, + title: null, + loading: false, + } + : { + available: true, + visible: true, + tabId, + url: wc.getURL() || null, + title: wc.getTitle() || null, + loading: wc.isLoading(), + }; + }); + + const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")( + function* (tabId: string, wc: Electron.WebContents, send: SendCommand) { + yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], { + concurrency: 2, + discard: true, + }); + const page = yield* evaluateWithDebugger<{ + url: string; + title: string; + loading: boolean; + visibleText: string; + interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; + }>( + send, + `(() => { + const selectorFor = (element) => { + if (element.id) return "#" + CSS.escape(element.id); + for (const attribute of ["data-testid", "name"]) { + const value = element.getAttribute(attribute); + if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; + } + const buildParts = (current, parts = []) => { + if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) { + return parts; + } + const parent = current.parentElement; + const siblings = parent + ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) + : []; + const base = current.tagName.toLowerCase(); + const part = siblings.length > 1 + ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" + : base; + return buildParts(parent, [part, ...parts]); + }; + return buildParts(element).join(" > "); + }; + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; + }; + const elements = Array.from(document.querySelectorAll( + "a[href],button,input,textarea,select,[role],[tabindex]" + )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role"), + name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", + selector: selectorFor(element), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }; + }); + return { + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), + interactiveElements: elements + }; + })()`, + true, + ); + const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ + send("Accessibility.getFullAXTree"), + attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()), + Ref.get(diagnosticsRef), + Ref.get(actionTimelineRef), + ]); + const sourceSize = sourceImage.getSize(); + const image = + sourceSize.width > MAX_SCREENSHOT_WIDTH + ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH }) + : sourceImage; + const size = image.getSize(); + const browserDiagnostics = diagnostics.get(wc.id); + return { + ...page, + accessibilityTree: accessibility, + consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])], + networkEntries: [...(browserDiagnostics?.networkEntries ?? [])], + actionTimeline: [...(timelines.get(tabId) ?? [])], + screenshot: { + mimeType: "image/png" as const, + data: image.toPNG().toString("base64"), + width: size.width, + height: size.height, + }, + }; + }, + ); + + const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* ( + tabId: string, + ) { + const wc = yield* requireWebContents(tabId); + return yield* withControlSession(tabId, wc, "snapshot", (send) => + captureAutomationSnapshot(tabId, wc, send), + ); + }); + + const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* ( + send: SendCommand, + input: PreviewAutomationClickInput, + ) { + if (!("selector" in input) && !("locator" in input)) { + return { x: input.x!, y: input.y! }; + } + const locator = automationLocator(input)!; + yield* ensurePlaywrightInjected(send); + const locatorJson = yield* encodeJson("automationClick.encodeLocator", locator); + const point = yield* evaluateWithDebugger< + { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const injected = globalThis.__t3PlaywrightInjected; + const parsed = injected.parseSelector(${locatorJson}); + const element = injected.querySelector(parsed, document, true); + if (!element) return { notFound: true }; + const visible = injected.elementState(element, "visible"); + const enabled = injected.elementState(element, "enabled"); + if (!visible.matches || !enabled.matches) return { notFound: true }; + element.scrollIntoView({ block: "center", inline: "center" }); + const rect = element.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in point) { + return yield* fail( + "automationClick", + automationError("PreviewAutomationInvalidSelectorError", point.message, { + selector: locator, + }), + ); + } + if ("notFound" in point) { + return yield* fail( + "automationClick", + automationError( + "PreviewAutomationExecutionError", + `No element matches locator ${locator}.`, + ), + ); + } + return point; + }); + + const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* ( + event: DesktopPreviewPointerEvent, + ) { + const listeners = yield* Ref.get(pointerEventListenersRef); + yield* Effect.forEach( + listeners, + (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore), + { discard: true }, + ); + }); + + const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* ( + tabId: string, + input: PreviewAutomationClickInput, + send: SendCommand, + ) { + yield* Effect.all( + [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], + { concurrency: 2, discard: true }, + ); + const point = yield* resolveClickPoint(send, input); + const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( + send, + "({ width: window.innerWidth, height: window.innerHeight })", + true, + ); + if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { + return yield* fail( + "automationClick", + automationError( + "PreviewAutomationExecutionError", + `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, + ), + ); + } + const moveSequence = yield* nextCounter(pointerSequenceRef); + const moveCreatedAt = yield* currentIso; + yield* emitPointerEvent({ + tabId, + phase: "move", + ...point, + sequence: moveSequence, + createdAt: moveCreatedAt, + }); + yield* Effect.sleep(AGENT_CURSOR_MOVE_MS); + const clickSequence = yield* nextCounter(pointerSequenceRef); + const clickCreatedAt = yield* currentIso; + yield* emitPointerEvent({ + tabId, + phase: "click", + ...point, + sequence: clickSequence, + createdAt: clickCreatedAt, + }); + yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS); + yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 }); + yield* send("Input.dispatchMouseEvent", { + type: "mousePressed", + ...point, + button: "left", + clickCount: 1, + }); + yield* send("Input.dispatchMouseEvent", { + type: "mouseReleased", + ...point, + button: "left", + clickCount: 1, + }); + }); + + const automationClick = Effect.fn("PreviewManager.automationClick")(function* ( + tabId: string, + input: PreviewAutomationClickInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "click", (send) => + performAutomationClick(tabId, input, send), + ); + }); + + const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* ( + send: SendCommand, + input: PreviewAutomationTypeInput, + ) { + const locator = automationLocator(input); + if (locator) yield* ensurePlaywrightInjected(send); + const locatorJson = locator ? yield* encodeJson("automationType.encodeLocator", locator) : null; + const result = yield* evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"}; + if (!element) return { notFound: true }; + element.focus(); + if (${input.clear ?? false}) { + if ("value" in element) element.value = ""; + else if (element.isContentEditable) element.textContent = ""; + element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); + } + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + return yield* fail( + "automationType", + automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }), + ); + } + if ("notFound" in result) { + return yield* fail( + "automationType", + automationError( + "PreviewAutomationExecutionError", + locator + ? `No element matches locator ${locator}.` + : "No element is focused in the preview.", + ), + ); + } + }); + + const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* ( + tabId: string, + input: PreviewAutomationTypeInput, + send: SendCommand, + ) { + yield* send("Runtime.enable"); + yield* focusAutomationTarget(send, input); + yield* send("Input.insertText", { text: input.text }); + const textJson = yield* encodeJson("automationType.encodeText", input.text); + yield* evaluateWithDebugger( + send, + `(() => { + const element = document.activeElement; + element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} })); + element?.dispatchEvent(new Event("change", { bubbles: true })); + })()`, + false, + ); + }); + + const automationType = Effect.fn("PreviewManager.automationType")(function* ( + tabId: string, + input: PreviewAutomationTypeInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "type", (send) => + performAutomationType(tabId, input, send), + ); + }); + + const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* ( + tabId: string, + input: PreviewAutomationPressInput, + send: SendCommand, + ) { + const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { + switch (modifier) { + case "Alt": + return value | 1; + case "Control": + return value | 2; + case "Meta": + return value | 4; + case "Shift": + return value | 8; + } + }, 0); + const key = input.key; + const text = key.length === 1 ? key : undefined; + const params = { + key, + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + modifiers, + ...(text ? { text, unmodifiedText: text } : {}), + }; + yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); + yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); + yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); + }); + + const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( + tabId: string, + input: PreviewAutomationPressInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "press", (send) => + performAutomationPress(tabId, input, send), + ); + }); + + const performAutomationScroll = Effect.fn("PreviewManager.performAutomationScroll")(function* ( + tabId: string, + input: PreviewAutomationScrollInput, + send: SendCommand, + ) { + yield* send("Runtime.enable"); + const locator = automationLocator(input); + if (locator) yield* ensurePlaywrightInjected(send); + const locatorJson = locator + ? yield* encodeJson("automationScroll.encodeLocator", locator) + : null; + const result = yield* evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"}; + if (!target) return { notFound: true }; + target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + return yield* fail( + "automationScroll", + automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }), + ); + } + if ("notFound" in result) { + return yield* fail( + "automationScroll", + automationError( + "PreviewAutomationExecutionError", + `No element matches locator ${locator}.`, + ), + ); + } + }); + + const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* ( + tabId: string, + input: PreviewAutomationScrollInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "scroll", (send) => + performAutomationScroll(tabId, input, send), + ); + }); + + const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")( + function* (input: PreviewAutomationEvaluateInput, send: SendCommand) { + yield* send("Runtime.enable"); + const value = yield* evaluateWithDebugger( + send, + input.expression, + input.returnByValue ?? true, + input.awaitPromise ?? true, + ); + const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); + if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { + return yield* fail( + "automationEvaluate", + automationError( + "PreviewAutomationResultTooLargeError", + `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, + { maximumBytes: MAX_EVALUATION_BYTES }, + ), + ); + } + return value; + }, + ); + + const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* ( + tabId: string, + input: PreviewAutomationEvaluateInput, + ) { + const wc = yield* requireWebContents(tabId); + return yield* withControlSession(tabId, wc, "evaluate", (send) => + performAutomationEvaluate(input, send), + ); + }); + + const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( + input: PreviewAutomationWaitForInput, + send: SendCommand, + ) { + const timeoutMs = input.timeoutMs ?? 15_000; + yield* send("Runtime.enable"); + const locator = automationLocator(input); + if (locator) yield* ensurePlaywrightInjected(send); + const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ + locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), + input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null), + input.urlIncludes + ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) + : Effect.succeed(null), + ]); + const deadline = (yield* currentMillis) + timeoutMs; + while ((yield* currentMillis) <= deadline) { + const result = yield* evaluateWithDebugger< + { matched: boolean } | { invalidSelector: true; message: string } + >( + send, + `(() => { + try { + const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"}; + const textMatched = ${ + textJson ? `(document.body?.innerText || "").includes(${textJson})` : "true" + }; + const urlMatched = ${ + urlIncludesJson ? `location.href.includes(${urlIncludesJson})` : "true" + }; + return { matched: selectorMatched && textMatched && urlMatched }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + return yield* fail( + "automationWaitFor", + automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }), + ); + } + if (result.matched) return; + yield* Effect.sleep(100); + } + return yield* fail( + "automationWaitFor", + automationError( + "PreviewAutomationTimeoutError", + `Preview condition did not match within ${timeoutMs}ms.`, + ), + ); + }); + + const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "waitFor", (send) => + performAutomationWaitFor(input, send), + ); + }); + + const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* ( + artifactPath: string, + ) { + const resolvedPath = yield* resolveArtifactPath(artifactPath); + yield* attempt("revealArtifact", () => shell.showItemInFolder(resolvedPath)); + }); + + const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* ( + artifactPath: string, + ) { + const resolvedPath = yield* resolveArtifactPath(artifactPath); + const image = yield* attempt("copyArtifactToClipboard.load", () => + nativeImage.createFromPath(resolvedPath), + ); + if (image.isEmpty()) { + return yield* fail( + "copyArtifactToClipboard", + new Error("Preview artifact could not be loaded as an image."), + ); + } + yield* attempt("copyArtifactToClipboard.write", () => clipboard.writeImage(image)); + }); + + const subscribe = ( + ref: Ref.Ref>, + listener: A, + ): Effect.Effect => + Effect.acquireRelease( + Ref.update(ref, (listeners) => new Set([...listeners, listener])), + () => + Ref.update(ref, (listeners) => { + const next = new Set(listeners); + next.delete(listener); + return next; + }), + ).pipe(Effect.asVoid); + + const destroy = Effect.fn("PreviewManager.destroy")(function* () { + const tabs = yield* SynchronizedRef.get(tabsRef); + yield* Effect.forEach(tabs.keys(), closeTab, { discard: true }); + yield* Effect.all( + [ + Ref.set(listenersRef, new Set()), + Ref.set(expectedAgentInputsRef, new Map()), + Ref.set(pointerEventListenersRef, new Set()), + Ref.set(recordingFrameListenersRef, new Set()), + ], + { discard: true }, + ); + }); + + yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore)); + + return { + automationClick, + automationEvaluate, + automationPress, + automationScroll, + automationSnapshot, + automationStatus, + automationType, + automationWaitFor, + cancelPickElement, + captureScreenshot, + closeTab, + copyArtifactToClipboard, + createTab, + goBack, + goForward, + hardReload, + navigate, + openDevTools, + pickElement, + refresh, + registerWebview, + resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), + revealArtifact, + saveRecording, + setAnnotationTheme, + setMainWindow, + startRecording, + stopRecording, + subscribePointerEvents: (listener: PointerEventListener) => + subscribe(pointerEventListenersRef, listener), + subscribeRecordingFrames: (listener: RecordingFrameListener) => + subscribe(recordingFrameListenersRef, listener), + subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), + zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")), + zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")), + }; +}); + +export class PreviewTabNotFoundError extends Error { + readonly tabId: string; + constructor(tabId: string) { + super(`Preview tab not found: ${tabId}`); + this.name = "PreviewTabNotFoundError"; + this.tabId = tabId; + } +} + +export class PreviewWebContentsNotFoundError extends Error { + readonly tabId: string; + readonly webContentsId: number; + constructor(tabId: string, webContentsId: number) { + super(`WebContents ${webContentsId} not found for preview tab ${tabId}`); + this.name = "PreviewWebContentsNotFoundError"; + this.tabId = tabId; + this.webContentsId = webContentsId; + } +} + +export class PreviewWebviewNotInitializedError extends Error { + readonly tabId: string; + constructor(tabId: string) { + super(`Preview tab "${tabId}" has no webview registered`); + this.name = "PreviewWebviewNotInitializedError"; + this.tabId = tabId; + } +} + +export class PreviewManagerError extends Data.TaggedError("PreviewManagerError")<{ + readonly operation: string; + readonly cause: unknown; +}> { + override get message() { + return `Desktop preview operation failed: ${this.operation}`; + } +} + +export interface PreviewManagerShape { + readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; + readonly getBrowserSession: (scope?: string) => Effect.Effect; + readonly isBrowserPartition: (partition: string) => boolean; + readonly createTab: (tabId: string) => Effect.Effect; + readonly closeTab: (tabId: string) => Effect.Effect; + readonly registerWebview: ( + tabId: string, + webContentsId: number, + ) => Effect.Effect; + readonly navigate: (tabId: string, url: string) => Effect.Effect; + readonly goBack: (tabId: string) => Effect.Effect; + readonly goForward: (tabId: string) => Effect.Effect; + readonly refresh: (tabId: string) => Effect.Effect; + readonly zoomIn: (tabId: string) => Effect.Effect; + readonly zoomOut: (tabId: string) => Effect.Effect; + readonly resetZoom: (tabId: string) => Effect.Effect; + readonly hardReload: (tabId: string) => Effect.Effect; + readonly openDevTools: (tabId: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; + readonly getBrowserPartition: (scope?: string) => Effect.Effect; + readonly setAnnotationTheme: ( + theme: DesktopPreviewAnnotationTheme, + ) => Effect.Effect; + readonly pickElement: ( + tabId: string, + ) => Effect.Effect; + readonly cancelPickElement: (tabId: string) => Effect.Effect; + readonly captureScreenshot: ( + tabId: string, + ) => Effect.Effect; + readonly revealArtifact: (path: string) => Effect.Effect; + readonly copyArtifactToClipboard: (path: string) => Effect.Effect; + readonly startRecording: (tabId: string) => Effect.Effect; + readonly stopRecording: (tabId: string) => Effect.Effect; + readonly saveRecording: ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) => Effect.Effect; + readonly automationStatus: ( + tabId: string, + ) => Effect.Effect; + readonly automationSnapshot: ( + tabId: string, + ) => Effect.Effect; + readonly automationClick: ( + tabId: string, + input: PreviewAutomationClickInput, + ) => Effect.Effect; + readonly automationType: ( + tabId: string, + input: PreviewAutomationTypeInput, + ) => Effect.Effect; + readonly automationPress: ( + tabId: string, + input: PreviewAutomationPressInput, + ) => Effect.Effect; + readonly automationScroll: ( + tabId: string, + input: PreviewAutomationScrollInput, + ) => Effect.Effect; + readonly automationEvaluate: ( + tabId: string, + input: PreviewAutomationEvaluateInput, + ) => Effect.Effect; + readonly automationWaitFor: ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) => Effect.Effect; + readonly subscribeStateChanges: (listener: Listener) => Effect.Effect; + readonly subscribePointerEvents: ( + listener: PointerEventListener, + ) => Effect.Effect; + readonly subscribeRecordingFrames: ( + listener: RecordingFrameListener, + ) => Effect.Effect; +} + +export class PreviewManager extends Context.Service()( + "@t3tools/desktop/preview/Manager/PreviewManager", +) {} + +const make = Effect.gen(function* PreviewManagerMake() { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const browserSession = yield* BrowserSession.BrowserSession; + const operations = yield* makeNativeOperations(environment.browserArtifactsDir); + const browserSessionEffect = ( + operation: string, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause }))); + + return PreviewManager.of({ + setMainWindow: operations.setMainWindow, + getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { + return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope)); + }), + isBrowserPartition: browserSession.isPartition, + createTab: operations.createTab, + closeTab: operations.closeTab, + registerWebview: operations.registerWebview, + navigate: operations.navigate, + goBack: operations.goBack, + goForward: operations.goForward, + refresh: operations.refresh, + zoomIn: operations.zoomIn, + zoomOut: operations.zoomOut, + resetZoom: operations.resetZoom, + hardReload: operations.hardReload, + openDevTools: operations.openDevTools, + clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { + yield* browserSessionEffect("clearCookies", browserSession.clearCookies()); + }), + clearCache: Effect.fn("PreviewManager.clearCache")(function* () { + yield* browserSessionEffect("clearCache", browserSession.clearCache()); + }), + getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { + return yield* browserSessionEffect("getBrowserPartition", browserSession.getPartition(scope)); + }), + setAnnotationTheme: operations.setAnnotationTheme, + pickElement: operations.pickElement, + cancelPickElement: operations.cancelPickElement, + captureScreenshot: operations.captureScreenshot, + revealArtifact: operations.revealArtifact, + copyArtifactToClipboard: operations.copyArtifactToClipboard, + startRecording: operations.startRecording, + stopRecording: operations.stopRecording, + saveRecording: operations.saveRecording, + automationStatus: operations.automationStatus, + automationSnapshot: operations.automationSnapshot, + automationClick: operations.automationClick, + automationType: operations.automationType, + automationPress: operations.automationPress, + automationScroll: operations.automationScroll, + automationEvaluate: operations.automationEvaluate, + automationWaitFor: operations.automationWaitFor, + subscribeStateChanges: operations.subscribeStateChanges, + subscribePointerEvents: operations.subscribePointerEvents, + subscribeRecordingFrames: operations.subscribeRecordingFrames, + }); +}).pipe(Effect.withSpan("PreviewManager.make")); + +export const layer = Layer.effect(PreviewManager, make); diff --git a/apps/desktop/src/preview/PickLabelPosition.ts b/apps/desktop/src/preview/PickLabelPosition.ts new file mode 100644 index 00000000000..cf7f3c811f8 --- /dev/null +++ b/apps/desktop/src/preview/PickLabelPosition.ts @@ -0,0 +1,46 @@ +/** + * Pure clamp/flip math for the floating label that follows the cursor while + * the user is picking an element in the in-app browser. Lives in its own + * electron-free module so the geometry can be unit-tested without spinning + * up an Electron preload context (`PickPreload.ts` itself imports + * `electron` and `react-grab/primitives`, which can't load under vitest). + * + * - Horizontally pins the label to `targetLeft`, clamped into + * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. + * - Vertically prefers above the target. If the label would overflow the + * top, flips below; if THAT also overflows the bottom, pins to the + * bottom margin (better to overlap the highlight than disappear). + */ + +/** Distance in CSS pixels between the highlight and the floating label. */ +export const LABEL_GAP = 4; +/** Minimum padding the label keeps from any viewport edge. */ +export const VIEWPORT_MARGIN = 4; + +export function computeLabelPosition(input: { + targetLeft: number; + targetTop: number; + targetBottom: number; + labelWidth: number; + labelHeight: number; + viewportWidth: number; + viewportHeight: number; +}): { x: number; y: number } { + const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; + const { viewportWidth, viewportHeight } = input; + + let x = targetLeft; + const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; + if (x > maxX) x = maxX; + if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; + + let y = targetTop - labelHeight - LABEL_GAP; + if (y < VIEWPORT_MARGIN) { + y = targetBottom + LABEL_GAP; + if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { + y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); + } + } + + return { x, y }; +} diff --git a/apps/desktop/src/preview/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts new file mode 100644 index 00000000000..5696fe50812 --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { computeLabelPosition } from "./PickLabelPosition.ts"; + +const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; + +describe("computeLabelPosition", () => { + it("anchors to the element's top-left when there's room above and to the right", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(200); + // 200 (top) - 18 (height) - 4 (gap) + expect(y).toBe(200 - 18 - 4); + }); + + it("clamps left edge so the label stays inside the viewport", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -50, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(4); + }); + + it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 1240, + targetTop: 200, + targetBottom: 240, + labelWidth: 200, + labelHeight: 18, + }); + // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076 + expect(x).toBe(1076); + }); + + it("flips the label below the element when there's no room above", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 4, + targetBottom: 44, + labelWidth: 120, + labelHeight: 18, + }); + // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48 + expect(y).toBe(48); + }); + + it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 0, + targetBottom: 800, + labelWidth: 120, + labelHeight: 18, + }); + // Above overflows top → flip below = 800 + 4 = 804 → also overflows + // bottom → pin to viewportHeight - labelHeight - margin = 778. + expect(y).toBe(800 - 18 - 4); + }); + + it("never returns a negative coordinate", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -1000, + targetTop: -1000, + targetBottom: -900, + labelWidth: 5000, + labelHeight: 5000, + }); + expect(x).toBeGreaterThanOrEqual(0); + expect(y).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts new file mode 100644 index 00000000000..2654b898102 --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.ts @@ -0,0 +1,1263 @@ +// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. +import { ipcRenderer } from "electron"; +import { getElementContext } from "react-grab/primitives"; +import type { + DesktopPreviewAnnotationTheme, + PickedElementPayload, + PickedElementStackFrame, + PreviewAnnotationPayload, + PreviewAnnotationPoint, + PreviewAnnotationRect, + PreviewAnnotationRegionTarget, + PreviewAnnotationStrokeTarget, + PreviewAnnotationStyleChange, +} from "@t3tools/contracts"; + +import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; +import { + ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_THEME_CHANNEL, + CANCEL_PICK_CHANNEL, + ELEMENT_PICKED_CHANNEL, + HUMAN_INPUT_CHANNEL, + START_PICK_CHANNEL, +} from "./GuestProtocol.ts"; +const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; +const Z_INDEX_OVERLAY = 2147483646; +const PRIMARY = "var(--t3-primary)"; +const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; +const MAX_MARQUEE_ELEMENTS = 20; +const CONTENT_LAYER_Z_INDEX = 1; +const CHROME_LAYER_Z_INDEX = 10; + +type AnnotationTool = "select" | "marquee" | "draw" | "erase"; + +interface SelectedElement { + id: string; + element: Element; + outline: HTMLDivElement; + label: HTMLDivElement; + baselineStyles: Map; +} + +interface AnnotationSession { + teardown: (notifyMain: boolean) => void; + applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; +} + +let activeSession: AnnotationSession | null = null; +let idSequence = 0; +let annotationTheme: DesktopPreviewAnnotationTheme | null = null; + +const applyAnnotationTheme = ( + host: HTMLElement, + theme: DesktopPreviewAnnotationTheme | null, +): void => { + if (!theme) return; + host.style.colorScheme = theme.colorScheme; + const variables = { + "--t3-radius": theme.radius, + "--t3-background": theme.background, + "--t3-foreground": theme.foreground, + "--t3-popover": theme.popover, + "--t3-popover-foreground": theme.popoverForeground, + "--t3-primary": theme.primary, + "--t3-primary-foreground": theme.primaryForeground, + "--t3-muted": theme.muted, + "--t3-muted-foreground": theme.mutedForeground, + "--t3-accent": theme.accent, + "--t3-accent-foreground": theme.accentForeground, + "--t3-border": theme.border, + "--t3-input": theme.input, + "--t3-ring": theme.ring, + "--t3-font-sans": theme.fontSans, + "--t3-font-mono": theme.fontMono, + }; + for (const [name, value] of Object.entries(variables)) { + host.style.setProperty(name, value); + } +}; + +const reportHumanPointerInput = (event: PointerEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "pointer", + x: event.clientX, + y: event.clientY, + button: event.button, + }); +}; + +const reportHumanKeyInput = (event: KeyboardEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "key", + key: event.key, + code: event.code, + }); +}; + +window.addEventListener("pointerdown", reportHumanPointerInput, true); +window.addEventListener("keydown", reportHumanKeyInput, true); + +const nextId = (prefix: string): string => { + idSequence += 1; + return `${prefix}_${idSequence.toString(36)}`; +}; + +const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, +}); + +const normalizeRect = ( + startX: number, + startY: number, + endX: number, + endY: number, +): PreviewAnnotationRect => ({ + x: Math.min(startX, endX), + y: Math.min(startY, endY), + width: Math.abs(endX - startX), + height: Math.abs(endY - startY), +}); + +const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; + +function unionRects( + rects: ReadonlyArray, + padding = 20, +): PreviewAnnotationRect | null { + if (rects.length === 0) return null; + const left = Math.min(...rects.map((rect) => rect.x)); + const top = Math.min(...rects.map((rect) => rect.y)); + const right = Math.max(...rects.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); + const x = Math.max(0, left - padding); + const y = Math.max(0, top - padding); + const maxWidth = Math.max(1, window.innerWidth - x); + const maxHeight = Math.max(1, window.innerHeight - y); + return { + x, + y, + width: Math.min(maxWidth, right - left + padding * 2), + height: Math.min(maxHeight, bottom - top + padding * 2), + }; +} + +function isAnnotationNode(element: Element): boolean { + return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; +} + +function pickFromPoint(clientX: number, clientY: number): Element | null { + for (const candidate of document.elementsFromPoint(clientX, clientY)) { + if (!(candidate instanceof Element)) continue; + if (isAnnotationNode(candidate)) continue; + if (candidate === document.documentElement || candidate === document.body) continue; + return candidate; + } + return null; +} + +function describeRawElement(element: Element): string { + const tag = element.tagName.toLowerCase(); + const id = element.id ? `#${element.id}` : ""; + const classes = + element instanceof HTMLElement && typeof element.className === "string" + ? element.className + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((name) => `.${name}`) + .join("") + : ""; + return `${tag}${id}${classes}`; +} + +function createBox(color: string, fill: string): HTMLDivElement { + const node = document.createElement("div"); + node.setAttribute(OVERLAY_ATTRIBUTE, ""); + node.style.cssText = [ + "position:fixed", + "pointer-events:none", + `border:2px solid ${color}`, + `background:${fill}`, + "border-radius:3px", + "box-sizing:border-box", + "display:none", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return node; +} + +function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { + node.style.display = "block"; + node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; + node.style.width = `${rect.width}px`; + node.style.height = `${rect.height}px`; +} + +function createLabel(): HTMLDivElement { + const label = document.createElement("div"); + label.setAttribute(OVERLAY_ATTRIBUTE, ""); + label.className = + "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md"; + label.style.cssText = [ + "position:fixed", + "pointer-events:none", + "white-space:nowrap", + "text-overflow:ellipsis", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return label; +} + +function updateSelectedVisual(target: SelectedElement): void { + if (!target.element.isConnected) { + target.outline.style.display = "none"; + target.label.style.display = "none"; + return; + } + const rect = target.element.getBoundingClientRect(); + positionBox(target.outline, rectFromDomRect(rect)); + target.label.textContent = describeRawElement(target.element); + target.label.style.display = "block"; + target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; +} + +function toStackFrame(frame: { + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; +}): PickedElementStackFrame { + return { + functionName: frame.functionName ?? null, + fileName: frame.fileName ?? null, + lineNumber: frame.lineNumber ?? null, + columnNumber: frame.columnNumber ?? null, + }; +} + +async function captureElement(element: Element): Promise { + try { + const context = await getElementContext(element); + const stack = (context.stack ?? []).map(toStackFrame); + return { + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + tagName: element.tagName.toLowerCase(), + selector: context.selector, + htmlPreview: context.htmlPreview ?? "", + componentName: context.componentName, + source: stack[0] ?? null, + stack, + styles: context.styles ?? "", + pickedAt: new Date().toISOString(), + }; + } catch { + return null; + } +} + +function createButton(label: string, title: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = title; + button.className = + "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60"; + return button; +} + +function styleControl(input: HTMLInputElement | HTMLSelectElement): void { + input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); + input.className = + "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none"; +} + +function createUnitControl(input: HTMLInputElement): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.style.cssText = "position:relative;min-width:0"; + const unit = document.createElement("span"); + unit.textContent = input.dataset.unit ?? ""; + unit.className = + "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; + wrapper.append(input, unit); + return wrapper; +} + +function createField( + labelText: string, + input: HTMLInputElement | HTMLSelectElement, +): HTMLLabelElement { + const label = document.createElement("label"); + label.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; + const text = document.createElement("span"); + text.textContent = labelText; + styleControl(input); + label.append( + text, + input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input, + ); + return label; +} + +function createStyleSection(): HTMLElement { + const section = document.createElement("section"); + section.className = "grid gap-1 border-t border-border py-2"; + return section; +} + +function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { + const input = document.createElement("input"); + input.type = "number"; + input.placeholder = placeholder; + input.style.paddingRight = "30px"; + input.dataset.unit = unit; + return input; +} + +function pathFromPoints(points: ReadonlyArray): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; + let path = `M ${points[0]!.x} ${points[0]!.y}`; + for (let index = 1; index < points.length - 1; index += 1) { + const current = points[index]!; + const next = points[index + 1]!; + path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; + } + const last = points[points.length - 1]!; + path += ` L ${last.x} ${last.y}`; + return path; +} + +function strokeBounds( + points: ReadonlyArray, + width: number, +): PreviewAnnotationRect { + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const padding = width + 3; + const left = Math.min(...xs) - padding; + const top = Math.min(...ys) - padding; + const right = Math.max(...xs) + padding; + const bottom = Math.max(...ys) + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +function startAnnotation(): void { + activeSession?.teardown(false); + let finished = false; + const host = document.createElement("div"); + host.setAttribute(OVERLAY_ATTRIBUTE, ""); + host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; + applyAnnotationTheme(host, annotationTheme); + const shadowRoot = host.attachShadow({ mode: "closed" }); + const themeStyle = document.createElement("style"); + themeStyle.textContent = previewAnnotationStyles; + shadowRoot.appendChild(themeStyle); + + const root = document.createElement("div"); + root.setAttribute(OVERLAY_ATTRIBUTE, ""); + root.className = "fixed inset-0 font-sans text-foreground"; + root.style.cssText = "pointer-events:none"; + const cursorStyle = document.createElement("style"); + cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); + cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; + document.documentElement.appendChild(cursorStyle); + shadowRoot.appendChild(root); + + const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); + const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); + root.append(hoverOutline, marqueeBox); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute(OVERLAY_ATTRIBUTE, ""); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); + svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; + svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); + root.appendChild(svg); + + const toolbar = document.createElement("div"); + toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); + toolbar.className = + "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; + toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); + root.appendChild(toolbar); + + const editor = document.createElement("div"); + editor.setAttribute(OVERLAY_ATTRIBUTE, ""); + editor.className = + "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; + editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); + root.appendChild(editor); + + const composerRow = document.createElement("div"); + composerRow.className = "flex items-start gap-2 p-2"; + + const adjust = createButton("", "Expand annotation editor"); + adjust.setAttribute("aria-label", "Expand annotation editor"); + adjust.setAttribute("aria-expanded", "false"); + adjust.className += + " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; + adjust.innerHTML = + ''; + composerRow.appendChild(adjust); + + const comment = document.createElement("textarea"); + comment.placeholder = "Describe the change…"; + comment.rows = 1; + comment.className = + "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; + composerRow.appendChild(comment); + + const dragHandle = document.createElement("button"); + dragHandle.type = "button"; + dragHandle.textContent = "⠿"; + dragHandle.title = "Drag annotation editor"; + dragHandle.className = + "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; + composerRow.appendChild(dragHandle); + + const submit = createButton("Attach", "Attach annotation and screenshot"); + submit.className += + " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; + composerRow.appendChild(submit); + editor.appendChild(composerRow); + + const stylePanel = document.createElement("div"); + stylePanel.className = + "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; + editor.appendChild(stylePanel); + + const selected = new Map(); + const regions: PreviewAnnotationRegionTarget[] = []; + const strokes: PreviewAnnotationStrokeTarget[] = []; + const styleChanges = new Map(); + const toolButtons = new Map(); + let tool: AnnotationTool = "select"; + let dragStart: PreviewAnnotationPoint | null = null; + let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; + let pendingCapture = false; + let editorExpanded = false; + let editorWasShown = false; + let editorPosition: { left: number; top: number } | null = null; + let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; + let editorLayoutFrame: number | null = null; + + const resizeComment = (): void => { + const maxHeight = 96; + comment.style.height = "auto"; + const nextHeight = Math.min(comment.scrollHeight, maxHeight); + comment.style.height = `${nextHeight}px`; + comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; + queueEditorLayout(); + }; + comment.addEventListener("input", resizeComment); + + const updateStatus = (): void => { + const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; + editor.style.display = hasTargets ? "flex" : "none"; + submit.disabled = !hasTargets; + submit.style.opacity = hasTargets ? "1" : "0.45"; + adjust.disabled = !hasTargets; + stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; + queueEditorLayout(); + if (hasTargets && !editorWasShown) { + editorWasShown = true; + window.setTimeout(() => comment.focus({ preventScroll: true }), 0); + } + }; + + const refreshToolButtons = (): void => { + for (const [candidate, button] of toolButtons) { + const active = candidate === tool; + button.classList.toggle("bg-primary/10", active); + button.classList.toggle("text-primary", active); + button.classList.toggle("text-foreground", !active); + } + if (tool !== "select") hoverOutline.style.display = "none"; + if (tool !== "marquee") marqueeBox.style.display = "none"; + document.documentElement.setAttribute("data-t3code-annotation-tool", tool); + }; + + const removeSelected = (target: SelectedElement): void => { + if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + selected.delete(target.element); + target.outline.remove(); + target.label.remove(); + for (const [key, change] of styleChanges) { + if (change.targetId === target.id) styleChanges.delete(key); + } + updateStatus(); + }; + + const addSelected = (element: Element): void => { + if (selected.has(element)) return; + const target: SelectedElement = { + id: nextId("element"), + element, + outline: createBox(PRIMARY, PRIMARY_FILL), + label: createLabel(), + baselineStyles: new Map(), + }; + selected.set(element, target); + root.append(target.outline, target.label); + updateSelectedVisual(target); + updateStatus(); + if (editorExpanded) { + stylePanel.style.display = "grid"; + syncStyleControls(); + } + }; + + const toggleSelected = (element: Element, additive: boolean): void => { + const existing = selected.get(element); + if (existing) { + removeSelected(existing); + return; + } + if (!additive) { + for (const target of Array.from(selected.values())) removeSelected(target); + } + addSelected(element); + }; + + const setStyleForSelected = (property: string, value: string): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + if (!target.baselineStyles.has(property)) { + target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); + } + const key = `${target.id}:${property}`; + const previousValue = + styleChanges.get(key)?.previousValue ?? + getComputedStyle(target.element).getPropertyValue(property).trim(); + target.element.style.setProperty(property, value, "important"); + styleChanges.set(key, { + targetId: target.id, + selector: null, + property, + previousValue, + value, + }); + updateSelectedVisual(target); + } + }; + + const textSection = createStyleSection(); + const colorsSection = createStyleSection(); + const bordersSection = createStyleSection(); + const sizingSection = createStyleSection(); + stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); + + const fontFamily = document.createElement("select"); + for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontFamily.appendChild(option); + } + fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); + textSection.appendChild(createField("Font", fontFamily)); + + const fontSize = createUnitInput("px", "16"); + fontSize.min = "1"; + fontSize.max = "300"; + fontSize.addEventListener("input", () => { + if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); + }); + textSection.appendChild(createField("Font size", fontSize)); + + const fontWeight = document.createElement("select"); + for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontWeight.appendChild(option); + } + fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); + textSection.appendChild(createField("Font weight", fontWeight)); + + const lineHeight = document.createElement("input"); + lineHeight.type = "text"; + lineHeight.placeholder = "normal / 1.4"; + lineHeight.addEventListener("change", () => { + if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); + }); + textSection.appendChild(createField("Line height", lineHeight)); + + const createColorRow = ( + labelText: string, + property: string, + section: HTMLElement, + ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { + const row = document.createElement("label"); + row.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; + const label = document.createElement("span"); + label.textContent = labelText; + const control = document.createElement("div"); + control.className = + "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; + const color = document.createElement("input"); + color.type = "color"; + color.setAttribute("aria-label", labelText); + color.style.cssText = + "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; + const text = document.createElement("input"); + text.type = "text"; + text.setAttribute("aria-label", `${labelText} value`); + text.className = + "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; + color.addEventListener("input", () => { + text.value = color.value; + setStyleForSelected(property, color.value); + }); + text.addEventListener("change", () => { + const value = text.value.trim(); + if (!value) return; + setStyleForSelected(property, value); + if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; + }); + control.append(color, text); + row.append(label, control); + section.appendChild(row); + return { row, color, text }; + }; + + const textColor = createColorRow("Text color", "color", colorsSection); + const backgroundColor = createColorRow("Background", "background-color", colorsSection); + + const opacity = document.createElement("input"); + opacity.type = "range"; + opacity.min = "0"; + opacity.max = "1"; + opacity.step = "0.05"; + opacity.value = "1"; + opacity.style.accentColor = PRIMARY; + opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); + colorsSection.appendChild(createField("Opacity", opacity)); + + const radius = createUnitInput("px", "0"); + radius.min = "0"; + radius.max = "300"; + radius.addEventListener("input", () => { + if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); + }); + bordersSection.appendChild(createField("Radius", radius)); + + const borderColor = createColorRow("Border color", "border-color", bordersSection); + + const borderWidth = createUnitInput("px", "0"); + borderWidth.min = "0"; + borderWidth.max = "100"; + borderWidth.addEventListener("input", () => { + if (borderWidth.value) { + setStyleForSelected("border-style", "solid"); + setStyleForSelected("border-width", `${borderWidth.value}px`); + } + }); + bordersSection.appendChild(createField("Border width", borderWidth)); + + const dimensions = document.createElement("div"); + dimensions.style.cssText = + "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; + const dimensionLabel = document.createElement("div"); + dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; + dimensionLabel.innerHTML = "WidthHeight"; + const dimensionControls = document.createElement("div"); + dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; + const widthInput = createUnitInput("px", "auto"); + const heightInput = createUnitInput("px", "auto"); + styleControl(widthInput); + styleControl(heightInput); + const aspectLock = createButton("", "Lock aspect ratio"); + aspectLock.setAttribute("aria-pressed", "true"); + aspectLock.style.cssText += + ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; + aspectLock.className += " bg-primary/10 text-primary"; + dimensionControls.append( + createUnitControl(widthInput), + createUnitControl(heightInput), + aspectLock, + ); + dimensions.append(dimensionLabel, dimensionControls); + sizingSection.appendChild(dimensions); + + let aspectLocked = true; + let aspectRatio = 1; + const refreshAspectButton = (): void => { + aspectLock.innerHTML = aspectLocked + ? '' + : ''; + aspectLock.setAttribute("aria-pressed", String(aspectLocked)); + aspectLock.classList.toggle("bg-primary/10", aspectLocked); + aspectLock.classList.toggle("text-primary", aspectLocked); + aspectLock.classList.toggle("bg-muted", !aspectLocked); + aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); + }; + aspectLock.addEventListener("click", () => { + aspectLocked = !aspectLocked; + refreshAspectButton(); + }); + widthInput.addEventListener("input", () => { + const width = Number(widthInput.value); + if (!Number.isFinite(width) || width <= 0) return; + setStyleForSelected("width", `${width}px`); + if (aspectLocked && aspectRatio > 0) { + const height = Math.max(1, Math.round(width / aspectRatio)); + heightInput.value = String(height); + setStyleForSelected("height", `${height}px`); + } + }); + heightInput.addEventListener("input", () => { + const height = Number(heightInput.value); + if (!Number.isFinite(height) || height <= 0) return; + setStyleForSelected("height", `${height}px`); + if (aspectLocked && aspectRatio > 0) { + const width = Math.max(1, Math.round(height * aspectRatio)); + widthInput.value = String(width); + setStyleForSelected("width", `${width}px`); + } + }); + refreshAspectButton(); + + const addSpacingField = ( + label: string, + property: string, + placeholder: string, + ): HTMLInputElement => { + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = placeholder; + input.addEventListener("change", () => { + if (input.value.trim()) setStyleForSelected(property, input.value.trim()); + }); + sizingSection.appendChild(createField(label, input)); + return input; + }; + const padding = addSpacingField("Padding", "padding", "0 0 0 0"); + const margin = addSpacingField("Margin", "margin", "0 0 0 0"); + const gap = addSpacingField("Gap", "gap", "0px"); + + const syncStyleControls = (): void => { + const first = selected.values().next().value as SelectedElement | undefined; + if (!first) return; + const computed = getComputedStyle(first.element); + const rect = first.element.getBoundingClientRect(); + aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; + widthInput.value = String(Math.round(rect.width)); + heightInput.value = String(Math.round(rect.height)); + fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); + fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; + lineHeight.value = computed.lineHeight; + fontFamily.value = Array.from(fontFamily.options).some( + (option) => option.value === computed.fontFamily, + ) + ? computed.fontFamily + : "inherit"; + textColor.text.value = computed.color; + backgroundColor.text.value = computed.backgroundColor; + borderColor.text.value = computed.borderColor; + opacity.value = computed.opacity; + radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); + borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); + padding.value = computed.padding; + margin.value = computed.margin; + gap.value = computed.gap === "normal" ? "0px" : computed.gap; + }; + + const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ + ["select", "Select", "Select elements (V)"], + ["marquee", "Region", "Draw a region or marquee-select elements (R)"], + ["draw", "Draw", "Draw freehand (D)"], + ["erase", "Erase", "Remove an annotation target (E)"], + ]; + for (const [candidate, label, title] of tools) { + const button = createButton(label, title); + button.className += " h-8 px-2.5 text-sm"; + button.addEventListener("click", () => { + tool = candidate; + refreshToolButtons(); + }); + toolButtons.set(candidate, button); + toolbar.appendChild(button); + } + + const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { + const margin = 8; + const rect = editor.getBoundingClientRect(); + return { + left: Math.min( + Math.max(margin, left), + Math.max(margin, window.innerWidth - rect.width - margin), + ), + top: Math.min( + Math.max(margin, top), + Math.max(margin, window.innerHeight - rect.height - margin), + ), + }; + }; + + const applyEditorPosition = (position: { left: number; top: number }): void => { + const clamped = clampEditorPosition(position.left, position.top); + editor.style.left = `${clamped.left}px`; + editor.style.top = `${clamped.top}px`; + editor.style.right = "auto"; + editor.style.bottom = "auto"; + if (editorExpanded) editorPosition = clamped; + }; + + const getAnnotationBounds = (): PreviewAnnotationRect | null => + unionRects( + [ + ...Array.from(selected.values(), (target) => + rectFromDomRect(target.element.getBoundingClientRect()), + ), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ], + 0, + ); + + const positionCompactEditor = (): void => { + const bounds = getAnnotationBounds(); + if (!bounds) return; + const editorRect = editor.getBoundingClientRect(); + const gap = 8; + const candidates = [ + { left: bounds.x + bounds.width + gap, top: bounds.y }, + { left: bounds.x - editorRect.width - gap, top: bounds.y }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y + bounds.height + gap, + }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y - editorRect.height - gap, + }, + ]; + const overflow = (position: { left: number; top: number }): number => + Math.max(0, -position.left) + + Math.max(0, -position.top) + + Math.max(0, position.left + editorRect.width - window.innerWidth) + + Math.max(0, position.top + editorRect.height - window.innerHeight); + const best = candidates.reduce((current, candidate) => + overflow(candidate) < overflow(current) ? candidate : current, + ); + applyEditorPosition(best); + }; + + function queueEditorLayout(): void { + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + editorLayoutFrame = window.requestAnimationFrame(() => { + editorLayoutFrame = null; + if (editor.style.display === "none") return; + if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); + else positionCompactEditor(); + }); + } + + adjust.addEventListener("click", () => { + if (selected.size === 0) return; + if (!editorExpanded) { + const rect = editor.getBoundingClientRect(); + editorExpanded = true; + editorPosition = { left: rect.left, top: rect.top }; + stylePanel.style.display = selected.size > 0 ? "grid" : "none"; + dragHandle.style.display = "block"; + adjust.setAttribute("aria-expanded", "true"); + adjust.title = "Collapse annotation editor"; + adjust.setAttribute("aria-label", "Collapse annotation editor"); + if (selected.size > 0) syncStyleControls(); + } else { + editorExpanded = false; + editorPosition = null; + stylePanel.style.display = "none"; + dragHandle.style.display = "none"; + adjust.setAttribute("aria-expanded", "false"); + adjust.title = "Expand annotation editor"; + adjust.setAttribute("aria-label", "Expand annotation editor"); + } + queueEditorLayout(); + }); + + const onEditorPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || !editorExpanded) return; + const rect = editor.getBoundingClientRect(); + editorDrag = { + pointerId: event.pointerId, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + dragHandle.setPointerCapture(event.pointerId); + dragHandle.style.cursor = "grabbing"; + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerMove = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + applyEditorPosition({ + left: event.clientX - editorDrag.offsetX, + top: event.clientY - editorDrag.offsetY, + }); + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerUp = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + editorDrag = null; + dragHandle.style.cursor = "grab"; + if (dragHandle.hasPointerCapture(event.pointerId)) + dragHandle.releasePointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + }; + dragHandle.addEventListener("pointerdown", onEditorPointerDown); + dragHandle.addEventListener("pointermove", onEditorPointerMove); + dragHandle.addEventListener("pointerup", onEditorPointerUp); + dragHandle.addEventListener("pointercancel", onEditorPointerUp); + + const repaint = (): void => { + for (const target of selected.values()) updateSelectedVisual(target); + queueEditorLayout(); + }; + + const removeTargetAtPoint = (x: number, y: number): boolean => { + for (const target of Array.from(selected.values()).toReversed()) { + const rect = target.element.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + removeSelected(target); + return true; + } + } + const regionIndex = regions.findIndex( + (region) => + x >= region.rect.x && + x <= region.rect.x + region.rect.width && + y >= region.rect.y && + y <= region.rect.y + region.rect.height, + ); + if (regionIndex >= 0) { + const [removed] = regions.splice(regionIndex, 1); + root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + const strokeIndex = strokes.findIndex( + (stroke) => + x >= stroke.bounds.x && + x <= stroke.bounds.x + stroke.bounds.width && + y >= stroke.bounds.y && + y <= stroke.bounds.y + stroke.bounds.height, + ); + if (strokeIndex >= 0) { + const [removed] = strokes.splice(strokeIndex, 1); + svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + return false; + }; + + const selectElementsInRect = (rect: PreviewAnnotationRect): number => { + const candidates = Array.from(document.querySelectorAll("body *")) + .filter((element) => !isAnnotationNode(element)) + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter(({ rect: candidate }) => { + if (candidate.width < 2 || candidate.height < 2) return false; + return !( + candidate.right < rect.x || + candidate.left > rect.x + rect.width || + candidate.bottom < rect.y || + candidate.top > rect.y + rect.height + ); + }) + .filter(({ element, rect: candidate }) => { + const centerX = candidate.left + candidate.width / 2; + const centerY = candidate.top + candidate.height / 2; + return ( + centerX >= rect.x && + centerX <= rect.x + rect.width && + centerY >= rect.y && + centerY <= rect.y + rect.height && + (element.children.length === 0 || + element instanceof HTMLButtonElement || + element instanceof HTMLAnchorElement || + element.getAttribute("role") === "button") + ); + }) + .sort( + (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, + ) + .slice(0, MAX_MARQUEE_ELEMENTS); + for (const candidate of candidates) addSelected(candidate.element); + return candidates.length; + }; + + const clearHoverOutline = (): void => { + hoverOutline.style.display = "none"; + }; + + const onPointerMove = (event: PointerEvent): void => { + if (isAnnotationNode(event.target as Element)) { + clearHoverOutline(); + return; + } + if (tool === "select" && dragStart === null) { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); + else clearHoverOutline(); + return; + } + clearHoverOutline(); + if (tool === "marquee" && dragStart) { + positionBox( + marqueeBox, + normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), + ); + return; + } + if (tool === "draw" && activeStroke) { + activeStroke.target.points = [ + ...activeStroke.target.points, + { x: event.clientX, y: event.clientY }, + ]; + activeStroke.target.bounds = strokeBounds( + activeStroke.target.points, + activeStroke.target.width, + ); + activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); + } + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "select") { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) toggleSelected(target, event.shiftKey); + return; + } + if (tool === "erase") { + removeTargetAtPoint(event.clientX, event.clientY); + return; + } + dragStart = { x: event.clientX, y: event.clientY }; + if (tool === "draw") { + const stroke: PreviewAnnotationStrokeTarget = { + id: nextId("stroke"), + color: annotationTheme?.primary ?? "#2563eb", + width: 4, + points: [dragStart], + bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, + }; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute(OVERLAY_ATTRIBUTE, ""); + path.setAttribute("data-stroke-id", stroke.id); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", stroke.color); + path.setAttribute("stroke-width", String(stroke.width)); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + activeStroke = { target: stroke, path }; + } + }; + + const onPointerUp = (event: PointerEvent): void => { + if (!dragStart) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "marquee") { + const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); + marqueeBox.style.display = "none"; + if (isUsableRect(rect)) { + const found = selectElementsInRect(rect); + if (found === 0) { + const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; + regions.push(region); + const regionBox = createBox( + PRIMARY, + "color-mix(in srgb, var(--t3-primary) 6%, transparent)", + ); + regionBox.setAttribute("data-region-id", region.id); + positionBox(regionBox, rect); + root.appendChild(regionBox); + } + } + } else if (tool === "draw" && activeStroke) { + if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); + else activeStroke.path.remove(); + activeStroke = null; + } + dragStart = null; + updateStatus(); + }; + + const onClick = (event: MouseEvent): void => { + if (isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + }; + + const onPointerOut = (event: PointerEvent): void => { + if (event.relatedTarget === null) clearHoverOutline(); + }; + + const onWindowBlur = (): void => { + clearHoverOutline(); + }; + + const restoreStyles = (): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + }; + + const teardown = (notifyMain: boolean): void => { + if (finished) return; + finished = true; + restoreStyles(); + window.removeEventListener("pointermove", onPointerMove, true); + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("pointerup", onPointerUp, true); + window.removeEventListener("pointerout", onPointerOut, true); + window.removeEventListener("click", onClick, true); + window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("scroll", repaint, true); + window.removeEventListener("resize", repaint); + dragHandle.removeEventListener("pointerdown", onEditorPointerDown); + dragHandle.removeEventListener("pointermove", onEditorPointerMove); + dragHandle.removeEventListener("pointerup", onEditorPointerUp); + dragHandle.removeEventListener("pointercancel", onEditorPointerUp); + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.removeAttribute("data-t3code-annotation-tool"); + cursorStyle.remove(); + host.remove(); + activeSession = null; + if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); + }; + + const onCancel = (): void => teardown(false); + const onCaptured = (): void => teardown(false); + const onKeyDown = (event: KeyboardEvent): void => { + if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + teardown(true); + return; + } + if (event.key === "v") tool = "select"; + else if (event.key === "r") tool = "marquee"; + else if (event.key === "d") tool = "draw"; + else if (event.key === "e") tool = "erase"; + else return; + refreshToolButtons(); + }; + + submit.addEventListener("click", () => { + if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) + return; + pendingCapture = true; + submit.disabled = true; + submit.textContent = "Capturing…"; + void Promise.all( + Array.from(selected.values()).map(async (target) => { + const element = await captureElement(target.element); + if (!element) return null; + for (const change of styleChanges.values()) { + if (change.targetId === target.id) change.selector = element.selector; + } + return { + id: target.id, + element, + rect: rectFromDomRect(target.element.getBoundingClientRect()), + }; + }), + ).then((captured) => { + const elements = captured.filter((target) => target !== null); + const annotation: PreviewAnnotationPayload = { + id: nextId("annotation"), + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + comment: comment.value.trim(), + elements, + regions: [...regions], + strokes: [...strokes], + styleChanges: Array.from(styleChanges.values()), + screenshot: null, + createdAt: new Date().toISOString(), + }; + editor.style.display = "none"; + toolbar.style.display = "none"; + hoverOutline.style.display = "none"; + const screenshotRect = unionRects([ + ...elements.map((target) => target.rect), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ]); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); + }); + }); + comment.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + submit.click(); + }); + + window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); + window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); + window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); + window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); + window.addEventListener("click", onClick, { capture: true, passive: false }); + window.addEventListener("blur", onWindowBlur); + window.addEventListener("keydown", onKeyDown, { capture: true }); + window.addEventListener("scroll", repaint, { capture: true, passive: true }); + window.addEventListener("resize", repaint, { passive: true }); + ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.appendChild(host); + refreshToolButtons(); + updateStatus(); + activeSession = { + teardown, + applyTheme: (theme) => applyAnnotationTheme(host, theme), + }; +} + +ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { + if (theme) annotationTheme = theme; + startAnnotation(); +}); +ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { + annotationTheme = theme; + activeSession?.applyTheme(theme); +}); +ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview/PickedElementPayload.test.ts b/apps/desktop/src/preview/PickedElementPayload.test.ts new file mode 100644 index 00000000000..d7a96732477 --- /dev/null +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPickedElementPayload, isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; + +function validPayload(overrides?: Record): Record { + return { + pageUrl: "https://example.com/", + pageTitle: "Example", + tagName: "button", + selector: "button.submit", + htmlPreview: "", + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + stack: [ + { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + ], + styles: ".submit { color: white; }", + pickedAt: "2026-05-03T18:00:00.000Z", + ...overrides, + }; +} + +describe("isPickedElementPayload", () => { + it("accepts a complete, well-typed payload", () => { + expect(isPickedElementPayload(validPayload())).toBe(true); + }); + + it("accepts nullable string fields when null", () => { + expect( + isPickedElementPayload( + validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), + ), + ).toBe(true); + }); + + it("accepts an empty stack array", () => { + expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); + }); + + it("accepts stack frames with null fields", () => { + expect( + isPickedElementPayload( + validPayload({ + stack: [ + { + functionName: null, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + ], + }), + ), + ).toBe(true); + }); + + it("rejects null and primitive inputs", () => { + expect(isPickedElementPayload(null)).toBe(false); + expect(isPickedElementPayload(undefined)).toBe(false); + expect(isPickedElementPayload("string")).toBe(false); + expect(isPickedElementPayload(42)).toBe(false); + expect(isPickedElementPayload([])).toBe(false); + }); + + it.each<[string, Record]>([ + ["missing pageUrl", validPayload({ pageUrl: undefined })], + ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], + ["missing tagName", validPayload({ tagName: undefined })], + ["missing htmlPreview", validPayload({ htmlPreview: undefined })], + ["missing styles", validPayload({ styles: undefined })], + ["missing pickedAt", validPayload({ pickedAt: undefined })], + ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], + ["wrong-type selector", validPayload({ selector: 99 })], + ["wrong-type componentName", validPayload({ componentName: 99 })], + ])("rejects payloads with %s", (_label, value) => { + expect(isPickedElementPayload(value)).toBe(false); + }); + + it("rejects malformed source frames", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: 0, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects non-finite numeric line/column numbers", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.POSITIVE_INFINITY, + columnNumber: null, + }, + }), + ), + ).toBe(false); + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.NaN, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects malformed stack arrays", () => { + expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); + expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); + }); +}); + +function validAnnotation(overrides?: Record): Record { + return { + id: "annotation_1", + pageUrl: "https://example.com/", + pageTitle: "Example", + comment: "Make this clearer", + elements: [ + { + id: "element_1", + element: validPayload(), + rect: { x: 10, y: 20, width: 100, height: 40 }, + }, + ], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [ + { + id: "stroke_1", + color: "#7c3aed", + width: 4, + points: [ + { x: 10, y: 10 }, + { x: 20, y: 20 }, + ], + bounds: { x: 6, y: 6, width: 18, height: 18 }, + }, + ], + styleChanges: [ + { + targetId: "element_1", + selector: "button.submit", + property: "opacity", + previousValue: "1", + value: "0.5", + }, + ], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + ...overrides, + }; +} + +describe("isPreviewAnnotationPayload", () => { + it("accepts a structured annotation draft before screenshot capture", () => { + expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); + }); + + it("rejects screenshots supplied by the guest preload", () => { + expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( + false, + ); + }); + + it("rejects malformed geometry and nested element payloads", () => { + expect( + isPreviewAnnotationPayload( + validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), + ), + ).toBe(false); + expect( + isPreviewAnnotationPayload( + validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/PickedElementPayload.ts b/apps/desktop/src/preview/PickedElementPayload.ts new file mode 100644 index 00000000000..e2d596120db --- /dev/null +++ b/apps/desktop/src/preview/PickedElementPayload.ts @@ -0,0 +1,146 @@ +/** + * Strict structural validator for `PickedElementPayload` messages received + * from the in-page picker preload (`apps/desktop/src/preview/PickPreload.ts`) + * via `wc.ipc`. Lives in its own electron-free module so the validator is + * trivially unit-testable. + * + * Validation must be tight: downstream `normalizeElementContextSelection` + * calls `.trim()` on incoming strings, so a malformed payload (preload bug, + * future schema mismatch, malicious page that intercepts the preload's IPC + * channel via prototype pollution) would otherwise throw deep in the + * renderer and the chip silently never appears. + */ +import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isFiniteNumberOrNull(value: unknown): value is number | null { + return value === null || (typeof value === "number" && Number.isFinite(value)); +} + +function isPickedStackFrame(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const frame = value as Record; + return ( + isStringOrNull(frame["functionName"]) && + isStringOrNull(frame["fileName"]) && + isFiniteNumberOrNull(frame["lineNumber"]) && + isFiniteNumberOrNull(frame["columnNumber"]) + ); +} + +export function isPickedElementPayload(value: unknown): value is PickedElementPayload { + if (typeof value !== "object" || value === null) return false; + const c = value as Record; + if (typeof c["pageUrl"] !== "string") return false; + if (typeof c["tagName"] !== "string") return false; + if (typeof c["htmlPreview"] !== "string") return false; + if (typeof c["styles"] !== "string") return false; + if (typeof c["pickedAt"] !== "string") return false; + if (!isStringOrNull(c["pageTitle"])) return false; + if (!isStringOrNull(c["selector"])) return false; + if (!isStringOrNull(c["componentName"])) return false; + if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false; + if (!Array.isArray(c["stack"])) return false; + if (!c["stack"].every(isPickedStackFrame)) return false; + return true; +} + +function isRect(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const rect = value as Record; + return ["x", "y", "width", "height"].every( + (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]), + ); +} + +function isPoint(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const point = value as Record; + return ( + typeof point["x"] === "number" && + Number.isFinite(point["x"]) && + typeof point["y"] === "number" && + Number.isFinite(point["y"]) + ); +} + +export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload { + if (typeof value !== "object" || value === null) return false; + const annotation = value as Record; + if (typeof annotation["id"] !== "string") return false; + if (typeof annotation["pageUrl"] !== "string") return false; + if (!isStringOrNull(annotation["pageTitle"])) return false; + if (typeof annotation["comment"] !== "string") return false; + if (typeof annotation["createdAt"] !== "string") return false; + if (annotation["screenshot"] !== null) return false; + + const elements = annotation["elements"]; + if (!Array.isArray(elements)) return false; + if ( + !elements.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return ( + typeof target["id"] === "string" && + isPickedElementPayload(target["element"]) && + isRect(target["rect"]) + ); + }) + ) { + return false; + } + + const regions = annotation["regions"]; + if (!Array.isArray(regions)) return false; + if ( + !regions.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return typeof target["id"] === "string" && isRect(target["rect"]); + }) + ) { + return false; + } + + const strokes = annotation["strokes"]; + if (!Array.isArray(strokes)) return false; + if ( + !strokes.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return ( + typeof target["id"] === "string" && + typeof target["color"] === "string" && + typeof target["width"] === "number" && + Number.isFinite(target["width"]) && + Array.isArray(target["points"]) && + target["points"].every(isPoint) && + isRect(target["bounds"]) + ); + }) + ) { + return false; + } + + const styleChanges = annotation["styleChanges"]; + if (!Array.isArray(styleChanges)) return false; + if ( + !styleChanges.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const change = entry as Record; + return ( + typeof change["targetId"] === "string" && + isStringOrNull(change["selector"]) && + typeof change["property"] === "string" && + typeof change["previousValue"] === "string" && + typeof change["value"] === "string" + ); + }) + ) { + return false; + } + return true; +} diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts new file mode 100644 index 00000000000..33915dba0be --- /dev/null +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -0,0 +1,26 @@ +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { describe, expect } from "vite-plus/test"; + +import { + playwrightInjectedRuntimeInstallExpression, + playwrightInjectedRuntimeSource, +} from "./PlaywrightInjectedRuntime.ts"; + +describe("playwright injected runtime", () => { + effectIt.effect("extracts the pinned runtime from playwright-core", () => + Effect.gen(function* () { + const source = yield* playwrightInjectedRuntimeSource(); + expect(source.length).toBeGreaterThan(100_000); + expect(source).toContain("InjectedScript"); + }), + ); + + effectIt.effect("builds an idempotent install expression", () => + Effect.gen(function* () { + const expression = yield* playwrightInjectedRuntimeInstallExpression(); + expect(expression).toContain("__t3PlaywrightInjected"); + expect(expression).toContain('testIdAttributeName":"data-testid'); + }), + ); +}); diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts new file mode 100644 index 00000000000..1a4dce14f87 --- /dev/null +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -0,0 +1,90 @@ +// @effect-diagnostics nodeBuiltinImport:off - Extracts Playwright's installed Node bundle for browser injection. +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { runInNewContext } from "node:vm"; + +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const require = createRequire(import.meta.url); +const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); + +export class PlaywrightInjectedRuntimeError extends Data.TaggedError( + "PlaywrightInjectedRuntimeError", +)<{ + readonly operation: string; + readonly cause: unknown; +}> { + override get message() { + return `Playwright injected runtime operation failed: ${this.operation}`; + } +} + +const fail = (operation: string, cause: unknown) => + new PlaywrightInjectedRuntimeError({ operation, cause }); + +export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")( + function* () { + const packageJsonPath = yield* Effect.try({ + try: () => require.resolve("playwright-core/package.json"), + catch: (cause) => fail("resolvePackage", cause), + }); + const coreBundle = yield* Effect.tryPromise({ + try: () => readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"), + catch: (cause) => fail("readCoreBundle", cause), + }); + const marker = "source3 = "; + const start = coreBundle.indexOf(marker); + if (start < 0) { + return yield* fail( + "findSourceMarker", + new Error("Playwright injected runtime marker was not found."), + ); + } + const literalStart = start + marker.length; + const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); + if (literalEnd < 0) { + return yield* fail( + "findSourceTerminator", + new Error("Playwright injected runtime terminator was not found."), + ); + } + const literal = coreBundle.slice(literalStart, literalEnd); + const source = yield* Effect.try({ + try: () => runInNewContext(literal, Object.create(null), { timeout: 1_000 }), + catch: (cause) => fail("evaluateSourceLiteral", cause), + }); + if (typeof source !== "string" || source.length < 100_000) { + return yield* fail( + "validateSource", + new Error("Playwright injected runtime extraction returned invalid source."), + ); + } + return source; + }, +); + +export const playwrightInjectedRuntimeInstallExpression = Effect.fn( + "PlaywrightInjectedRuntime.installExpression", +)(function* () { + const source = yield* playwrightInjectedRuntimeSource(); + const options = yield* encodeUnknownJson({ + isUnderTest: false, + sdkLanguage: "javascript", + testIdAttributeName: "data-testid", + stableRafCount: 1, + browserName: "chromium", + shouldPrependErrorPrefix: false, + isUtilityWorld: false, + customEngines: [], + }).pipe(Effect.mapError((cause) => fail("encodeOptions", cause))); + return `(() => { + if (globalThis.__t3PlaywrightInjected) return true; + const module = { exports: {} }; + ${source} + globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${options}); + return true; + })()`; +}); diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts new file mode 100644 index 00000000000..498c1df4665 --- /dev/null +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts"; + +/** + * Mirrors Electron's webview attribute parser closely enough to catch the + * regressions we've already hit: + * + * - whitespace inside the comma-separated list silently drops keys (so + * `" sandbox=true"` becomes an unknown key and Electron falls back to + * defaults — re-opening the Node-leak window we closed), + * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy + * strings and assigned to a boolean preference, which silently flips + * `contextIsolation=no` to ENABLED (then react-grab can't see the React + * DevTools hook and componentName resolution always returns null). + * + * The actual Electron parser does roughly: + * + * for (const pair of webpreferences.split(',')) { + * const [key, value] = pair.split('='); + * prefs[key] = value; // value left as a string + * } + * + * then later coerces booleans via `Boolean(value)`. Replicating that here + * keeps the test independent of Electron internals while still failing if + * we accidentally ship `"contextIsolation=no"` again. + */ +function parseWebPreferences(input: string): Record { + const out: Record = {}; + for (const pair of input.split(",")) { + if (pair !== pair.trim()) { + // Electron's parser doesn't trim; surface the bug as undefined-key. + out[pair] = pair.split("=")[1]; + continue; + } + const [key, value] = pair.split("="); + if (!key) continue; + out[key] = value; + } + return out; +} + +describe("PREVIEW_WEBVIEW_PREFERENCES", () => { + const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); + + it("contains exactly the three security-critical keys", () => { + expect(Object.keys(parsed).toSorted()).toEqual( + ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), + ); + }); + + it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { + // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so + // `contextIsolation="no"` would silently leave isolation ENABLED. Lock + // the values to `"true"` / `"false"` so the parser does the right thing. + for (const value of Object.values(parsed)) { + expect(value).toMatch(/^(true|false)$/); + } + }); + + it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { + expect(parsed["contextIsolation"]).toBe("false"); + }); + + it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { + expect(parsed["sandbox"]).toBe("true"); + }); + + it("disables nodeIntegration (defense in depth — page never gets Node)", () => { + expect(parsed["nodeIntegration"]).toBe("false"); + }); + + it("contains no whitespace (Electron's parser does not trim)", () => { + // Electron splits on `,` without trimming, so any whitespace would turn + // a key into an unknown one and silently drop the security flag. + expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); + }); +}); diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts new file mode 100644 index 00000000000..085c75232b3 --- /dev/null +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -0,0 +1,42 @@ +/** + * webPreferences override applied to every preview `` element via + * its `webpreferences="..."` attribute. Single source of truth so all guest + * surfaces inherit the same security posture. + * + * Lives in its own electron-free module so the value is unit-testable + * without importing `Manager.ts` (which transitively imports + * `electron` and blows up under vitest). + * + * - `contextIsolation=false`: the picker preload needs to share `globalThis` + * with the page so react-grab/bippy can read the React DevTools hook + * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without + * this every pick comes back with `componentName: null` even on dev React + * apps. + * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical + * when paired with `contextIsolation=false` — without sandbox, the preload + * has full Node access (`require`, `fs`, `child_process`, ...) and that + * `require` would land on the page's shared `globalThis`, giving any + * third-party page in the preview full Node + IPC access to the host. + * In sandboxed mode Electron still synthesizes the `electron` module for + * the preload's `import { ipcRenderer }` line, but no Node globals leak. + * - `nodeIntegration=false`: pinned for clarity (the page itself never gets + * Node access). + * + * Format notes (locked down by `WebviewPreferences.test.ts`): + * - Whitespace-free. Electron's webpreferences parser splits on `,` and + * does not trim, so a leading space would turn a key into an unknown one + * and silently drop it. + * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not + * special-cased by the parser; `value="no"` becomes the truthy STRING + * `"no"` when assigned to a boolean webPreferences key. Most critically, + * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED → + * react-grab can't see the React DevTools hook. + * + * Defense in depth: `apps/desktop/src/main.ts` also runs a + * `will-attach-webview` handler that force-sets `sandbox: true` and + * `nodeIntegration*: false` on the actual webPreferences object, gated on + * the preview partition, so even if this string is ever wrong, the + * security-critical flags can't regress on preview tabs. + */ +export const PREVIEW_WEBVIEW_PREFERENCES = + "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5e977de2dea..e22db07c0cd 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -8,6 +8,17 @@ import * as Ref from "effect/Ref"; import type * as Electron from "electron"; import { vi } from "vite-plus/test"; +vi.mock("electron", async (importOriginal) => ({ + ...(await importOriginal()), + session: { + fromPartition: vi.fn(() => ({ + getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"), + setPermissionRequestHandler: vi.fn(), + setUserAgent: vi.fn(), + })), + }, +})); + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -18,6 +29,7 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; +import * as PreviewManager from "../preview/Manager.ts"; const environmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -56,6 +68,7 @@ function makeFakeBrowserWindow() { once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), + setAutoHideCursor: vi.fn(), setTitle: vi.fn(), setTitleBarOverlay: vi.fn(), show: vi.fn(), @@ -66,6 +79,7 @@ function makeFakeBrowserWindow() { window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, openDevTools: webContents.openDevTools, + setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, }; } @@ -122,10 +136,17 @@ function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; + readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly openedExternalUrls?: unknown[]; }) { const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { - create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), + create: (options) => + Effect.sync(() => { + input.createdWindowOptions?.push(options); + }).pipe( + Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), + Effect.as(input.window), + ), main: Ref.get(input.mainWindow), currentMainOrFirst: Ref.get(input.mainWindow), focusedMainOrFirst: Ref.get(input.mainWindow), @@ -155,6 +176,12 @@ function makeTestLayer(input: { } satisfies ElectronShell.ElectronShellShape), electronThemeLayer, electronWindowLayer, + Layer.mock(PreviewManager.PreviewManager)({ + getBrowserSession: () => Effect.succeed({} as Electron.Session), + setMainWindow: () => Effect.void, + isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), + }), ), ), ); @@ -187,10 +214,12 @@ describe("DesktopWindow", () => { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, + createdWindowOptions, }); yield* Effect.gen(function* () { @@ -200,6 +229,8 @@ describe("DesktopWindow", () => { yield* desktopWindow.handleBackendReady; assert.equal(yield* Ref.get(createCount), 1); + assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); + assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); }).pipe(Effect.provide(layer)); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 35145cc1d53..642abd535ae 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -11,6 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as PreviewManager from "../preview/Manager.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -36,7 +37,8 @@ type DesktopWindowRuntimeServices = | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme - | ElectronWindow.ElectronWindow; + | ElectronWindow.ElectronWindow + | PreviewManager.PreviewManager; export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( "DesktopWindowDevServerUrlMissingError", @@ -48,7 +50,8 @@ export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( export type DesktopWindowError = | DesktopWindowDevServerUrlMissingError - | ElectronWindow.ElectronWindowCreateError; + | ElectronWindow.ElectronWindowCreateError + | PreviewManager.PreviewManagerError; export interface DesktopWindowShape { readonly createMain: Effect.Effect; @@ -79,9 +82,10 @@ function resolveDesktopDevServerUrl( function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, + platform: NodeJS.Platform, ): { icon: string } | Record { - if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle - const ext = process.platform === "win32" ? "ico" : "png"; + if (platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = platform === "win32" ? "ico" : "png"; return Option.match(iconPaths[ext], { onNone: () => ({}), onSome: (icon) => ({ icon }), @@ -103,8 +107,11 @@ export function isSameOriginRendererNavigation(input: { } } -function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { - if (process.platform === "darwin") { +function getWindowTitleBarOptions( + shouldUseDarkColors: boolean, + platform: NodeJS.Platform, +): WindowTitleBarOptions { + if (platform === "darwin") { return { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 18 }, @@ -124,6 +131,7 @@ function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarO function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, + platform: NodeJS.Platform, ): Effect.Effect { return Effect.sync(() => { if (window.isDestroyed()) { @@ -131,7 +139,7 @@ function syncWindowAppearance( } window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); - const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); } @@ -162,6 +170,7 @@ const make = Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; + const previewManager = yield* PreviewManager.PreviewManager; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const state = yield* DesktopState.DesktopState; const context = yield* Effect.context(); @@ -170,11 +179,12 @@ const make = Effect.gen(function* () { const createWindow = Effect.fn("desktop.window.createWindow")(function* ( backendHttpUrl: URL, ): Effect.fn.Return { + yield* previewManager.getBrowserSession(); const applicationUrl = environment.isDevelopment ? yield* resolveDesktopDevServerUrl(environment) : backendHttpUrl.href; const iconPaths = yield* assets.iconPaths; - const iconOption = getIconOption(iconPaths); + const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; const window = yield* electronWindow.create({ width: 1100, @@ -183,18 +193,39 @@ const make = Effect.gen(function* () { minHeight: 620, show: false, autoHideMenuBar: true, + ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), ...iconOption, title: environment.displayName, - ...getWindowTitleBarOptions(shouldUseDarkColors), + ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, contextIsolation: true, nodeIntegration: false, sandbox: true, + webviewTag: true, }, }); + if (environment.platform === "darwin") { + window.setAutoHideCursor(false); + } + + yield* previewManager.setMainWindow(window); + window.webContents.on("will-attach-webview", (event, webPreferences, params) => { + if ( + typeof params.partition !== "string" || + !previewManager.isBrowserPartition(params.partition) + ) { + event.preventDefault(); + return; + } + webPreferences.sandbox = true; + webPreferences.nodeIntegration = false; + webPreferences.nodeIntegrationInSubFrames = false; + webPreferences.contextIsolation = false; + }); + window.webContents.on("context-menu", (event, params) => { event.preventDefault(); @@ -297,7 +328,7 @@ const make = Effect.gen(function* () { }); const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; - if (process.platform === "linux") { + if (environment.platform === "linux") { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } bindFirstRevealTrigger(revealSubscribers, () => { @@ -387,7 +418,7 @@ const make = Effect.gen(function* () { syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors), + syncWindowAppearance(window, shouldUseDarkColors, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index d42d2230946..dceefc14e9e 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -14,17 +14,18 @@ export default defineConfig({ run: { tasks: { build: { - command: "vp pack", + command: "node scripts/build-preview-annotation-css.mjs && vp pack", dependsOn: ["t3#build"], cache: false, }, dev: { - command: "cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", + command: + "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", dependsOn: ["t3#build"], cache: false, }, "dev:bundle": { - command: "vp pack --watch", + command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch", cache: false, }, "dev:electron": { @@ -56,5 +57,15 @@ export default defineConfig({ define: publicConfigDefine, entry: ["src/preload.ts"], }, + { + format: "cjs", + outDir: "dist-electron", + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/preview-pick-preload.ts"], + deps: { + alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), + }, + }, ], }); diff --git a/apps/mobile/src/features/observability/mobileTracing.test.ts b/apps/mobile/src/features/observability/mobileTracing.test.ts index 53bf4160477..0b0f83c6971 100644 --- a/apps/mobile/src/features/observability/mobileTracing.test.ts +++ b/apps/mobile/src/features/observability/mobileTracing.test.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; import { remoteHttpClientLayer } from "@t3tools/client-runtime"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { makeMobileTracingLayer } from "./mobileTracing"; @@ -29,7 +30,7 @@ it.effect("exports spans through the scoped mobile OTLP layer", () => { }, ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); const tracedApplication = Layer.effectDiscard( - Effect.void.pipe(Effect.withSpan("mobile.test.span")), + Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), ).pipe(Layer.provide(tracingLayer)); return Effect.gen(function* () { diff --git a/apps/mobile/src/features/observability/mobileTracing.ts b/apps/mobile/src/features/observability/mobileTracing.ts index 32f3d9f94c3..dfc6f875c1b 100644 --- a/apps/mobile/src/features/observability/mobileTracing.ts +++ b/apps/mobile/src/features/observability/mobileTracing.ts @@ -1,7 +1,5 @@ import Constants from "expo-constants"; -import * as Layer from "effect/Layer"; -import type { HttpClient } from "effect/unstable/http"; -import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; @@ -28,27 +26,13 @@ export function resolveMobileTracingConfig(): MobileTracingConfig | null { export function makeMobileTracingLayer( config: MobileTracingConfig | null, resource: MobileTracingResource, -): Layer.Layer { - if (config === null) { - return Layer.empty; - } - - return OtlpTracer.layer({ - url: config.tracesUrl, - headers: { - Authorization: `Bearer ${config.tracesToken}`, - "X-Axiom-Dataset": config.tracesDataset, - }, - resource: { - serviceName: "t3-mobile", - serviceVersion: resource.serviceVersion, - attributes: { - "service.runtime": "react-native", - "service.component": "mobile", - "deployment.environment.name": resource.appVariant, - }, - }, - }).pipe(Layer.provide(OtlpSerialization.layerJson)); +) { + return makeRelayClientTracingLayer(config, { + serviceName: "t3-mobile-relay-client", + serviceVersion: resource.serviceVersion, + runtime: "react-native", + client: `mobile-${resource.appVariant}`, + }); } export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), { diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index b9f94ee82d3..404dbbd016b 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -73,7 +73,7 @@ import { type TestProviderAdapterHarness, } from "./TestProviderAdapter.integration.ts"; import { deriveServerPaths, ServerConfig } from "../src/config.ts"; -import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; @@ -356,7 +356,7 @@ export const makeOrchestrationIntegrationHarness = ( }), ), Layer.provideMerge( - WorkspaceEntriesLive.pipe( + WorkspaceEntries.layer.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistry.layer), Layer.provide(NodeServices.layer), diff --git a/apps/server/package.json b/apps/server/package.json index 6114e4fd4c3..866962cf7f2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@ff-labs/fff-node": "^0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index aced9266733..a158eaa068d 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -18,6 +18,7 @@ import { import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import serverPackageJson from "../package.json" with { type: "json" }; interface PackageJson { @@ -175,6 +176,7 @@ const buildCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", + shell: false, }), ); @@ -290,15 +292,15 @@ const publishCmd = Command.make( () => Effect.gen(function* () { const args = createVpPmPublishArgs(config); + const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); yield* runCommand( - ChildProcess.make("vp", ["pm", ...args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: repoRoot, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve .cmd shims. - shell: process.platform === "win32", + shell: spawnCommand.shell, }), ); }), diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts index 04c2321870e..31f2ef6f1f7 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -3,6 +3,8 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import process from "node:process"; import readline from "node:readline"; import * as NodeTimers from "node:timers"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; type JsonPrimitive = null | boolean | number | string; type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -128,9 +130,10 @@ class JsonRpcChild { closed = false; constructor(bin: string, args: string[], cwd: string) { - this.child = spawn(bin, args, { + const spawnCommand = Effect.runSync(resolveSpawnCommand(bin, args)); + this.child = spawn(spawnCommand.command, spawnCommand.args, { cwd, - shell: process.platform === "win32", + shell: spawnCommand.shell, stdio: ["pipe", "pipe", "pipe"], env: process.env, }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts new file mode 100644 index 00000000000..6abd8f48e61 --- /dev/null +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -0,0 +1,150 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { ServerConfig } from "../config.ts"; +import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts"; +import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; +import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; + +const configLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-asset-access-test-", +}); +const testLayer = Layer.mergeAll( + configLayer, + WorkspacePathsLive, + ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), + ServerSecretStore.layer.pipe(Layer.provide(configLayer)), +).pipe(Layer.provideMerge(NodeServices.layer)); + +describe("AssetAccess", () => { + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-workspace-", + }); + const htmlPath = path.join(root, "report.html"); + const cssPath = path.join(root, "report.css"); + yield* fileSystem.writeFileString(htmlPath, ''); + yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); + yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value"); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "report.html")).toEqual({ + kind: "file", + path: htmlPath, + }); + expect(yield* resolveAsset(token, "report.css")).toEqual({ + kind: "file", + path: cssPath, + }); + expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); + expect(yield* resolveAsset(token, ".env")).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects workspace files outside the authorized root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-outside-", + }); + const htmlPath = path.join(outside, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "

outside

"); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(error.message).toContain("relative to the project root"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues exact attachment capabilities by attachment id", () => + Effect.gen(function* () { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + + const result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "ignored.png")).toEqual({ + kind: "file", + path: attachmentPath, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues project favicon capabilities with a signed fallback", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-", + }); + const faviconPath = path.join(root, "favicon.svg"); + yield* fileSystem.writeFileString(faviconPath, ""); + + const faviconResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const faviconSeparatorIndex = faviconSuffix.indexOf("/"); + expect( + yield* resolveAsset( + faviconSuffix.slice(0, faviconSeparatorIndex), + faviconSuffix.slice(faviconSeparatorIndex + 1), + ), + ).toEqual({ kind: "file", path: faviconPath }); + + yield* fileSystem.remove(faviconPath); + const fallbackResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); + expect( + yield* resolveAsset( + fallbackSuffix.slice(0, fallbackSeparatorIndex), + fallbackSuffix.slice(fallbackSeparatorIndex + 1), + ), + ).toEqual({ kind: "project-favicon-fallback" }); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts new file mode 100644 index 00000000000..659413f4748 --- /dev/null +++ b/apps/server/src/assets/AssetAccess.ts @@ -0,0 +1,287 @@ +import type { AssetResource } from "@t3tools/contracts"; +import { AssetAccessError } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; + +export const ASSET_ROUTE_PREFIX = "/api/assets"; +export const FALLBACK_PROJECT_FAVICON_SVG = ``; + +const SIGNING_SECRET_NAME = "asset-access-signing-key"; +const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; +const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]); +const PREVIEW_ASSET_EXTENSIONS = new Set([ + ...PREVIEWABLE_EXTENSIONS, + ".avif", + ".css", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".js", + ".mjs", + ".otf", + ".png", + ".svg", + ".ttf", + ".webp", + ".woff", + ".woff2", +]); + +const AssetClaimsSchema = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-file"), + workspaceRoot: Schema.String, + baseRelativePath: Schema.String, + expiresAt: Schema.Number, + }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment"), + attachmentId: Schema.String, + expiresAt: Schema.Number, + }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("project-favicon"), + workspaceRoot: Schema.String, + relativePath: Schema.NullOr(Schema.String), + expiresAt: Schema.Number, + }), +]); +type AssetClaims = typeof AssetClaimsSchema.Type; + +const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); +const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); +const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); + +export type ResolvedAsset = + | { readonly kind: "file"; readonly path: string } + | { readonly kind: "project-favicon-fallback" }; + +function decodeClaims(encodedPayload: string): AssetClaims | null { + try { + return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +function decodeRelativePath(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +const failAccess = (message: string, cause?: unknown) => + new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); + +const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( + function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { + const fileSystem = yield* FileSystem.FileSystem; + const workspacePaths = yield* WorkspacePaths; + const resolved = yield* workspacePaths + .resolveRelativePathWithinRoot(input) + .pipe(Effect.orElseSucceed(() => null)); + if (!resolved) return null; + + const [canonicalRoot, canonicalFile] = yield* Effect.all([ + fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)), + fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)), + ]); + if (!canonicalRoot || !canonicalFile) return null; + + const path = yield* Path.Path; + const relative = path.relative(canonicalRoot, canonicalFile); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null; + + const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null)); + return info?.type === "File" ? canonicalFile : null; + }, +); + +export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { + readonly resource: AssetResource; + readonly workspaceRoot?: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; + const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + let claims: AssetClaims; + let fileName: string; + + switch (input.resource._tag) { + case "workspace-file": { + if (!input.workspaceRoot) { + return yield* failAccess("Workspace context was not found."); + } + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const relativePath = path.isAbsolute(input.resource.path) + ? path.relative(workspaceRoot, input.resource.path) + : input.resource.path; + const resolved = yield* workspacePaths + .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { + return yield* failAccess("Only HTML and PDF files can open in the browser."); + } + const canonicalFile = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot, + relativePath: resolved.relativePath, + }); + if (!canonicalFile) { + return yield* failAccess("Workspace asset was not found."); + } + claims = { + version: 1, + kind: "workspace-file", + workspaceRoot: yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt, + }; + fileName = path.basename(resolved.relativePath); + break; + } + case "attachment": { + const config = yield* ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: input.resource.attachmentId, + }); + if (!attachmentPath) { + return yield* failAccess("Attachment was not found."); + } + claims = { + version: 1, + kind: "attachment", + attachmentId: input.resource.attachmentId, + expiresAt, + }; + fileName = path.basename(attachmentPath); + break; + } + case "project-favicon": { + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.resource.cwd) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const faviconResolver = yield* ProjectFaviconResolver; + const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); + const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; + if ( + relativePath && + !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath })) + ) { + return yield* failAccess("Project favicon was not found."); + } + claims = { + version: 1, + kind: "project-favicon", + workspaceRoot: yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + relativePath, + expiresAt, + }; + fileName = relativePath ? path.basename(relativePath) : "favicon.svg"; + break; + } + } + + const secretStore = yield* ServerSecretStore; + const signingSecret = yield* secretStore + .getOrCreateRandom(SIGNING_SECRET_NAME, 32) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); + const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; + return { + relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, + expiresAt, + }; +}); + +export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( + token: string, + relativePath: string, +) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) return null; + + const secretStore = yield* ServerSecretStore; + const signingSecret = yield* secretStore + .getOrCreateRandom(SIGNING_SECRET_NAME, 32) + .pipe(Effect.orElseSucceed(() => null)); + if (!signingSecret) return null; + if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; + + const claims = decodeClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + + if (claims.kind === "attachment") { + const config = yield* ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: claims.attachmentId, + }); + if (!attachmentPath) return null; + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null)); + return info?.type === "File" + ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) + : null; + } + + if (claims.kind === "project-favicon") { + if (claims.relativePath === null) { + return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; + } + const faviconPath = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot: claims.workspaceRoot, + relativePath: claims.relativePath, + }); + return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; + } + + const decodedPath = decodeRelativePath(relativePath); + if (decodedPath === null) return null; + const path = yield* Path.Path; + const segments = decodedPath.split(/[\\/]/); + if ( + decodedPath.length === 0 || + decodedPath.includes("\0") || + segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) || + !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase()) + ) { + return null; + } + const joinedRelativePath = + claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath); + const workspaceFile = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot: claims.workspaceRoot, + relativePath: joinedRelativePath, + }); + return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null; +}); diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index 8c6999a7341..dc7db435426 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -1,8 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import NodePath from "node:path"; -export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; - export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, ""); if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 422d880d7f1..a3bbcc66d34 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -3,7 +3,7 @@ import * as NFS from "node:fs"; import * as path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Duration from "effect/Duration"; @@ -11,8 +11,9 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; +import { readBootstrapEnvelope } from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); @@ -41,14 +42,6 @@ const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema)); it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { - it.effect("uses platform-specific fd paths", () => - Effect.sync(() => { - assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); - assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); - assert.equal(resolveFdPath(3, "win32"), undefined); - }), - ); - it.effect("reads a bootstrap envelope from a provided fd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -89,7 +82,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; try { - const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.provideService(HostProcessPlatform, "linux")); assertSome(payload, { mode: "desktop", }); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 9ad6328798d..83d1d337888 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -11,6 +11,7 @@ import * as Predicate from "effect/Predicate"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; class BootstrapError extends Data.TaggedError("BootstrapError")<{ readonly message: string; @@ -110,36 +111,39 @@ const isFdReady = (fd: number) => ); const makeBootstrapInputStream = (fd: number) => - Effect.try({ - try: () => { - const fdPath = resolveFdPath(fd); - if (fdPath === undefined) { - return makeDirectBootstrapStream(fd); - } + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* Effect.try({ + try: () => { + const fdPath = resolveFdPath(fd, platform); + if (fdPath === undefined) { + return makeDirectBootstrapStream(fd); + } - let streamFd: number | undefined; - try { - streamFd = NFS.openSync(fdPath, "r"); - return NFS.createReadStream("", { - fd: streamFd, - encoding: "utf8", - autoClose: true, - }); - } catch (error) { - if (isBootstrapFdPathDuplicationError(error)) { - if (streamFd !== undefined) { - NFS.closeSync(streamFd); + let streamFd: number | undefined; + try { + streamFd = NFS.openSync(fdPath, "r"); + return NFS.createReadStream("", { + fd: streamFd, + encoding: "utf8", + autoClose: true, + }); + } catch (error) { + if (isBootstrapFdPathDuplicationError(error)) { + if (streamFd !== undefined) { + NFS.closeSync(streamFd); + } + return makeDirectBootstrapStream(fd); } - return makeDirectBootstrapStream(fd); + throw error; } - throw error; - } - }, - catch: (error) => - new BootstrapError({ - message: "Failed to duplicate bootstrap fd.", - cause: error, - }), + }, + catch: (error) => + new BootstrapError({ + message: "Failed to duplicate bootstrap fd.", + cause: error, + }), + }); }); const makeDirectBootstrapStream = (fd: number): Readable => { @@ -165,10 +169,7 @@ const isBootstrapFdPathDuplicationError = Predicate.compose( (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", ); -export function resolveFdPath( - fd: number, - platform: NodeJS.Platform = process.platform, -): string | undefined { +function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { if (platform === "linux") { return `/proc/self/fd/${fd}`; } diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 21ee746853e..601d11ae0bd 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,4 +1,4 @@ -import NodeOS from "node:os"; +import * as NodeOS from "node:os"; import { assert, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 167fb75a37c..54f9fd40da9 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Console from "effect/Console"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -29,6 +30,7 @@ import * as CliState from "../cloud/CliState.ts"; import * as CliTokenManager from "../cloud/CliTokenManager.ts"; import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; import { relayUrlConfig } from "../cloud/publicConfig.ts"; +import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; import { ServerConfig } from "../config.ts"; import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; @@ -228,6 +230,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f httpClient.execute, Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), + withRelayClientTracing, ); return response.ok ? ({ status: "revoked" } satisfies RelayUnlinkResult) @@ -299,6 +302,7 @@ const runCloudCommand = ( RelayClient.layerCloudflared({ baseDir: config.baseDir }), EnvironmentAuth.runtimeLayer, ServerEnvironmentLive, + headlessRelayClientTracingLayer, ).pipe( Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(Layer.succeed(ServerConfig, config)), diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 799ab609f43..8ea7ca06f9a 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -3,13 +3,19 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; -import { HttpClient } from "effect/unstable/http"; +import * as Tracer from "effect/Tracer"; +import { HttpClient, HttpServerRequest } from "effect/unstable/http"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; +import { + consumeCloudReplayGuards, + reconcileDesiredCloudLink, + traceRelayBrokerHandler, +} from "./http.ts"; import { CloudManagedEndpointRuntime, type CloudManagedEndpointRuntimeShape, @@ -69,6 +75,38 @@ describe("consumeCloudReplayGuards", () => { ); }); +describe("traceRelayBrokerHandler", () => { + it.effect("continues the incoming relay trace with the product tracer", () => + Effect.gen(function* () { + const spans: Array = []; + const productTracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const request = HttpServerRequest.fromWeb( + new Request("https://environment.example.test/api/t3-cloud/mint-credential", { + headers: { + traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", + }, + }), + ); + + yield* traceRelayBrokerHandler(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.provideService(RelayClientTracer, Option.some(productTracer)), + ); + + expect(spans).toHaveLength(1); + const span = spans[0]!; + expect(span.traceId).toBe("0123456789abcdef0123456789abcdef"); + expect(Option.getOrUndefined(span.parent)?.spanId).toBe("0123456789abcdef"); + }), + ); +}); + describe("reconcileDesiredCloudLink", () => { it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 896990849b6..b78d47a20c1 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -29,6 +29,7 @@ import { RelayLinkProofRequest, RelayManagedEndpointOrigin, } from "@t3tools/contracts/relay"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { normalizeRelayIssuer, RELAY_HEALTH_REQUEST_TYP, @@ -47,7 +48,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/unstable/http"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -110,6 +111,19 @@ const requireRelayUrl = relayUrlConfig.pipe( ), ); +export const traceRelayBrokerHandler = ( + effect: Effect.Effect, +): Effect.Effect => + HttpServerRequest.HttpServerRequest.pipe( + Effect.flatMap((request) => + Option.match(HttpTraceContext.fromHeaders(request.headers), { + onNone: () => effect, + onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)), + }), + ), + withRelayClientTracing, + ); + function bytesToString(bytes: Uint8Array): string { return new TextDecoder().decode(bytes); } @@ -512,6 +526,7 @@ const relayClientRequest =
( message: `T3 Connect relay request failed: ${String(cause)}`, }), ), + withRelayClientTracing, ); const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( @@ -938,7 +953,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group( .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) .handle("t3MintCredential", ({ payload }) => - cloudMintCredentialHandler(dependencies, payload), + traceRelayBrokerHandler(cloudMintCredentialHandler(dependencies, payload)), ); }), ); diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 558560bfffb..4cce901fa55 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -2,7 +2,11 @@ import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; -import { makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts"; +import { + makeCloudCliOAuthConfig, + makeRelayUrlConfig, + resolveRelayClientTracingConfig, +} from "./publicConfig.ts"; const provideEnv = (env: Readonly>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))); @@ -83,3 +87,39 @@ it.effect("requires Clerk OAuth config when the server bundle has no injected va clerkCliOAuthClientIdFallback: "", }).pipe(provideEnv({}), Effect.flip), ); + +it("resolves relay client tracing from runtime config with build-time fallback", () => { + const fallback = { + tracesUrl: "https://embedded.example.test/v1/traces", + tracesDataset: "embedded-dataset", + tracesToken: "embedded-token", + }; + + assert.deepEqual(resolveRelayClientTracingConfig({}, fallback), fallback); + assert.deepEqual( + resolveRelayClientTracingConfig( + { + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://runtime.example.test/v1/traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", + }, + fallback, + ), + { + tracesUrl: "https://runtime.example.test/v1/traces", + tracesDataset: "runtime-dataset", + tracesToken: "runtime-token", + }, + ); + assert.equal( + resolveRelayClientTracingConfig( + { + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "http://insecure.example.test/v1/traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", + }, + fallback, + ), + null, + ); +}); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 5c64a242377..b344107d756 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -9,6 +9,9 @@ import * as SchemaIssue from "effect/SchemaIssue"; declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined; +declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; +declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; +declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const; @@ -32,6 +35,15 @@ function readBuildTimeValue(value: string | undefined): string { return typeof value === "undefined" ? "" : value.trim(); } +function normalizeSecureUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + export const buildTimeRelayUrl = typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" ? "" @@ -46,6 +58,37 @@ export const buildTimeClerkCliOAuthClientId = readBuildTimeValue( ? undefined : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, ); +export const buildTimeRelayClientTracing = { + tracesUrl: readBuildTimeValue( + typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__ === "undefined" + ? undefined + : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__, + ), + tracesDataset: readBuildTimeValue( + typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__ === "undefined" + ? undefined + : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__, + ), + tracesToken: readBuildTimeValue( + typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__ === "undefined" + ? undefined + : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__, + ), +} as const; + +export function resolveRelayClientTracingConfig( + env: Readonly> = process.env, + fallback = buildTimeRelayClientTracing, +) { + const tracesUrl = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() || fallback.tracesUrl; + const tracesDataset = + env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() || fallback.tracesDataset; + const tracesToken = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() || fallback.tracesToken; + const normalizedTracesUrl = normalizeSecureUrl(tracesUrl); + return normalizedTracesUrl && tracesDataset && tracesToken + ? { tracesUrl: normalizedTracesUrl, tracesDataset, tracesToken } + : null; +} export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { const runtimeConfig = Config.nonEmptyString("T3CODE_RELAY_URL"); diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts new file mode 100644 index 00000000000..e35c94545a5 --- /dev/null +++ b/apps/server/src/cloud/relayTracing.ts @@ -0,0 +1,21 @@ +import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; + +import { resolveRelayClientTracingConfig } from "./publicConfig.ts"; + +const relayClientTracingConfig = resolveRelayClientTracingConfig(); + +export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer( + relayClientTracingConfig, + { + serviceName: "t3-headless-relay-client", + runtime: "node", + client: "headless-cli", + }, +); + +export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, { + serviceName: "t3-server", + runtime: "node", + client: "environment-server", + component: "relay-broker", +}); diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index ed81f021f4b..f5f746134f2 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -4,6 +4,7 @@ import type { ServerProcessSignal, ServerSignalProcessResult, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -277,6 +278,9 @@ const runProcess = Effect.fn("runProcess")( readonly errorMessage: string; }) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // `ps` and `powershell.exe` are real executables; spawning through cmd.exe + // shell mode would re-tokenize the PowerShell `-Command` payload (which + // contains pipes) before PowerShell ever sees it. const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { cwd: process.cwd(), @@ -369,8 +373,10 @@ function readWindowsProcessRows(): Effect.Effect< ); } -export const readProcessRows = (platform = process.platform) => - platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); +export const readProcessRows = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); +}); export function aggregateProcessDiagnostics(input: { readonly serverPid: number; @@ -387,7 +393,7 @@ function assertDescendantPid( return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process.")); } - return readProcessRows().pipe( + return readProcessRows.pipe( Effect.flatMap((rows) => { const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); const descendant = buildDescendantEntries(filteredRows, process.pid).some( @@ -407,7 +413,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () { const readAt = yield* DateTime.now; - const rows = yield* readProcessRows().pipe( + const rows = yield* readProcessRows.pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); return makeResult({ serverPid: process.pid, rows, readAt }); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index 2b6dfe8d362..efeeb66256d 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -252,7 +252,7 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { const sampleOnce = Effect.gen(function* () { const sampledAt = yield* DateTime.now; const sampledAtMs = DateTime.toEpochMillis(sampledAt); - const rows = yield* readProcessRows().pipe( + const rows = yield* readProcessRows.pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const samples = collectMonitoredSamples({ diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index cc8d803c970..fd4f6baab1a 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -1,4 +1,5 @@ import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -11,8 +12,8 @@ import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/Serv import packageJson from "../../../package.json" with { type: "json" }; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; -function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { - switch (process.platform) { +function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { + switch (platform) { case "darwin": return "darwin"; case "linux": @@ -24,8 +25,10 @@ function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { } } -function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { - switch (process.arch) { +function platformArch( + architecture: NodeJS.Architecture, +): ExecutionEnvironmentDescriptor["platform"]["arch"] { + switch (architecture) { case "arm64": return "arm64"; case "x64": @@ -40,6 +43,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem @@ -80,8 +85,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function environmentId, label, platform: { - os: platformOs(), - arch: platformArch(), + os: platformOs(hostPlatform), + arch: platformArch(hostArchitecture), }, serverVersion: packageJson.version, capabilities: { diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts index 827f562422e..3a4dce1627c 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { vi } from "vite-plus/test"; import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; @@ -28,6 +29,16 @@ const LinuxMachineInfoLayer = Layer.merge( : Effect.succeed(""), }), ); +const withHostPlatform = ( + layer: Layer.Layer, + platform: NodeJS.Platform, + hostname: string, +) => + Layer.mergeAll( + layer, + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessHostname, hostname), + ); afterEach(() => { runMock.mockReset(); @@ -38,9 +49,7 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "win32", - hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); expect(result).toBe("macbook-pro"); }), @@ -61,9 +70,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "darwin", - hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); expect(result).toBe("Julius's MacBook Pro"); expect(runMock).toHaveBeenCalledWith( @@ -80,9 +87,7 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "linux", - hostname: "buildbox", - }).pipe(Effect.provide(LinuxMachineInfoLayer)); + }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); expect(result).toBe("Build Agent 01"); expect(runMock).not.toHaveBeenCalled(); @@ -104,9 +109,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "linux", - hostname: "runner-01", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); expect(result).toBe("CI Runner"); expect(runMock).toHaveBeenCalledWith( @@ -123,9 +126,7 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "win32", - hostname: "JULIUS-LAPTOP", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); expect(result).toBe("JULIUS-LAPTOP"); }), @@ -145,9 +146,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "darwin", - hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); expect(result).toBe("macbook-pro"); }), @@ -168,9 +167,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "linux", - hostname: " ", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); expect(result).toBe("t3code"); }), diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts index b07425b936b..73a3b9526c4 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -1,5 +1,4 @@ -import * as OS from "node:os"; - +import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -8,8 +7,6 @@ import { ProcessRunner } from "../../processRunner.ts"; interface ResolveServerEnvironmentLabelInput { readonly cwdBaseName: string; - readonly platform?: NodeJS.Platform; - readonly hostname?: string | null; } function normalizeLabel(value: string | null | undefined): string | null { @@ -69,9 +66,8 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( return normalizeLabel(result.value.stdout); }); -const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( - platform: NodeJS.Platform, -) { +const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { + const platform = yield* HostProcessPlatform; if (platform === "darwin") { return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); } @@ -94,13 +90,12 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( input: ResolveServerEnvironmentLabelInput, ) { - const platform = input.platform ?? process.platform; - const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); + const friendlyHostLabel = yield* resolveFriendlyHostLabel(); if (friendlyHostLabel) { return friendlyHostLabel; } - const hostname = normalizeLabel(input.hostname ?? OS.hostname()); + const hostname = normalizeLabel(yield* HostProcessHostname); if (hostname) { return hostname; } diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index cfd1b3db66d..f1d274813de 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -25,15 +25,13 @@ import { import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; -import { - ATTACHMENTS_ROUTE_PREFIX, - normalizeAttachmentRelativePath, - resolveAttachmentRelativePath, -} from "./attachmentPaths.ts"; -import { resolveAttachmentPathById } from "./attachmentStore.ts"; import { resolveStaticDir, ServerConfig } from "./config.ts"; +import { + ASSET_ROUTE_PREFIX, + FALLBACK_PROJECT_FAVICON_SVG, + resolveAsset, +} from "./assets/AssetAccess.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { annotateEnvironmentRequest, @@ -44,8 +42,6 @@ import { import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; -const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; -const FALLBACK_PROJECT_FAVICON_SVG = ``; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const INDEX_HTML_FILE_NAME = "index.html"; @@ -179,107 +175,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( ), ); -export const attachmentsRouteLayer = HttpRouter.add( +export const assetRouteLayer = HttpRouter.add( "GET", - `${ATTACHMENTS_ROUTE_PREFIX}/*`, + `${ASSET_ROUTE_PREFIX}/*`, Effect.gen(function* () { - yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } - const config = yield* ServerConfig; - const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); - const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); - if (!normalizedRelativePath) { - return HttpServerResponse.text("Invalid attachment path", { status: 400 }); - } - - const isIdLookup = - !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); - const filePath = isIdLookup - ? resolveAttachmentPathById({ - attachmentsDir: config.attachmentsDir, - attachmentId: normalizedRelativePath, - }) - : resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: normalizedRelativePath, - }); - if (!filePath) { - return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", { - status: isIdLookup ? 404 : 400, - }); - } - - const fileSystem = yield* FileSystem.FileSystem; - const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); - if (!fileInfo || fileInfo.type !== "File") { + const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + if (separatorIndex <= 0) { return HttpServerResponse.text("Not Found", { status: 404 }); } - return yield* HttpServerResponse.file(filePath, { - status: 200, - headers: { - "Cache-Control": "public, max-age=31536000, immutable", - }, - }).pipe( - Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), + const asset = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, - }), - ), -); - -export const projectFaviconRouteLayer = HttpRouter.add( - "GET", - "/api/project-favicon", - Effect.gen(function* () { - yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); - const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Bad Request", { status: 400 }); - } - - const projectCwd = url.value.searchParams.get("cwd"); - if (!projectCwd) { - return HttpServerResponse.text("Missing cwd parameter", { status: 400 }); + if (!asset) { + return HttpServerResponse.text("Not Found", { status: 404 }); } - - const faviconResolver = yield* ProjectFaviconResolver; - const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd); - if (!faviconFilePath) { + if (asset.kind === "project-favicon-fallback") { return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { status: 200, contentType: "image/svg+xml", headers: { - "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", }, }); } - return yield* HttpServerResponse.file(faviconFilePath, { + return yield* HttpServerResponse.file(asset.path, { status: 200, headers: { - "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", }, }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, - }), - ), + }), ); export const staticAndDevRouteLayer = Layer.unwrap( diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 188a8d32d18..1bfd042d078 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -205,6 +205,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); + assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); }), diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts new file mode 100644 index 00000000000..f60652609f5 --- /dev/null +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -0,0 +1,165 @@ +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { McpSchema, McpServer } from "effect/unstable/ai"; +import { HttpServerResponse } from "effect/unstable/http"; + +import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; + +const environmentId = EnvironmentId.make("environment-mcp-test"); +const threadId = ThreadId.make("thread-mcp-test"); +const tabId = PreviewTabId.make("tab-mcp-test"); +const invocation = { + environmentId, + threadId, + providerSessionId: "provider-session-mcp-test", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"] as const), + issuedAt: 1, + expiresAt: Number.MAX_SAFE_INTEGER, +}; +const client = McpSchema.McpServerClient.of({ + clientId: 1, + initializePayload: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); +const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(PreviewAutomationBroker.layer), +); + +it("normalizes empty successful notification responses to accepted", () => { + const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( + HttpServerResponse.text("", { status: 200, contentType: "application/json" }), + ); + expect(notificationResponse.status).toBe(202); + + const resultResponse = McpHttpServer.normalizeMcpHttpResponse( + HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), + ); + expect(resultResponse.status).toBe(200); +}); + +it.effect("registers annotated tools and preserves authenticated request context", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const requests = yield* broker.connect("mcp-test-client"); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: true, + result: + request.operation === "snapshot" + ? { + url: "http://example.test/", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + screenshot: { + mimeType: "image/png", + data: Buffer.from("png").toString("base64"), + width: 10, + height: 5, + }, + } + : request.operation === "press" + ? undefined + : { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "mcp-test-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); + expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); + expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); + expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); + + const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); + expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true); + expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true); + expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); + + const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); + expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); + expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); + expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); + + const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); + expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); + expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); + + const status = yield* server + .callTool({ name: "preview_status", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(status.isError).toBe(false); + expect(status.structuredContent).toMatchObject({ + available: true, + tabId, + }); + + const malformed = yield* server + .callTool({ name: "preview_click", arguments: { selector: "" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(malformed.isError).toBe(true); + + const snapshot = yield* server + .callTool({ name: "preview_snapshot", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(snapshot.isError).toBe(false); + expect(snapshot.content.some((content) => content.type === "image")).toBe(true); + expect(snapshot.structuredContent).toMatchObject({ + screenshot: { mimeType: "image/png", width: 10, height: 5 }, + }); + + const press = yield* server + .callTool({ name: "preview_press", arguments: { key: "Enter" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(press.isError).toBe(false); + expect(press.structuredContent).toBeNull(); + expect(press.content).toEqual([{ type: "text", text: "null" }]); + }), + ).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts new file mode 100644 index 00000000000..6cde2017a9e --- /dev/null +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -0,0 +1,191 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import type * as Types from "effect/Types"; +import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import { + PreviewSnapshotToolkitHandlersLive, + PreviewStandardToolkitHandlersLive, +} from "./toolkits/preview/handlers.ts"; +import { + PreviewSnapshotTool, + PreviewSnapshotToolkit, + PreviewStandardToolkit, +} from "./toolkits/preview/tools.ts"; + +const unauthorized = HttpServerResponse.jsonUnsafe( + { + error: "invalid_mcp_credential", + message: "A valid provider-scoped MCP bearer credential is required.", + }, + { + status: 401, + headers: { + "cache-control": "no-store", + "www-authenticate": "Bearer", + }, + }, +); + +type AuthenticatedHttpEffect = Effect.Effect< + HttpServerResponse.HttpServerResponse, + Types.unhandled, + McpInvocationContext.McpInvocationContext +>; + +type McpAuthMiddleware = ( + httpEffect: AuthenticatedHttpEffect, +) => Effect.Effect< + HttpServerResponse.HttpServerResponse, + Types.unhandled, + HttpServerRequest.HttpServerRequest +>; + +export const normalizeMcpHttpResponse = ( + response: HttpServerResponse.HttpServerResponse, +): HttpServerResponse.HttpServerResponse => { + const bodyIsEmpty = + response.body._tag === "Empty" || + (response.body._tag === "Uint8Array" && response.body.contentLength === 0) || + (response.body._tag === "Raw" && response.body.contentLength === 0); + return response.status === 200 && bodyIsEmpty + ? HttpServerResponse.setStatus(response, 202) + : response; +}; + +const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( + Effect.map( + (registry): McpAuthMiddleware => + Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = yield* registry.resolve(token); + if (!invocation) return unauthorized; + return yield* httpEffect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.map(normalizeMcpHttpResponse), + ); + }), + ), + Effect.withSpan("McpHttpServer.makeAuthMiddleware"), +); + +const McpAuthMiddlewareLive = HttpRouter.middleware<{ + provides: McpInvocationContext.McpInvocationContext; +}>()(makeMcpAuthMiddleware).layer; + +const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const built = yield* PreviewSnapshotToolkit; + const tool = PreviewSnapshotTool; + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: tool.name, + description: Tool.getDescription(tool), + inputSchema: Tool.getJsonSchema(tool), + annotations: { + ...Context.getOption(tool.annotations, Tool.Title).pipe( + Option.map((title) => ({ title })), + Option.getOrUndefined, + ), + readOnlyHint: Context.get(tool.annotations, Tool.Readonly), + destructiveHint: Context.get(tool.annotations, Tool.Destructive), + idempotentHint: Context.get(tool.annotations, Tool.Idempotent), + openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), + }, + }), + annotations: tool.annotations, + handle: (payload) => + Effect.withFiber((fiber) => { + const invocation = Context.getUnsafe( + fiber.context, + McpInvocationContext.McpInvocationContext, + ); + return built.handle("preview_snapshot", payload).pipe( + Stream.unwrap, + Stream.run(Sink.last()), + Effect.flatMap(Effect.fromOption), + Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.matchCause({ + onFailure: (cause) => + new McpSchema.CallToolResult({ + isError: true, + content: [{ type: "text", text: Cause.pretty(cause) }], + }), + onSuccess: ({ encodedResult }) => { + const snapshot = encodedResult as { + readonly screenshot: { + readonly mimeType: "image/png"; + readonly data: string; + readonly width: number; + readonly height: number; + }; + readonly [key: string]: unknown; + }; + const { screenshot, ...page } = snapshot; + const metadata = { + ...page, + screenshot: { + mimeType: screenshot.mimeType, + width: screenshot.width, + height: screenshot.height, + }, + }; + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + { + type: "image", + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ], + }); + }, + }), + ); + }), + }); +}); + +const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe( + Layer.provide(PreviewStandardToolkitHandlersLive), +); + +const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnapshot()).pipe( + Layer.provide(PreviewSnapshotToolkitHandlersLive), +); + +export const PreviewToolkitRegistrationLive = Layer.mergeAll( + PreviewStandardToolkitRegistrationLive, + PreviewSnapshotRegistrationLive, +); + +const McpTransportLive = McpServer.layerHttp({ + name: "T3 Code", + version: packageJson.version, + path: "/mcp", +}).pipe(Layer.provide(McpAuthMiddlewareLive)); + +export const layer = PreviewToolkitRegistrationLive.pipe( + Layer.provideMerge(McpTransportLive), + Layer.provide(PreviewAutomationBroker.layer), +); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts new file mode 100644 index 00000000000..0d3f84df42c --- /dev/null +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -0,0 +1,33 @@ +import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +export type McpCapability = "preview"; + +export interface McpInvocationScope { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly providerSessionId: string; + readonly providerInstanceId: ProviderInstanceId; + readonly capabilities: ReadonlySet; + readonly issuedAt: number; + readonly expiresAt: number; +} + +export class McpInvocationContext extends Context.Service< + McpInvocationContext, + McpInvocationScope +>()("t3/mcp/McpInvocationContext") {} + +export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( + capability: McpCapability, +) { + const invocation = yield* McpInvocationContext; + if (!invocation.capabilities.has(capability)) { + return yield* new PreviewAutomationUnavailableError({ + message: `MCP credential does not grant the ${capability} capability.`, + }); + } + return invocation; +}); diff --git a/apps/server/src/mcp/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts new file mode 100644 index 00000000000..d5dc582046c --- /dev/null +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -0,0 +1,28 @@ +import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +export interface McpProviderSessionConfig { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly providerSessionId: string; + readonly providerInstanceId: ProviderInstanceId; + readonly endpoint: string; + readonly authorizationHeader: string; +} + +const sessionsByThread = new Map(); + +export function setMcpProviderSession(config: McpProviderSessionConfig): void { + sessionsByThread.set(config.threadId, config); +} + +export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined { + return sessionsByThread.get(threadId); +} + +export function clearMcpProviderSession(threadId: ThreadId): void { + sessionsByThread.delete(threadId); +} + +export function clearAllMcpProviderSessions(): void { + sessionsByThread.clear(); +} diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts new file mode 100644 index 00000000000..d6540d567af --- /dev/null +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -0,0 +1,68 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { HttpServer } from "effect/unstable/http"; + +import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; + +const environmentId = EnvironmentId.make("environment-1"); +const fakeHttpServer = HttpServer.HttpServer.of({ + address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 43123 }, + serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], +}); +const fakeEnvironment = ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.die("unused"), +}); + +const makeRegistry = (now: () => number) => + McpSessionRegistry.__testing + .make({ + now, + idleTimeoutMs: 100, + maximumLifetimeMs: 1_000, + }) + .pipe( + Effect.provideService(HttpServer.HttpServer, fakeHttpServer), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provide(NodeServices.layer), + ); + +it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => + Effect.gen(function* () { + let timestamp = 1_000; + const registry = yield* makeRegistry(() => timestamp); + const threadId = ThreadId.make("thread-1"); + const issued = yield* registry.issue({ + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + }); + expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); + const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); + expect(token.length).toBeGreaterThan(20); + + const resolved = yield* registry.resolve(token); + expect(resolved?.threadId).toBe(threadId); + + yield* registry.revokeThread(threadId); + expect(yield* registry.resolve(token)).toBeUndefined(); + + timestamp += 2_000; + }), +); + +it.effect("expires credentials after inactivity", () => + Effect.gen(function* () { + let timestamp = 1_000; + const registry = yield* makeRegistry(() => timestamp); + const issued = yield* registry.issue({ + threadId: ThreadId.make("thread-2"), + providerInstanceId: ProviderInstanceId.make("claude"), + }); + const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); + timestamp += 101; + expect(yield* registry.resolve(token)).toBeUndefined(); + }), +); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts new file mode 100644 index 00000000000..1ee7d278c62 --- /dev/null +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -0,0 +1,207 @@ +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { HttpServer } from "effect/unstable/http"; + +import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpProviderSession from "./McpProviderSession.ts"; + +export interface McpCredentialRequest { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; +} + +export interface McpIssuedCredential { + readonly config: McpProviderSession.McpProviderSessionConfig; + readonly expiresAt: number; +} + +export interface McpSessionRegistryShape { + readonly issue: (request: McpCredentialRequest) => Effect.Effect; + readonly resolve: ( + rawToken: string, + ) => Effect.Effect; + readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect; + readonly revokeThread: (threadId: ThreadId) => Effect.Effect; + readonly revokeAll: Effect.Effect; +} + +export class McpSessionRegistry extends Context.Service< + McpSessionRegistry, + McpSessionRegistryShape +>()("t3/mcp/McpSessionRegistry") {} + +interface CredentialRecord { + readonly tokenHash: string; + readonly scope: McpInvocationContext.McpInvocationScope; + readonly lastUsedAt: number; +} + +interface RegistryState { + readonly records: ReadonlyMap; +} + +export interface McpSessionRegistryOptions { + readonly idleTimeoutMs?: number; + readonly maximumLifetimeMs?: number; + readonly now?: () => number; +} + +const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; +const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; + +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); + +const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( + options: McpSessionRegistryOptions = {}, +) { + const crypto = yield* Crypto.Crypto; + const environment = yield* ServerEnvironment; + const environmentId = yield* environment.getEnvironmentId; + const httpServer = yield* HttpServer.HttpServer; + const state = yield* SynchronizedRef.make({ records: new Map() }); + const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis; + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; + const endpoint = + httpServer.address._tag === "TcpAddress" + ? `http://127.0.0.1:${httpServer.address.port}/mcp` + : "http://127.0.0.1/mcp"; + + const hashToken = (token: string) => + crypto + .digest("SHA-256", new TextEncoder().encode(token)) + .pipe(Effect.map(bytesToHex), Effect.orDie); + + const pruneExpired = (records: ReadonlyMap, timestamp: number) => { + const next = new Map( + Array.from(records).filter( + ([, record]) => + timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs, + ), + ); + return next.size === records.size ? records : next; + }; + + const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( + function* (request) { + const issuedAt = yield* currentTimeMillis; + const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); + const tokenHash = yield* hashToken(rawToken); + const expiresAt = issuedAt + maximumLifetimeMs; + const scope: McpInvocationContext.McpInvocationScope = { + environmentId, + threadId: ThreadId.make(request.threadId), + providerSessionId, + providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + capabilities: new Set(["preview"]), + issuedAt, + expiresAt, + }; + yield* SynchronizedRef.update(state, ({ records }) => { + const next = new Map(pruneExpired(records, issuedAt)); + next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); + return { records: next }; + }); + return { + config: { + environmentId, + threadId: scope.threadId, + providerSessionId, + providerInstanceId: scope.providerInstanceId, + endpoint, + authorizationHeader: `Bearer ${rawToken}`, + }, + expiresAt, + }; + }, + ); + + const resolve: McpSessionRegistryShape["resolve"] = Effect.fn("McpSessionRegistry.resolve")( + function* (rawToken) { + if (rawToken.length === 0) return undefined; + const tokenHash = yield* hashToken(rawToken); + const timestamp = yield* currentTimeMillis; + return yield* SynchronizedRef.modify(state, ({ records }) => { + const current = pruneExpired(records, timestamp); + const record = current.get(tokenHash); + if (!record) return [undefined, { records: current }] as const; + const next = new Map(current); + next.set(tokenHash, { ...record, lastUsedAt: timestamp }); + return [record.scope, { records: next }] as const; + }); + }, + ); + + const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => + SynchronizedRef.update(state, ({ records }) => ({ + records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), + })); + + return McpSessionRegistry.of({ + issue, + resolve, + revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( + function* (providerSessionId) { + yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); + }, + ), + revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { + yield* revokeWhere((record) => record.scope.threadId === threadId); + }), + revokeAll: SynchronizedRef.set(state, { records: new Map() }), + }); +}); + +let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; + +const make = Effect.acquireRelease( + makeWithOptions().pipe( + Effect.tap((registry) => + Effect.sync(() => { + activeMcpSessionRegistry = registry; + }), + ), + ), + (registry) => + Effect.sync(() => { + if (activeMcpSessionRegistry === registry) { + activeMcpSessionRegistry = undefined; + } + }), +); + +export const layer: Layer.Layer< + McpSessionRegistry, + never, + Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer +> = Layer.effect(McpSessionRegistry, make); + +export const issueActiveMcpCredential = ( + request: McpCredentialRequest, +): Effect.Effect => + activeMcpSessionRegistry + ? activeMcpSessionRegistry + .revokeThread(request.threadId) + .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) + : Effect.sync((): McpIssuedCredential | undefined => undefined); + +export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; + +export const revokeAllActiveMcpCredentials = (): Effect.Effect => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; + +/** Exposed for tests. */ +export const __testing = { + make: makeWithOptions, +}; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts new file mode 100644 index 00000000000..353353aaef2 --- /dev/null +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -0,0 +1,89 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PreviewAutomationNoFocusedOwnerError, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; + +const scope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"] as const), + issuedAt: 1, + expiresAt: 2, +}; + +it.effect("routes a request to the focused owner and correlates its response", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.__testing.make; + const requests = yield* broker.connect("client-1"); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: true, + result: { available: true }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "client-1", + environmentId: scope.environmentId, + threadId: scope.threadId, + tabId: null, + visible: false, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + const result = yield* broker.invoke<{ available: boolean }>({ + scope, + operation: "open", + input: {}, + }); + + expect(result).toEqual({ available: true }); + }), + ), +); + +it.effect("rejects calls when no focused owner exists", () => + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.__testing.make; + const error = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + }), +); + +it.effect("routes interactive commands to a hidden durable browser host", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.__testing.make; + const requests = yield* broker.connect("client-hidden"); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: true }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "client-hidden", + environmentId: scope.environmentId, + threadId: scope.threadId, + tabId: "tab-hidden", + visible: false, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + }), + ), +); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts new file mode 100644 index 00000000000..e0a7b0c9285 --- /dev/null +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -0,0 +1,309 @@ +import { + PreviewAutomationControlInterruptedError, + PreviewAutomationExecutionError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationNoFocusedOwnerError, + PreviewAutomationResultTooLargeError, + PreviewAutomationTabNotFoundError, + PreviewAutomationTimeoutError, + PreviewAutomationUnavailableError, + PreviewAutomationUnsupportedClientError, + type PreviewAutomationError, + type PreviewAutomationOperation, + type PreviewAutomationOwner, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + type PreviewTabId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as McpInvocationContext from "./McpInvocationContext.ts"; + +export interface PreviewAutomationInvokeInput { + readonly scope: McpInvocationContext.McpInvocationScope; + readonly operation: PreviewAutomationOperation; + readonly input: unknown; + readonly tabId?: PreviewTabId; + readonly timeoutMs?: number; +} + +export interface PreviewAutomationBrokerShape { + readonly connect: (clientId: string) => Effect.Effect>; + readonly reportOwner: ( + owner: PreviewAutomationOwner, + ) => Effect.Effect; + readonly clearOwner: (clientId: string) => Effect.Effect; + readonly respond: ( + response: PreviewAutomationResponse, + ) => Effect.Effect; + readonly invoke: ( + request: PreviewAutomationInvokeInput, + ) => Effect.Effect; +} + +export class PreviewAutomationBroker extends Context.Service< + PreviewAutomationBroker, + PreviewAutomationBrokerShape +>()("t3/mcp/PreviewAutomationBroker") {} + +interface ClientConnection { + readonly clientId: string; + readonly queue: Queue.Queue< + Parameters[0] extends never + ? never + : import("@t3tools/contracts").PreviewAutomationRequest + >; +} + +interface PendingRequest { + readonly clientId: string; + readonly deferred: Deferred.Deferred; +} + +interface BrokerState { + readonly clients: ReadonlyMap; + readonly owners: ReadonlyMap; + readonly pending: ReadonlyMap; + readonly requestSequence: number; +} + +const makeResponseError = ( + error: NonNullable, +): PreviewAutomationError => { + switch (error._tag) { + case "PreviewAutomationNoFocusedOwnerError": + return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); + case "PreviewAutomationUnsupportedClientError": + return new PreviewAutomationUnsupportedClientError({ message: error.message }); + case "PreviewAutomationTabNotFoundError": + return new PreviewAutomationTabNotFoundError({ message: error.message }); + case "PreviewAutomationTimeoutError": + return new PreviewAutomationTimeoutError({ message: error.message }); + case "PreviewAutomationControlInterruptedError": + return new PreviewAutomationControlInterruptedError({ message: error.message }); + case "PreviewAutomationInvalidSelectorError": { + const detail = + typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + return new PreviewAutomationInvalidSelectorError({ + message: error.message, + selector: + detail && "selector" in detail && typeof detail.selector === "string" + ? detail.selector + : "", + }); + } + case "PreviewAutomationResultTooLargeError": { + const detail = + typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + return new PreviewAutomationResultTooLargeError({ + message: error.message, + maximumBytes: + detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" + ? detail.maximumBytes + : 64_000, + }); + } + case "PreviewAutomationUnavailableError": + return new PreviewAutomationUnavailableError({ message: error.message }); + default: + return new PreviewAutomationExecutionError({ + message: error.message, + detail: error.detail, + }); + } +}; + +const make = Effect.gen(function* PreviewAutomationBrokerMake() { + const state = yield* SynchronizedRef.make({ + clients: new Map(), + owners: new Map(), + pending: new Map(), + requestSequence: 0, + }); + + const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( + clientId: string, + queue: ClientConnection["queue"], + ) { + const toFail = yield* SynchronizedRef.modify(state, (current) => { + if (current.clients.get(clientId)?.queue !== queue) { + return [[] as ReadonlyArray, current] as const; + } + const clients = new Map(current.clients); + const owners = new Map(current.owners); + const pending = new Map(current.pending); + const disconnected: PendingRequest[] = []; + clients.delete(clientId); + owners.delete(clientId); + for (const [requestId, entry] of pending) { + if (entry.clientId === clientId) { + pending.delete(requestId); + disconnected.push(entry); + } + } + return [disconnected, { ...current, clients, owners, pending }] as const; + }); + yield* Effect.forEach( + toFail, + ({ deferred }) => + Deferred.fail( + deferred, + new PreviewAutomationUnavailableError({ + message: "The preview automation client disconnected.", + }), + ), + { discard: true }, + ); + yield* Queue.shutdown(queue); + }); + + const connect: PreviewAutomationBrokerShape["connect"] = Effect.fn( + "PreviewAutomationBroker.connect", + )(function* (clientId) { + const queue = yield* Queue.unbounded(); + const previous = yield* SynchronizedRef.modify(state, (current) => { + const clients = new Map(current.clients); + clients.set(clientId, { clientId, queue }); + return [current.clients.get(clientId), { ...current, clients }] as const; + }); + if (previous) yield* disconnect(clientId, previous.queue); + return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); + }); + + const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn( + "PreviewAutomationBroker.reportOwner", + )(function* (owner) { + yield* SynchronizedRef.update(state, (current) => { + const owners = new Map(current.owners); + owners.set(owner.clientId, owner); + return { ...current, owners }; + }); + }); + + const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn( + "PreviewAutomationBroker.clearOwner", + )(function* (clientId) { + yield* SynchronizedRef.update(state, (current) => { + const owners = new Map(current.owners); + owners.delete(clientId); + return { ...current, owners }; + }); + }); + + const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn( + "PreviewAutomationBroker.respond", + )(function* (response) { + const pending = yield* SynchronizedRef.modify(state, (current) => { + const entry = current.pending.get(response.requestId); + if (!entry) return [undefined, current] as const; + const next = new Map(current.pending); + next.delete(response.requestId); + return [entry, { ...current, pending: next }] as const; + }); + if (!pending) return; + if (response.ok) { + yield* Deferred.succeed(pending.deferred, response.result); + } else { + yield* Deferred.fail( + pending.deferred, + response.error + ? makeResponseError(response.error) + : new PreviewAutomationExecutionError({ + message: "Preview automation failed without an error payload.", + }), + ); + } + }); + + const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* ( + input: Parameters[0], + ): Effect.fn.Return { + const current = yield* SynchronizedRef.get(state); + const candidates = Array.from(current.owners.values()) + .filter( + (owner) => + owner.environmentId === input.scope.environmentId && + owner.threadId === input.scope.threadId && + owner.supportsAutomation, + ) + .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); + const owner = candidates[0]; + if (!owner) { + return yield* new PreviewAutomationNoFocusedOwnerError({ + message: "No desktop browser host is available for this thread.", + }); + } + const connection = current.clients.get(owner.clientId); + if (!connection) { + return yield* new PreviewAutomationUnavailableError({ + message: "The browser host is not connected.", + }); + } + if ( + input.operation !== "open" && + input.operation !== "status" && + !owner.tabId && + !input.tabId + ) { + return yield* new PreviewAutomationTabNotFoundError({ + message: "The browser host does not have an active tab.", + }); + } + const timeoutMs = input.timeoutMs ?? 15_000; + const deferred = yield* Deferred.make(); + const requestId = yield* SynchronizedRef.modify(state, (next) => { + const requestId = `preview-${next.requestSequence}`; + const pending = new Map(next.pending); + pending.set(requestId, { clientId: owner.clientId, deferred }); + return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; + }); + const removePending = SynchronizedRef.update(state, (next) => { + if (!next.pending.has(requestId)) return next; + const pending = new Map(next.pending); + pending.delete(requestId); + return { ...next, pending }; + }); + const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { + const offered = yield* Queue.offer(connection.queue, { + requestId, + threadId: input.scope.threadId, + tabId: input.tabId ?? owner.tabId ?? undefined, + operation: input.operation, + input: input.input, + timeoutMs, + }); + if (!offered) { + return yield* new PreviewAutomationUnavailableError({ + message: "The preview automation client is no longer accepting requests.", + }); + } + const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); + return yield* Option.match(result, { + onNone: () => + Effect.fail( + new PreviewAutomationTimeoutError({ + message: `Preview automation timed out after ${timeoutMs}ms.`, + }), + ), + onSome: (value) => Effect.succeed(value as A), + }); + }); + return yield* awaitResponse().pipe(Effect.ensuring(removePending)); + }); + + return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); +}).pipe(Effect.withSpan("PreviewAutomationBroker.make")); + +export const layer = Layer.effect(PreviewAutomationBroker, make); + +/** Exposed for tests. */ +export const __testing = { + make, +}; diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts new file mode 100644 index 00000000000..6013b1cac9e --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -0,0 +1,63 @@ +import * as Effect from "effect/Effect"; +import type { + PreviewAutomationOperation, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, + PreviewAutomationSnapshot, + PreviewAutomationStatus, +} from "@t3tools/contracts"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; +import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; + +const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( + operation: PreviewAutomationOperation, + input: unknown, + timeoutMs?: number, +): Effect.fn.Return< + A, + import("@t3tools/contracts").PreviewAutomationError, + McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker +> { + const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + return yield* broker.invoke({ + scope, + operation, + input, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); +}); + +const handlers = { + preview_status: () => invoke("status", {}), + preview_open: (input) => + invoke("open", { + ...input, + show: input.show ?? true, + reuseExistingTab: input.reuseExistingTab ?? true, + }), + preview_navigate: (input) => invoke("navigate", input, input.timeoutMs), + preview_snapshot: () => invoke("snapshot", {}), + preview_click: (input) => invoke("click", input, input.timeoutMs).pipe(Effect.as(null)), + preview_type: (input) => invoke("type", input, input.timeoutMs).pipe(Effect.as(null)), + preview_press: (input) => invoke("press", input).pipe(Effect.as(null)), + preview_scroll: (input) => invoke("scroll", input).pipe(Effect.as(null)), + preview_evaluate: (input) => + invoke("evaluate", input).pipe(Effect.map((result) => result ?? null)), + preview_wait_for: (input) => + invoke("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), + preview_recording_start: () => invoke("recordingStart", {}), + preview_recording_stop: () => invoke("recordingStop", {}), +} satisfies Parameters[0]; + +const { preview_snapshot, ...standardHandlers } = handlers; + +export const PreviewStandardToolkitHandlersLive = PreviewStandardToolkit.toLayer(standardHandlers); + +export const PreviewSnapshotToolkitHandlersLive = PreviewSnapshotToolkit.toLayer({ + preview_snapshot, +}); + +export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts new file mode 100644 index 00000000000..1347e0db0ec --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/unstable/ai"; + +import { PreviewToolkit } from "./tools.ts"; + +const schemaHasDescription = (schema: unknown): boolean => { + if (!schema || typeof schema !== "object") return false; + const record = schema as Record; + if (typeof record.description === "string" && record.description.length > 0) return true; + return [record.anyOf, record.oneOf, record.allOf] + .filter(Array.isArray) + .some((members) => members.some(schemaHasDescription)); +}; + +it("exports provider-compatible object schemas with described parameters", () => { + for (const tool of Object.values(PreviewToolkit.tools)) { + const schema = Tool.getJsonSchema(tool) as { + readonly type?: unknown; + readonly properties?: Readonly>; + readonly anyOf?: unknown; + readonly oneOf?: unknown; + }; + expect( + tool.description?.length ?? 0, + `${tool.name} should have a useful description`, + ).toBeGreaterThan(40); + expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); + expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); + expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); + for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { + expect( + schemaHasDescription(fieldSchema), + `${tool.name}.${field} should explain what data the agent must pass`, + ).toBe(true); + } + } +}); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts new file mode 100644 index 00000000000..fd2fedbb369 --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -0,0 +1,196 @@ +import { + PreviewAutomationClickInput, + PreviewAutomationError, + PreviewAutomationEvaluateInput, + PreviewAutomationNavigateInput, + PreviewAutomationOpenInput, + PreviewAutomationPressInput, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + PreviewAutomationBroker.PreviewAutomationBroker, +]; + +const browserTool = (tool: T): T => + tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; + +const safeBrowserTool = (tool: T): T => + browserTool(tool).annotate(Tool.Destructive, false) as T; + +const readonlyBrowserTool = (tool: T): T => + safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; + +export const PreviewStatusTool = Tool.make("preview_status", { + description: + "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies, +}) + .annotate(Tool.Title, "Get preview status") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + +export const PreviewOpenTool = browserTool( + Tool.make("preview_open", { + description: + "Show and initialize the browser preview for the scoped thread, optionally reusing its current tab and navigating to a URL.", + parameters: PreviewAutomationOpenInput, + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies, + }) + .annotate(Tool.Title, "Open browser preview") + .annotate(Tool.Destructive, false), +); + +export const PreviewNavigateTool = safeBrowserTool( + Tool.make("preview_navigate", { + description: + "Navigate the active collaborative browser tab. Pass {url:'https://t3.chat'} for a website, or {target:{kind:'environment-port',port:5173}} for a dev server in the current environment. Exactly one of url or target is required. Defaults to waiting for page loading to stop.", + parameters: PreviewAutomationNavigateInput, + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Navigate browser preview"), +); + +export const PreviewSnapshotTool = readonlyBrowserTool( + Tool.make("preview_snapshot", { + description: + "Inspect the current page before interacting. Returns URL/title/loading state, visible text, semantic interactive elements with reusable selectors and coordinates, accessibility data, recent console/network failures, action history, and a PNG screenshot.", + success: PreviewAutomationSnapshot, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Inspect browser page"), +); + +export const PreviewClickTool = browserTool( + Tool.make("preview_click", { + description: + "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", + parameters: PreviewAutomationClickInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Click preview page"), +); + +export const PreviewTypeTool = browserTool( + Tool.make("preview_type", { + description: + "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", + parameters: PreviewAutomationTypeInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Type into preview page"), +); + +export const PreviewPressTool = browserTool( + Tool.make("preview_press", { + description: + "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", + parameters: PreviewAutomationPressInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Press key in preview page"), +); + +export const PreviewScrollTool = safeBrowserTool( + Tool.make("preview_scroll", { + description: + "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", + parameters: PreviewAutomationScrollInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Scroll preview page"), +); + +export const PreviewEvaluateTool = browserTool( + Tool.make("preview_evaluate", { + description: + "Evaluate a JavaScript expression in the page's main frame and return a serializable result up to 64 KB. Prefer preview_snapshot and semantic click/type/wait tools; use this for inspection or interactions those tools cannot express. The expression may mutate page state.", + parameters: PreviewAutomationEvaluateInput, + success: Schema.Unknown, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Evaluate JavaScript in preview"), +); + +export const PreviewWaitForTool = readonlyBrowserTool( + Tool.make("preview_wait_for", { + description: + "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", + parameters: PreviewAutomationWaitForInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Wait for preview page condition"), +); + +export const PreviewRecordingStartTool = safeBrowserTool( + Tool.make("preview_recording_start", { + description: + "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", + success: PreviewAutomationRecordingStatus, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Start browser recording"), +); + +export const PreviewRecordingStopTool = safeBrowserTool( + Tool.make("preview_recording_stop", { + description: "Stop the active browser recording and save it as a local evidence artifact.", + success: PreviewAutomationRecordingArtifact, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Stop browser recording"), +); + +export const PreviewToolkit = Toolkit.make( + PreviewStatusTool, + PreviewOpenTool, + PreviewNavigateTool, + PreviewSnapshotTool, + PreviewClickTool, + PreviewTypeTool, + PreviewPressTool, + PreviewScrollTool, + PreviewEvaluateTool, + PreviewWaitForTool, + PreviewRecordingStartTool, + PreviewRecordingStopTool, +); + +export const PreviewStandardToolkit = Toolkit.make( + PreviewStatusTool, + PreviewOpenTool, + PreviewNavigateTool, + PreviewClickTool, + PreviewTypeTool, + PreviewPressTool, + PreviewScrollTool, + PreviewEvaluateTool, + PreviewWaitForTool, + PreviewRecordingStartTool, + PreviewRecordingStopTool, +); + +export const PreviewSnapshotToolkit = Toolkit.make(PreviewSnapshotTool); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 3c9ec2cdfa5..5e36f9f4bab 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -56,7 +56,7 @@ import { } from "../../provider/Services/ProviderService.ts"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; -import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -330,7 +330,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), Layer.provideMerge( - WorkspaceEntriesLive.pipe( + WorkspaceEntries.layer.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistry.layer), ), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 40291ec4f66..48ff133f56d 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -34,7 +34,7 @@ import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; -import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -83,7 +83,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const checkpointStore = yield* CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const appendRevertFailureActivity = (input: { @@ -252,9 +252,9 @@ const make = Effect.gen(function* () { checkpointRef: targetCheckpointRef, }); - // Invalidate the workspace entry cache so the @-mention file picker + // Refresh the workspace entry index so the @-mention file picker // reflects files created or deleted during this turn. - yield* workspaceEntries.invalidate(input.cwd); + yield* workspaceEntries.refresh(input.cwd); const files = yield* checkpointStore .diffCheckpoints({ @@ -690,9 +690,9 @@ const make = Effect.gen(function* () { return; } - // Invalidate the workspace entry cache so the @-mention file picker + // Refresh the workspace entry index so the @-mention file picker // reflects the reverted filesystem state. - yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); + yield* workspaceEntries.refresh(sessionRuntime.value.cwd); const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); if (rolledBackTurns > 0) { diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 93a40ae7e19..bc72758bc71 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,21 +1,15 @@ -import * as NodeOS from "node:os"; -import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { - readPathFromLoginShell, - readEnvironmentFromWindowsShell, - resolveWindowsEnvironment, - type CommandAvailabilityOptions, - type WindowsShellEnvironmentReader, listLoginShellCandidates, mergePathEntries, + readPathFromLoginShell, readPathFromLaunchctl, + resolveWindowsEnvironment, } from "@t3tools/shared/shell"; - -type WindowsCommandAvailabilityChecker = ( - command: string, - options?: CommandAvailabilityOptions, -) => boolean; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as NodeOS from "node:os"; function logPathHydrationWarning(message: string, error?: unknown): void { process.stderr.write( @@ -23,66 +17,60 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } -export function fixPath( - options: { - env?: NodeJS.ProcessEnv; - platform?: NodeJS.Platform; - readPath?: typeof readPathFromLoginShell; - readWindowsEnvironment?: WindowsShellEnvironmentReader; - isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; - readLaunchctlPath?: typeof readPathFromLaunchctl; - userShell?: string; - logWarning?: (message: string, error?: unknown) => void; - } = {}, -): void { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const logWarning = options.logWarning ?? logPathHydrationWarning; - const readPath = options.readPath ?? readPathFromLoginShell; - - try { - if (platform === "win32") { - const repairedEnvironment = resolveWindowsEnvironment(env, { - readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, - ...(options.isWindowsCommandAvailable - ? { commandAvailable: options.isWindowsCommandAvailable } - : {}), - }); - for (const [key, value] of Object.entries(repairedEnvironment)) { - if (value !== undefined) { - env[key] = value; - } - } - return; +function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { + let shellPath: string | undefined; + for (const shell of listLoginShellCandidates(platform, env.SHELL)) { + try { + shellPath = readPathFromLoginShell(shell); + } catch (error) { + logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); } - if (platform !== "darwin" && platform !== "linux") return; + if (shellPath) break; + } - let shellPath: string | undefined; - for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { - try { - shellPath = readPath(shell); - } catch (error) { - logWarning(`Failed to read PATH from login shell ${shell}.`, error); - } + const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; + } +} - if (shellPath) { - break; - } - } +export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< + void, + never, + FileSystem.FileSystem | Path.Path +> { + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; - const launchctlPath = - platform === "darwin" && !shellPath - ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() - : undefined; - const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); - if (mergedPath) { - env.PATH = mergedPath; + if (platform === "win32") { + const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); + return {} as Partial; + }), + ), + ); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } } - } catch (error) { - logWarning("Failed to hydrate PATH from the user environment.", error); + return; } -} + + if (platform !== "darwin" && platform !== "linux") return; + + yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); + }), + ), + ); +}); export const expandHomePath = Effect.fn(function* (input: string) { const { join } = yield* Path.Path; diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts new file mode 100644 index 00000000000..a910e27470d --- /dev/null +++ b/apps/server/src/preview/Manager.test.ts @@ -0,0 +1,260 @@ +import { it } from "@effect/vitest"; +import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; +import { Effect, PubSub } from "effect"; +import { expect } from "vite-plus/test"; + +import * as PreviewManager from "./Manager.ts"; + +const DRAIN_LIMIT = 100; + +interface EventCollector { + /** Drain everything published since the last call (or since subscribe). */ + readonly drain: Effect.Effect>; +} + +/** + * Each `it.effect` shares the live PreviewManager layer across the whole + * `it.layer` block, so tests that assert per-thread counts must use a unique + * thread id to avoid bleeding state from earlier tests. + */ +let nextThreadId = 0; +const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`); + +/** + * Subscribe to the manager's event stream BEFORE the test publishes. We + * use `subscribeEvents` (synchronous PubSub.subscribe under the hood) so + * no event can land between subscribe and the consumer drain. + */ +const collectEvents = Effect.gen(function* () { + const manager = yield* PreviewManager.PreviewManager; + const subscription = yield* manager.subscribeEvents; + const collector: EventCollector = { + drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT), + }; + return collector; +}).pipe(Effect.withSpan("preview.test.collectEvents")); + +it.layer(PreviewManager.layer)("PreviewManager", (it) => { + it.effect("opens a session and emits opened with normalized URL", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const snapshot = yield* manager.open({ threadId, url: "localhost:5173" }); + expect(snapshot.tabId.startsWith("tab_")).toBe(true); + expect(snapshot.navStatus._tag).toBe("Loading"); + if (snapshot.navStatus._tag === "Loading") { + expect(snapshot.navStatus.url).toBe("http://localhost:5173/"); + } + + const events = yield* collector.drain; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("opened"); + if (events[0]?.type === "opened") { + expect(events[0].tabId).toBe(snapshot.tabId); + } + }), + ); + + it.effect("opens an Idle tab when no URL is supplied", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const snapshot = yield* manager.open({ threadId }); + expect(snapshot.navStatus._tag).toBe("Idle"); + }), + ); + + it.effect("treats bare hosts as https", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const snapshot = yield* manager.open({ threadId, url: "example.com" }); + if (snapshot.navStatus._tag === "Loading") { + expect(snapshot.navStatus.url).toBe("https://example.com/"); + } + }), + ); + + it.effect("rejects empty URL with PreviewInvalidUrlError", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const error = yield* Effect.flip(manager.open({ threadId, url: " " })); + expect(error._tag).toBe("PreviewInvalidUrlError"); + }), + ); + + it.effect("navigate updates snapshot and emits navigated", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const snapshot = yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "http://localhost:5173/about", + resolvedTitle: "About", + }); + + expect(snapshot.navStatus._tag).toBe("Success"); + if (snapshot.navStatus._tag === "Success") { + expect(snapshot.navStatus.url).toBe("http://localhost:5173/about"); + expect(snapshot.navStatus.title).toBe("About"); + } + const events = yield* collector.drain; + expect(events.map((e) => e.type)).toEqual(["opened", "navigated"]); + }), + ); + + it.effect("navigate fails for unknown tab", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const error = yield* Effect.flip( + manager.navigate({ + threadId, + tabId: "tab_missing", + url: "http://localhost:5173", + }), + ); + expect(error._tag).toBe("PreviewSessionLookupError"); + }), + ); + + it.effect("reportStatus emits failed for LoadFailed nav", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.reportStatus({ + threadId, + tabId: opened.tabId, + navStatus: { + _tag: "LoadFailed", + url: "http://localhost:5173", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }, + canGoBack: false, + canGoForward: false, + }); + + const events = yield* collector.drain; + const failed = events.find((e) => e.type === "failed"); + expect(failed?.type).toBe("failed"); + if (failed?.type === "failed") { + expect(failed.code).toBe(-105); + expect(failed.description).toBe("ERR_NAME_NOT_RESOLVED"); + } + }), + ); + + it.effect("close removes the session and emits closed", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.close({ threadId }); + + const result = yield* manager.list({ threadId }); + expect(result.sessions).toHaveLength(0); + const events = yield* collector.drain; + const closed = events.find((e) => e.type === "closed"); + expect(closed?.type).toBe("closed"); + }), + ); + + it.effect("close is idempotent for unknown threads", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.close({ threadId }); + const result = yield* manager.list({ threadId }); + expect(result.sessions).toHaveLength(0); + }), + ); + + it.effect("list returns every snapshot for the thread sorted by updatedAt", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const first = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const second = yield* manager.open({ threadId, url: "http://localhost:3000" }); + const result = yield* manager.list({ threadId }); + expect(result.sessions).toHaveLength(2); + const ids = result.sessions.map((s) => s.tabId); + expect(ids).toContain(first.tabId); + expect(ids).toContain(second.tabId); + }), + ); + + it.effect("open creates an independent tab on every call", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const b = yield* manager.open({ threadId, url: "http://localhost:3000/path" }); + + expect(a.tabId).not.toBe(b.tabId); + const list = yield* manager.list({ threadId }); + expect(list.sessions).toHaveLength(2); + + const events = yield* collector.drain; + expect(events.map((e) => e.type)).toEqual(["opened", "opened"]); + }), + ); + + it.effect("close with mismatching tabId is a no-op", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.close({ threadId, tabId: "tab_missing" }); + + const list = yield* manager.list({ threadId }); + expect(list.sessions).toHaveLength(1); + }), + ); + + it.effect("close with explicit tabId removes only that tab", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const b = yield* manager.open({ threadId, url: "http://localhost:3000" }); + + yield* manager.close({ threadId, tabId: a.tabId }); + + const list = yield* manager.list({ threadId }); + expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]); + }), + ); + + it.effect("multiple subscribers receive every event independently", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const aSub = yield* manager.subscribeEvents; + const bSub = yield* manager.subscribeEvents; + + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.open({ threadId, url: "http://localhost:3000" }); + + const aEvents = yield* PubSub.takeUpTo(aSub, DRAIN_LIMIT); + const bEvents = yield* PubSub.takeUpTo(bSub, DRAIN_LIMIT); + expect(aEvents.map((e) => e.type)).toEqual(["opened", "opened"]); + expect(bEvents.map((e) => e.type)).toEqual(["opened", "opened"]); + }), + ); +}); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts new file mode 100644 index 00000000000..8fa3a3668bf --- /dev/null +++ b/apps/server/src/preview/Manager.ts @@ -0,0 +1,362 @@ +/** + * In-memory PreviewManager implementation. + * + * Sessions are keyed by `(threadId, tabId)`; a single thread can host + * multiple tabs (browser-style). `open` always creates a new tab — tab + * lifecycle is owned by the renderer. + * + * Events are published via Effect's `PubSub`, so subscriber failures are + * isolated from the publishing call (a closed WS subscriber queue cannot + * fail an in-progress `navigate()`). + */ +import { + type PreviewCloseInput, + type PreviewEvent, + type PreviewError, + PreviewInvalidUrlError, + type PreviewListInput, + type PreviewListResult, + type PreviewNavigateInput, + type PreviewOpenInput, + type PreviewRefreshInput, + type PreviewReportStatusInput, + PreviewSessionLookupError, + type PreviewSessionSnapshot, +} from "@t3tools/contracts"; +import { + newPreviewTabId, + normalizePreviewUrl, + PreviewUrlNormalizationError, +} from "@t3tools/shared/preview"; +import { + Context, + DateTime, + Effect, + Layer, + PubSub, + type Scope, + Stream, + SynchronizedRef, +} from "effect"; + +export interface PreviewManagerShape { + readonly open: (input: PreviewOpenInput) => Effect.Effect; + readonly navigate: ( + input: PreviewNavigateInput, + ) => Effect.Effect; + readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; + readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; + readonly close: (input: PreviewCloseInput) => Effect.Effect; + readonly list: (input: PreviewListInput) => Effect.Effect; + readonly events: Stream.Stream; + readonly subscribeEvents: Effect.Effect, never, Scope.Scope>; +} + +export class PreviewManager extends Context.Service()( + "t3/preview/Manager/PreviewManager", +) {} + +interface PreviewSessionState { + readonly threadId: string; + readonly tabId: string; + readonly snapshot: PreviewSessionSnapshot; +} + +interface ManagerState { + /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ + readonly sessions: ReadonlyMap; +} + +const initialState: ManagerState = { sessions: new Map() }; + +const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; + +const sessionsForThread = ( + state: ManagerState, + threadId: string, +): ReadonlyArray => { + const out: PreviewSessionState[] = []; + for (const session of state.sessions.values()) { + if (session.threadId === threadId) out.push(session); + } + return out; +}; + +const normalizeUrl = (rawUrl: string): Effect.Effect => + Effect.try({ + try: () => normalizePreviewUrl(rawUrl), + catch: (cause) => + new PreviewInvalidUrlError({ + rawUrl, + detail: + cause instanceof PreviewUrlNormalizationError + ? cause.detail + : cause instanceof Error + ? cause.message + : String(cause), + }), + }); + +const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const buildLoadingSnapshot = (input: { + readonly threadId: string; + readonly tabId: string; + readonly url: string; + readonly title: string; + readonly updatedAt: string; +}): PreviewSessionSnapshot => ({ + threadId: input.threadId, + tabId: input.tabId, + navStatus: { _tag: "Loading", url: input.url, title: input.title }, + canGoBack: false, + canGoForward: false, + updatedAt: input.updatedAt, +}); + +const buildIdleSnapshot = (input: { + readonly threadId: string; + readonly tabId: string; + readonly updatedAt: string; +}): PreviewSessionSnapshot => ({ + threadId: input.threadId, + tabId: input.tabId, + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: input.updatedAt, +}); + +const make = Effect.gen(function* PreviewManagerMake() { + const stateRef = yield* SynchronizedRef.make(initialState); + // Unbounded PubSub is fine here — events are tiny and we don't want to + // block publishers if a subscriber is slow. WS clients backpressure on + // their own queues downstream. + const eventsPubSub = yield* PubSub.unbounded(); + const events: Stream.Stream = Stream.fromPubSub(eventsPubSub); + + /** + * Atomic read-modify-write over the session for `(threadId, tabId)`. The + * mutator runs under the SynchronizedRef so concurrent writers cannot + * interleave. Lookup failures travel through the modify result so both + * branches yield the same `[A, S]` shape `modifyEffect` requires. + * + * The event is published INSIDE the lock so observers see events in the + * same order as the underlying state transitions. Publishing an unbounded + * PubSub is non-blocking, so this is cheap. + */ + const mutateExistingSession = ( + threadId: string, + tabId: string, + mutator: ( + session: PreviewSessionState, + ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>, + ): Effect.Effect => { + type ModifyResult = + | { kind: "fail"; error: PreviewSessionLookupError } + | { kind: "ok"; result: R }; + + return SynchronizedRef.modifyEffect(stateRef, (state) => { + const session = state.sessions.get(compositeKey(threadId, tabId)); + if (!session) { + return Effect.succeed([ + { kind: "fail", error: new PreviewSessionLookupError({ threadId, tabId }) }, + state, + ] as readonly [ModifyResult, ManagerState]); + } + return mutator(session).pipe( + Effect.flatMap( + Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { + if (emit) yield* PubSub.publish(eventsPubSub, emit); + const sessions = new Map(state.sessions); + sessions.set(compositeKey(threadId, tabId), next); + return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [ + ModifyResult, + ManagerState, + ]; + }), + ), + ); + }).pipe( + Effect.flatMap((modify) => + modify.kind === "fail" ? Effect.fail(modify.error) : Effect.succeed(modify.result), + ), + ); + }; + + const open: PreviewManagerShape["open"] = Effect.fn("PreviewManager.open")(function* (input) { + const tabId = newPreviewTabId(); + const updatedAt = yield* currentIsoTimestamp; + const snapshot = input.url + ? buildLoadingSnapshot({ + threadId: input.threadId, + tabId, + url: yield* normalizeUrl(input.url), + title: "", + updatedAt, + }) + : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); + yield* SynchronizedRef.update(stateRef, (state) => { + const sessions = new Map(state.sessions); + sessions.set(compositeKey(input.threadId, tabId), { + threadId: input.threadId, + tabId, + snapshot, + }); + return { sessions }; + }); + yield* PubSub.publish(eventsPubSub, { + type: "opened", + threadId: input.threadId, + tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + return snapshot; + }); + + const navigate: PreviewManagerShape["navigate"] = Effect.fn("PreviewManager.navigate")( + function* (input) { + const url = yield* normalizeUrl(input.url); + return yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.navigateSession")(function* (session) { + const updatedAt = yield* currentIsoTimestamp; + const previousTitle = + session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title; + const resolvedTitle = input.resolvedTitle ?? previousTitle; + const snapshot: PreviewSessionSnapshot = { + threadId: session.threadId, + tabId: session.tabId, + navStatus: { _tag: "Success", url, title: resolvedTitle }, + canGoBack: session.snapshot.canGoBack, + canGoForward: session.snapshot.canGoForward, + updatedAt, + }; + return { + next: { ...session, snapshot }, + emit: { + type: "navigated", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }, + result: snapshot, + }; + }), + ); + }, + ); + + const reportStatus: PreviewManagerShape["reportStatus"] = Effect.fn( + "PreviewManager.reportStatus", + )(function* (input) { + yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.reportSessionStatus")(function* (session) { + const updatedAt = yield* currentIsoTimestamp; + const snapshot: PreviewSessionSnapshot = { + threadId: session.threadId, + tabId: session.tabId, + navStatus: input.navStatus, + canGoBack: input.canGoBack, + canGoForward: input.canGoForward, + updatedAt, + }; + const emit: PreviewEvent = + input.navStatus._tag === "LoadFailed" + ? { + type: "failed", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + url: input.navStatus.url, + title: input.navStatus.title, + code: input.navStatus.code, + description: input.navStatus.description, + } + : { + type: "navigated", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }; + return { + next: { ...session, snapshot }, + emit, + result: undefined as void, + }; + }), + ); + }); + + const refresh: PreviewManagerShape["refresh"] = Effect.fn("PreviewManager.refresh")( + function* (input) { + // Verify the session exists; the desktop bridge handles the actual reload + // and will report progress back via `reportStatus`. No event emitted. + yield* mutateExistingSession(input.threadId, input.tabId, (session) => + Effect.succeed({ next: session, emit: null, result: undefined as void }), + ); + }, + ); + + const close: PreviewManagerShape["close"] = Effect.fn("PreviewManager.close")(function* (input) { + const createdAt = yield* currentIsoTimestamp; + const events = yield* SynchronizedRef.modify(stateRef, (state) => { + const eventsToEmit: PreviewEvent[] = []; + const sessions = new Map(state.sessions); + const targets = input.tabId + ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( + (entry): entry is PreviewSessionState => entry !== undefined, + ) + : sessionsForThread(state, input.threadId); + for (const target of targets) { + sessions.delete(compositeKey(target.threadId, target.tabId)); + eventsToEmit.push({ + type: "closed", + threadId: target.threadId, + tabId: target.tabId, + createdAt, + }); + } + if (eventsToEmit.length === 0) { + return [eventsToEmit, state] as const; + } + return [eventsToEmit, { sessions }] as const; + }); + if (events.length > 0) { + yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { + discard: true, + }); + } + }); + + const list: PreviewManagerShape["list"] = Effect.fn("PreviewManager.list")(function* (input) { + return yield* SynchronizedRef.get(stateRef).pipe( + Effect.map( + (state): PreviewListResult => ({ + sessions: sessionsForThread(state, input.threadId) + .map((s) => s.snapshot) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), + }), + ), + ); + }); + + return { + open, + navigate, + reportStatus, + refresh, + close, + list, + events, + subscribeEvents: PubSub.subscribe(eventsPubSub), + } satisfies PreviewManagerShape; +}).pipe(Effect.withSpan("PreviewManager.make")); + +export const layer = Layer.effect(PreviewManager, make); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts new file mode 100644 index 00000000000..481d28d782f --- /dev/null +++ b/apps/server/src/preview/PortScanner.test.ts @@ -0,0 +1,89 @@ +import * as net from "node:net"; + +import { it as effectIt } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Net from "@t3tools/shared/Net"; +import { Effect, Layer } from "effect"; +import { expect } from "vite-plus/test"; + +import { ProcessRunner } from "../processRunner.ts"; +import * as PortScanner from "./PortScanner.ts"; +const TestProcessRunner = Layer.succeed(ProcessRunner, { + run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), +}); +const TestPortDiscoveryLive = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")), + ), +); + +const openServer = (port: number): Effect.Effect => + Effect.callback((resume) => { + const server = net.createServer(); + server.once("error", () => { + resume(Effect.succeed(null)); + }); + server.listen(port, "127.0.0.1", () => { + resume(Effect.succeed(server)); + }); + return Effect.sync(() => { + server.close(); + }); + }); + +const closeServer = (server: net.Server): Effect.Effect => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + +const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( + ports: ReadonlyArray, +) { + for (const port of ports) { + const server = yield* openServer(port); + if (server !== null) return { port, server }; + } + return yield* Effect.die( + new Error("No common development port was available for the preview scanner test"), + ); +}); + +const commonDevServer = Effect.acquireRelease( + openCommonDevServer(PortScanner.COMMON_DEV_PORTS), + ({ server }) => closeServer(server), +); + +/** + * Integration tests against a real TCP listener. We provide the Windows host + * platform so the tests exercise the TCP-probe fallback without depending on + * `lsof` being installed. + */ +effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { + it.effect( + "scan() returns a server we just opened on a curated dev port", + Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { + const { port } = yield* commonDevServer; + const scanner = yield* PortScanner.PortDiscovery; + const result = yield* scanner.scan(); + const found = result.find((server) => server.port === port); + expect(found).toBeDefined(); + expect(found?.host).toBe("localhost"); + }), + ); + + it.effect( + "retain drives an immediate broadcast to subscribers", + Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { + const { port } = yield* commonDevServer; + const received: number[] = []; + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.subscribe((servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), + ); + yield* scanner.retain; + expect(received).toContain(port); + }), + ); +}); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts new file mode 100644 index 00000000000..183d5d4f009 --- /dev/null +++ b/apps/server/src/preview/PortScanner.ts @@ -0,0 +1,363 @@ +/** + * In-process PortScanner implementation. + * + * macOS/Linux: parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` (-F output is a + * stable line-prefixed field format; this is the only `lsof` flag set we rely + * on). + * + * Windows / lsof missing: checks a curated list of common dev ports through + * the shared Net service. + * + * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber + * polls forever, but each tick is a no-op when the retain count is zero. + */ +import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Net from "@t3tools/shared/Net"; +import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; +import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; + +import { ProcessRunner } from "../processRunner.ts"; + +export interface PortDiscoveryShape { + readonly scan: () => Effect.Effect>; + readonly subscribe: ( + listener: (servers: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect; + readonly retain: Effect.Effect; + readonly registerTerminalProcesses: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray; + }) => Effect.Effect; + readonly unregisterTerminal: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; +} + +export class PortDiscovery extends Context.Service()( + "t3/preview/PortScanner/PortDiscovery", +) {} + +export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ + 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, +]); + +const POLL_INTERVAL = Duration.seconds(3); +const LSOF_TIMEOUT_MS = 5_000; +const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; + +type Listener = (servers: ReadonlyArray) => Effect.Effect; + +interface ScannerState { + readonly lastSnapshot: ReadonlyArray; + readonly listeners: ReadonlySet; + readonly terminalProcesses: ReadonlyMap< + string, + { + readonly owner: TerminalProcessOwner; + readonly processIds: ReadonlySet; + } + >; + readonly retainCount: number; +} + +interface TerminalProcessOwner { + readonly threadId: ThreadId; + readonly terminalId: string; +} + +const terminalOwnerKey = (owner: { + readonly threadId: string; + readonly terminalId: string; +}): string => `${owner.threadId}\u0000${owner.terminalId}`; + +const parseLsofOutput = ( + raw: string, + terminalByProcessId: ReadonlyMap = new Map(), +): ReadonlyArray => { + const seen = new Map(); + let pid: number | null = null; + let processName: string | null = null; + + for (const line of raw.split("\n")) { + if (line.length === 0) continue; + const tag = line.charAt(0); + const value = line.slice(1); + if (tag === "p") { + const parsed = Number.parseInt(value, 10); + pid = Number.isFinite(parsed) && parsed > 0 ? parsed : null; + processName = null; + continue; + } + if (tag === "c") { + processName = value.trim() || null; + continue; + } + if (tag === "n") { + const portMatch = parsePortFromLsofName(value); + if (portMatch == null) continue; + const url = `http://localhost:${portMatch}`; + const key = `localhost:${portMatch}`; + if (seen.has(key)) continue; + seen.set(key, { + host: "localhost", + port: portMatch, + url, + processName, + pid, + terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), + }); + } + } + + return Array.from(seen.values()).toSorted((a, b) => a.port - b.port); +}; + +const parsePortFromLsofName = (name: string): number | null => { + // Examples: "*:5173", "127.0.0.1:5173", "[::1]:5173", "localhost:5173", + // "192.168.1.10:5173 (LISTEN)" — we only care if the host part is local. + const trimmed = name.split(" ", 1)[0]?.trim() ?? ""; + if (trimmed.length === 0) return null; + const lastColon = trimmed.lastIndexOf(":"); + if (lastColon < 0) return null; + const hostPart = trimmed.slice(0, lastColon); + const portPart = trimmed.slice(lastColon + 1); + if (!LSOF_LOCAL_HOST_TOKENS.has(hostPart)) return null; + const port = Number.parseInt(portPart, 10); + if (!Number.isFinite(port) || port <= 0 || port >= 65536) return null; + return port; +}; + +const parseWindowsListenerOutput = ( + raw: string, + terminalByProcessId: ReadonlyMap = new Map(), +): ReadonlyArray => { + const seen = new Map(); + for (const line of raw.split(/\r?\n/g)) { + const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); + const host = hostRaw?.trim() ?? ""; + if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; + const port = Number(portRaw); + const pid = Number(pidRaw); + if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; + const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; + if (seen.has(port)) continue; + seen.set(port, { + host: "localhost", + port, + url: `http://localhost:${port}`, + processName: processNameRaw?.trim() || null, + pid: normalizedPid, + terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + }); + } + return [...seen.values()].toSorted((left, right) => left.port - right.port); +}; + +const serversEqual = ( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean => { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + const a = left[i]; + const b = right[i]; + if (!a || !b) return false; + if ( + a.host !== b.host || + a.port !== b.port || + a.url !== b.url || + a.processName !== b.processName || + a.pid !== b.pid || + a.terminal?.threadId !== b.terminal?.threadId || + a.terminal?.terminalId !== b.terminal?.terminalId + ) { + return false; + } + } + return true; +}; + +const make = Effect.gen(function* PortDiscoveryMake() { + const net = yield* Net.NetService; + const processRunner = yield* ProcessRunner; + const hostPlatform = yield* HostProcessPlatform; + const stateRef = yield* Ref.make({ + lastSnapshot: [], + listeners: new Set(), + terminalProcesses: new Map(), + retainCount: 0, + }); + + const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { + const results = yield* Effect.forEach( + COMMON_DEV_PORTS, + (port) => + net.isPortAvailableOnLoopback(port).pipe( + Effect.map((available) => ({ + port, + listening: !available, + })), + ), + { concurrency: "unbounded" }, + ); + return results + .filter((result) => result.listening) + .map((result) => ({ + host: "localhost", + port: result.port, + url: `http://localhost:${result.port}`, + processName: null, + pid: null, + terminal: null, + })); + }); + + const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { + const state = yield* Ref.get(stateRef); + const terminalByProcessId = new Map(); + for (const registration of state.terminalProcesses.values()) { + for (const processId of registration.processIds) { + terminalByProcessId.set(processId, registration.owner); + } + } + if (hostPlatform === "win32") { + const command = + 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; + const listeners = yield* processRunner + .run({ + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (listeners !== null) return listeners; + return yield* probeCommonPorts(); + } + const lsofResult = yield* processRunner + .run({ + command: "lsof", + args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], + timeout: Duration.millis(LSOF_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (lsofResult !== null) return lsofResult; + return yield* probeCommonPorts(); + }); + + const broadcast = Effect.fn("PortDiscovery.broadcast")(function* ( + servers: ReadonlyArray, + ) { + const listeners = (yield* Ref.get(stateRef)).listeners; + yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true }); + }); + + const pollTick = Effect.fn("PortDiscovery.pollTick")( + function* () { + if ((yield* Ref.get(stateRef)).retainCount <= 0) return; + const next = yield* scanOnce(); + const changed = yield* Ref.modify(stateRef, (state) => + serversEqual(state.lastSnapshot, next) + ? [false, state] + : [true, { ...state, lastSnapshot: next }], + ); + if (changed) yield* broadcast(next); + }, + Effect.catchCause((cause: Cause.Cause) => + Effect.logWarning("preview port scan failed", Cause.pretty(cause)), + ), + ); + + // Single layer-scoped polling fiber. Ticks are no-ops when no client is + // currently retained, so the cost is one Ref.get every POLL_INTERVAL. + yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + + const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () { + const wasIdle = yield* Ref.modify(stateRef, (state) => [ + state.retainCount === 0, + { ...state, retainCount: state.retainCount + 1 }, + ]); + if (wasIdle) { + // Run an immediate scan + broadcast so the new retainer doesn't have + // to wait up to POLL_INTERVAL for the first emission. + yield* pollTick(); + } + }); + + const retain: PortDiscoveryShape["retain"] = Effect.acquireRelease(acquireRetention(), () => + Ref.update(stateRef, (state) => ({ + ...state, + retainCount: Math.max(0, state.retainCount - 1), + })), + ); + + const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")( + (listener) => + Effect.acquireRelease( + Ref.update(stateRef, (state) => ({ + ...state, + listeners: new Set([...state.listeners, listener]), + })), + () => + Ref.update(stateRef, (state) => { + const listeners = new Set(state.listeners); + listeners.delete(listener); + return { ...state, listeners }; + }), + ), + ); + + const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn( + "PortDiscovery.registerTerminalProcesses", + )(function* (input) { + const owner = { + threadId: ThreadId.make(input.threadId), + terminalId: input.terminalId, + }; + const processIds = new Set( + input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), + ); + yield* Ref.update(stateRef, (state) => { + const terminalProcesses = new Map(state.terminalProcesses); + const key = terminalOwnerKey(owner); + if (processIds.size === 0) { + terminalProcesses.delete(key); + } else { + terminalProcesses.set(key, { owner, processIds }); + } + return { ...state, terminalProcesses }; + }); + }); + + const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn( + "PortDiscovery.unregisterTerminal", + )(function* (input) { + yield* Ref.update(stateRef, (state) => { + const terminalProcesses = new Map(state.terminalProcesses); + terminalProcesses.delete(terminalOwnerKey(input)); + return { ...state, terminalProcesses }; + }); + }); + + return { + scan: scanOnce, + subscribe, + retain, + registerTerminalProcesses, + unregisterTerminal, + } satisfies PortDiscoveryShape; +}).pipe(Effect.withSpan("PortDiscovery.make")); + +export const layer = Layer.effect(PortDiscovery, make); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 75e76b5e8e2..0a157e301c4 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,9 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { assertSuccess } from "@effect/vitest/utils"; -import * as Crypto from "effect/Crypto"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -11,24 +9,9 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - isCommandAvailable, - launchBrowser, - launchEditorProcess, - resolveAvailableEditors, - resolveBrowserLaunch, - resolveEditorLaunch, -} from "./externalLauncher.ts"; - -function encodeUtf16LeBase64(input: string): string { - const bytes = new Uint8Array(input.length * 2); - for (let index = 0; index < input.length; index += 1) { - const code = input.charCodeAt(index); - bytes[index * 2] = code & 0xff; - bytes[index * 2 + 1] = code >>> 8; - } - return Encoding.encodeBase64(bytes); -} +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { return ChildProcessSpawner.makeHandle({ @@ -49,756 +32,135 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) { }); } -it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { - it.effect("returns commands for command-based editors", () => - Effect.gen(function* () { - const antigravityLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "antigravity" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(antigravityLaunch, { - command: "agy", - args: ["/tmp/workspace"], - }); - - const cursorLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(cursorLaunch, { - command: "cursor", - args: ["/tmp/workspace"], - }); - - const traeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "trae" }, - "darwin", - ); - assert.deepEqual(traeLaunch, { - command: "trae", - args: ["/tmp/workspace"], - }); - - const kiroLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "kiro" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(kiroLaunch, { - command: "kiro", - args: ["ide", "/tmp/workspace"], - }); - - const vscodeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(vscodeLaunch, { - command: "code", - args: ["/tmp/workspace"], - }); - - const vscodeInsidersLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode-insiders" }, - "darwin", - ); - assert.deepEqual(vscodeInsidersLaunch, { - command: "code-insiders", - args: ["/tmp/workspace"], - }); - - const vscodiumLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscodium" }, - "darwin", - ); - assert.deepEqual(vscodiumLaunch, { - command: "codium", - args: ["/tmp/workspace"], - }); - - const zedLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLaunch, { - command: "zed", - args: ["/tmp/workspace"], - }); - - const ideaLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLaunch, { - command: "idea", - args: ["/tmp/workspace"], - }); - - const aquaLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "aqua" }, - "darwin", - ); - assert.deepEqual(aquaLaunch, { - command: "aqua", - args: ["/tmp/workspace"], - }); - - const clionLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "clion" }, - "darwin", - ); - assert.deepEqual(clionLaunch, { - command: "clion", - args: ["/tmp/workspace"], - }); - - const datagripLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "datagrip" }, - "darwin", - ); - assert.deepEqual(datagripLaunch, { - command: "datagrip", - args: ["/tmp/workspace"], - }); - - const dataspellLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "dataspell" }, - "darwin", - ); - assert.deepEqual(dataspellLaunch, { - command: "dataspell", - args: ["/tmp/workspace"], - }); - - const golandLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "goland" }, - "darwin", - ); - assert.deepEqual(golandLaunch, { - command: "goland", - args: ["/tmp/workspace"], - }); - - const phpstormLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "phpstorm" }, - "darwin", - ); - assert.deepEqual(phpstormLaunch, { - command: "phpstorm", - args: ["/tmp/workspace"], - }); - - const pycharmLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "pycharm" }, - "darwin", - ); - assert.deepEqual(pycharmLaunch, { - command: "pycharm", - args: ["/tmp/workspace"], - }); - - const riderLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rider" }, - "darwin", - ); - assert.deepEqual(riderLaunch, { - command: "rider", - args: ["/tmp/workspace"], - }); - - const rubymineLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rubymine" }, - "darwin", - ); - assert.deepEqual(rubymineLaunch, { - command: "rubymine", - args: ["/tmp/workspace"], - }); - - const rustroverLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rustrover" }, - "darwin", - ); - assert.deepEqual(rustroverLaunch, { - command: "rustrover", - args: ["/tmp/workspace"], - }); - - const webstormLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "webstorm" }, - "darwin", - ); - assert.deepEqual(webstormLaunch, { - command: "webstorm", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("applies launch-style-specific navigation arguments", () => - Effect.gen(function* () { - const lineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(lineOnly, { - command: "cursor", - args: ["--goto", "/tmp/workspace/AGENTS.md:48"], - }); - - const lineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(lineAndColumn, { - command: "cursor", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const traeLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "trae" }, - "darwin", - ); - assert.deepEqual(traeLineAndColumn, { - command: "trae", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const kiroLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "kiro" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(kiroLineAndColumn, { - command: "kiro", - args: ["ide", "--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const vscodeLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(vscodeLineAndColumn, { - command: "code", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode-insiders" }, - "darwin", - ); - assert.deepEqual(vscodeInsidersLineAndColumn, { - command: "code-insiders", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const vscodiumLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscodium" }, - "darwin", - ); - assert.deepEqual(vscodiumLineAndColumn, { - command: "codium", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const zedLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLineAndColumn, { - command: "zed", - args: ["/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const zedLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLineOnly, { - command: "zed", - args: ["/tmp/workspace/AGENTS.md:48"], - }); - - const ideaLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLineOnly, { - command: "idea", - args: ["--line", "48", "/tmp/workspace/AGENTS.md"], - }); - - const ideaLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLineAndColumn, { - command: "idea", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const aquaLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "aqua" }, - "darwin", - ); - assert.deepEqual(aquaLineAndColumn, { - command: "aqua", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const clionLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "clion" }, - "darwin", - ); - assert.deepEqual(clionLineAndColumn, { - command: "clion", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const datagripLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "datagrip" }, - "darwin", - ); - assert.deepEqual(datagripLineAndColumn, { - command: "datagrip", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const dataspellLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "dataspell" }, - "darwin", - ); - assert.deepEqual(dataspellLineAndColumn, { - command: "dataspell", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const golandLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "goland" }, - "darwin", - ); - assert.deepEqual(golandLineAndColumn, { - command: "goland", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const phpstormLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "phpstorm" }, - "darwin", - ); - assert.deepEqual(phpstormLineAndColumn, { - command: "phpstorm", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const pycharmLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "pycharm" }, - "darwin", - ); - assert.deepEqual(pycharmLineAndColumn, { - command: "pycharm", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const riderLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rider" }, - "darwin", - ); - assert.deepEqual(riderLineAndColumn, { - command: "rider", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const rubymineLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rubymine" }, - "darwin", - ); - assert.deepEqual(rubymineLineAndColumn, { - command: "rubymine", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const rustroverLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rustrover" }, - "darwin", - ); - assert.deepEqual(rustroverLineAndColumn, { - command: "rustrover", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const webstormLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "webstorm" }, - "darwin", - ); - assert.deepEqual(webstormLineOnly, { - command: "webstorm", - args: ["--line", "48", "/tmp/workspace/AGENTS.md"], - }); - }), - ); - - it.effect("falls back to zeditor when zed is not installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: dir, - }); - - assert.deepEqual(result, { - command: "zeditor", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("falls back to the primary command when no alias is installed", () => - Effect.gen(function* () { - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: "", - }); - assert.deepEqual(result, { - command: "zed", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("maps file-manager editor to OS open commands", () => - Effect.gen(function* () { - const launch1 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(launch1, { - command: "open", - args: ["/tmp/workspace"], - }); - - const launch2 = yield* resolveEditorLaunch( - { cwd: "C:\\workspace", editor: "file-manager" }, - "win32", - { PATH: "" }, - ); - assert.deepEqual(launch2, { - command: "explorer", - args: ["C:\\workspace"], - }); - - const launch3 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "linux", - { PATH: "" }, - ); - assert.deepEqual(launch3, { - command: "xdg-open", - args: ["/tmp/workspace"], - }); - }), - ); -}); - -it("resolveBrowserLaunch maps default browser launchers by platform", () => { - const target = "https://example.com/some path?name=o'hara"; - - assert.deepEqual(resolveBrowserLaunch(target, "darwin").command, "open"); - assert.deepEqual(resolveBrowserLaunch(target, "darwin").args, [target]); - assert.deepEqual(resolveBrowserLaunch(target, "darwin").options, { - detached: true, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - - assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).command, "xdg-open"); - assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).args, [target]); - - const windows = resolveBrowserLaunch(target, "win32", { - SYSTEMROOT: "C:\\Windows", - }); - assert.equal(windows.command, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); - assert.deepEqual(windows.args, [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - encodeUtf16LeBase64( - "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path?name=o''hara'", +const testLayer = (input: { + readonly platform: NodeJS.Platform; + readonly env?: Record; + readonly resolveExecutable?: (command: string) => string | undefined; + readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; + readonly onUnref?: () => void; +}) => { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + assert.equal(ChildProcess.isStandardCommand(command), true); + if (!ChildProcess.isStandardCommand(command)) { + throw new Error("Expected a standard command"); + } + input.onSpawn?.(command); + return makeMockDetachedHandle(input.onUnref); + }), ), - ]); - assert.deepEqual(windows.options, { - detached: true, - shell: false, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); -}); - -it("resolveBrowserLaunch opens through Windows from WSL when not remote", () => { - const launch = resolveBrowserLaunch("https://example.com", "linux", { - WSL_DISTRO_NAME: "Ubuntu", - }); - assert.equal(launch.command, "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"); - assert.equal(launch.options.detached, true); -}); - -it("resolveBrowserLaunch keeps xdg-open for WSL over SSH", () => { - const launch = resolveBrowserLaunch("https://example.com", "linux", { - WSL_DISTRO_NAME: "Ubuntu", - SSH_CONNECTION: "client server", - }); - assert.equal(launch.command, "xdg-open"); -}); - -it.layer(NodeServices.layer)("launchBrowser", (it) => { - it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.StandardCommand | undefined; - let didUnref = false; - - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { - spawn: (command) => - Effect.sync(() => { - assert.equal(ChildProcess.isStandardCommand(command), true); - if (!ChildProcess.isStandardCommand(command)) { - throw new Error("Expected a standard command"); - } - spawnedCommand = command; - return makeMockDetachedHandle(() => { - didUnref = true; - }); - }), - }); - - const result = yield* launchBrowser("https://example.com").pipe( - Effect.provide(spawnerLayer), - Effect.result, - ); - - assertSuccess(result, undefined); - assert.ok(spawnedCommand); - const expectedLaunch = resolveBrowserLaunch("https://example.com"); - assert.equal(spawnedCommand.command, expectedLaunch.command); - assert.deepEqual(spawnedCommand.args, expectedLaunch.args); - assert.deepEqual(spawnedCommand.options, expectedLaunch.options); - assert.equal(didUnref, true); - }), - ); -}); - -it.layer(NodeServices.layer)("launchEditorProcess", (it) => { - it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.StandardCommand | undefined; - let didUnref = false; - const expectedArgs = ["-e", "process.exit(0)"]; - - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { - spawn: (command) => - Effect.sync(() => { - assert.equal(ChildProcess.isStandardCommand(command), true); - if (!ChildProcess.isStandardCommand(command)) { - throw new Error("Expected a standard command"); - } - spawnedCommand = command; - return makeMockDetachedHandle(() => { - didUnref = true; - }); - }), - }); - - const result = yield* launchEditorProcess({ - command: process.execPath, - args: expectedArgs, - }).pipe(Effect.provide(spawnerLayer), Effect.result); - - assertSuccess(result, undefined); - assert.ok(spawnedCommand); - assert.equal(spawnedCommand.command, process.execPath); - assert.deepEqual( - spawnedCommand.args, - process.platform === "win32" ? expectedArgs.map((arg) => `"${arg}"`) : expectedArgs, - ); - assert.deepEqual(spawnedCommand.options, { - detached: true, - shell: process.platform === "win32", - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - assert.equal(didUnref, true); - }), - ); - - it.effect("rejects when command does not exist", () => - Effect.gen(function* () { - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}); - const result = yield* launchEditorProcess({ - command: `t3code-no-such-command-${yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4), - )}`, - args: [], - }).pipe(Effect.provide(spawnerLayer), Effect.result); - assert.equal(result._tag, "Failure"); - }), - ); -}); - -it.layer(NodeServices.layer)("isCommandAvailable", (it) => { - it.effect("resolves win32 commands with PATHEXT", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), - ); - - it("returns false when a command is not on PATH", () => { - const env = { - PATH: "", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); - }); - - it.effect("does not treat bare files without executable extension as available on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); - }), ); - it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); - }), + return Layer.mergeAll( + ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), + Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed( + SpawnExecutableResolution, + (command) => input.resolveExecutable?.(command) ?? command, + ), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), ); - - it.effect("uses platform-specific PATH delimiter for platform overrides", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); - const env = { - PATH: `${firstDir};${secondDir}`, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), +}; + +it.effect("launches the default browser through the platform command", () => { + let spawned: ChildProcess.StandardCommand | undefined; + let didUnref = false; + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + + yield* launcher.launchBrowser("https://example.com/some path"); + + assert.ok(spawned); + assert.equal(spawned.command, "xdg-open"); + assert.deepEqual(spawned.args, ["https://example.com/some path"]); + assert.equal(spawned.options.detached, true); + assert.equal(didUnref, true); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + onSpawn: (command) => { + spawned = command; + }, + onUnref: () => { + didUnref = true; + }, + }), + ), ); }); -it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { - it.effect("returns installed editors for command launches", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); - const editors = resolveAvailableEditors("win32", { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }); - assert.deepEqual(editors, [ - "trae", - "kiro", - "vscode-insiders", - "vscodium", - "aqua", - "clion", - "datagrip", - "dataspell", - "goland", - "phpstorm", - "pycharm", - "rider", - "rubymine", - "rustrover", - "webstorm", - "file-manager", - ]); - }), - ); - - it.effect("includes zed when only the zeditor command is installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - - const editors = resolveAvailableEditors("linux", { - PATH: dir, - }); - assert.deepEqual(editors, ["zed", "file-manager"]); - }), - ); - - it("omits file-manager when the platform opener is unavailable", () => { - const editors = resolveAvailableEditors("linux", { - PATH: "", - }); - assert.deepEqual(editors, []); - }); -}); +it.effect("launches an installed editor with platform-safe arguments", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + yield* launcher.launchEditor({ + editor: "vscode", + cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"'); + assert.deepEqual(spawned.args, [ + '^"--goto^"', + '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', + ]); + assert.equal(spawned.options.shell, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("discovers editors through the service API", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ), + ); + + assert.equal(editors.includes("vscode"), true); + assert.equal(editors.includes("file-manager"), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rejects unknown editors through the service API", () => + Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + const result = yield* launcher + .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), +); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index da19864dcf8..0b40acef5c0 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -12,12 +12,16 @@ import { type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; -import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; // ============================== @@ -26,8 +30,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export { ExternalLauncherError }; export type { LaunchEditorInput }; -export { isCommandAvailable } from "@t3tools/shared/shell"; - interface EditorLaunch { readonly command: string; readonly args: ReadonlyArray; @@ -61,6 +63,36 @@ const DETACHED_IGNORE_STDIO_OPTIONS = { stderr: "ignore", } as const satisfies ChildProcess.CommandOptions; +const compactEnv = (input: Record>): NodeJS.ProcessEnv => + Object.fromEntries( + Object.entries(input).flatMap(([key, value]) => + Option.match(value, { + onNone: () => [], + onSome: (resolved) => [[key, resolved]], + }), + ), + ); + +const BrowserLaunchEnvConfig = Config.all({ + SYSTEMROOT: Config.string("SYSTEMROOT").pipe(Config.option), + windir: Config.string("windir").pipe(Config.option), + WSL_DISTRO_NAME: Config.string("WSL_DISTRO_NAME").pipe(Config.option), + WSL_INTEROP: Config.string("WSL_INTEROP").pipe(Config.option), + SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), + SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), + container: Config.string("container").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const CommandLookupEnvConfig = Config.all({ + PATH: Config.string("PATH").pipe(Config.option), + Path: Config.string("Path").pipe(Config.option), + path: Config.string("path").pipe(Config.option), + PATHEXT: Config.string("PATHEXT").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); +const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); + function parseTargetPathAndPosition(target: string): Option.Option { const match = TARGET_WITH_POSITION_PATTERN.exec(target); if (!match?.[1] || !match[2]) { @@ -109,17 +141,17 @@ function resolveEditorArgs( return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; } -function resolveAvailableCommand( +const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( commands: ReadonlyArray, - options: CommandAvailabilityOptions = {}, -): Option.Option { + env: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { for (const command of commands) { - if (isCommandAvailable(command, options)) { + if (yield* isCommandAvailable(command, { env })) { return Option.some(command); } } return Option.none(); -} +}); function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); @@ -135,7 +167,7 @@ function escapePowerShellStringLiteral(input: string): string { return `'${input.replaceAll("'", "''")}'`; } -function resolvePowerShellPath(env: NodeJS.ProcessEnv = process.env): string { +function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string { return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; } @@ -145,7 +177,7 @@ function resolveWslPowerShellPath(): string { function shouldUseWindowsBrowserFromWsl( platform: NodeJS.Platform, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = {}, ): boolean { return ( platform === "linux" && @@ -184,10 +216,10 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } -export function resolveBrowserLaunch( +function buildBrowserLaunch( target: string, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = {}, ): ProcessLaunch { if (platform === "darwin") { return { @@ -212,34 +244,49 @@ export function resolveBrowserLaunch( }; } -export function resolveAvailableEditors( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { +const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { const command = fileManagerCommandForPlatform(platform); - if (isCommandAvailable(command, { platform, env })) { + if (yield* isCommandAvailable(command, { env })) { available.push(editor.id); } continue; } - const command = resolveAvailableCommand(editor.commands, { platform, env }); + const command = yield* resolveAvailableCommand(editor.commands, env); if (Option.isSome(command)) { available.push(editor.id); } } return available; -} +}); + +const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( + target: string, +) { + const platform = yield* HostProcessPlatform; + const env = yield* readBrowserLaunchEnv; + return buildBrowserLaunch(target, platform, env); +}); + +const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { + const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; + return yield* buildAvailableEditors(platform, env); +}); /** * ExternalLauncherShape - Service API for browser and editor launch actions. */ export interface ExternalLauncherShape { + readonly resolveAvailableEditors: () => Effect.Effect>; /** * Launch a URL target in the default browser. */ @@ -264,11 +311,11 @@ export class ExternalLauncher extends Context.Service { +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, @@ -281,7 +328,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( if (editorDef.commands) { const command = Option.getOrElse( - resolveAvailableCommand(editorDef.commands, { platform, env }), + yield* resolveAvailableCommand(editorDef.commands, env), () => editorDef.commands[0], ); return { @@ -312,29 +359,35 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( ); }); -export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( +const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( target: string, ): Effect.fn.Return { - return yield* launchAndUnref(resolveBrowserLaunch(target), "Browser auto-open failed"); + const launch = yield* resolveBrowserLaunch(target); + return yield* launchAndUnref(launch, "Browser auto-open failed"); }); -export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( +const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( launch: EditorLaunch, -): Effect.fn.Return { - if (!isCommandAvailable(launch.command)) { +): Effect.fn.Return< + void, + ExternalLauncherError, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const env = yield* readCommandLookupEnv; + if (!(yield* isCommandAvailable(launch.command, { env }))) { return yield* new ExternalLauncherError({ message: `Editor command not found: ${launch.command}`, }); } - const isWin32 = process.platform === "win32"; + const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); yield* launchAndUnref( { - command: launch.command, - args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args], + command: spawnCommand.command, + args: spawnCommand.args, options: { detached: true, - shell: isWin32, + shell: spawnCommand.shell, stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -346,16 +399,29 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const provideCommandResolutionServices = ( + effect: Effect.Effect, + ) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); return { + resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), launchEditor: (input) => - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + provideCommandResolutionServices( + Effect.flatMap(resolveEditorLaunch(input), (launch) => + launchEditorProcess(launch).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), ), ), } satisfies ExternalLauncherShape; diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index fae9ad574cf..f914c667a1c 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -8,6 +8,8 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { isWindowsCommandNotFound, @@ -21,6 +23,9 @@ import { type ChildProcessCommand = { readonly command: string; readonly args: ReadonlyArray; + readonly options: { + readonly shell?: boolean | string; + }; }; // Accesses private properties of ChildProcessCommand for testing purposes @@ -68,7 +73,6 @@ const runWith = Effect.flatMap((runner) => runner.run({ ...input, - shell: input.shell ?? false, }), ), Effect.provide( @@ -123,6 +127,44 @@ describe("runProcess", () => { }).pipe(Effect.provide(layer)); }); + it.effect("resolves and escapes Windows command shims before spawning", () => { + const spawner = makeSpawner((command) => + Effect.sync(() => { + expect(command.command).toBe('^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"'); + expect(command.args).toEqual([ + '^"repos^"', + '^"pr^"', + '^"list^"', + '^"--source-branch^"', + '^"feature^ ^&^ release^"', + ]); + expect(command.options.shell).toBe(true); + return makeHandle({ stdout: "[]" }); + }), + ); + + return runWith(spawner)({ + command: "az", + args: ["repos", "pr", "list", "--source-branch", "feature & release"], + env: { AZURE_CONFIG_DIR: "C:\\Users\\tester\\.azure" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessEnvironment, { + PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }), + Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => + env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && + env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" + ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" + : undefined, + ), + Effect.map((result) => { + expect(result.stdout).toBe("[]"); + }), + ); + }); + it.effect("fails when output exceeds max buffer in default mode", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); @@ -280,19 +322,13 @@ describe("runProcess", () => { }); describe("isWindowsCommandNotFound", () => { - it("matches the localized German cmd.exe error text", () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - - try { - expect( - isWindowsCommandNotFound( - 1, - "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", - ), - ).toBe(true); - } finally { - Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); - } - }); + it.effect("matches the localized German cmd.exe error text", () => + Effect.gen(function* () { + const isCommandNotFound = yield* isWindowsCommandNotFound( + 1, + "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + expect(isCommandNotFound).toBe(true); + }), + ); }); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 45135bf9d2a..4cfb764c557 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -8,6 +8,8 @@ import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectUint8StreamText, type CollectedUint8StreamText, @@ -24,7 +26,6 @@ export interface ProcessRunInput { readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; readonly truncatedMarker?: string | undefined; - readonly shell?: boolean | string | undefined; /** * On timeout, return a synthetic timedOut result. * Partial stdout/stderr are not preserved. @@ -109,11 +110,14 @@ function hasWindowsCommandNotFoundMessage(output: string): boolean { return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); } -export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { - if (process.platform !== "win32") return false; - if (code === 9009) return true; - return hasWindowsCommandNotFoundMessage(stderr); -} +export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsCommandNotFound")( + function* (code: number | null, stderr: string) { + const platform = yield* HostProcessPlatform; + if (platform !== "win32") return false; + if (code === 9009) return true; + return hasWindowsCommandNotFoundMessage(stderr); + }, +); const collectText = Effect.fn("processRunner.collectText")(function* (input: { readonly command: string; @@ -231,18 +235,24 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; + const extendEnv = input.env !== undefined; + const spawnCommand = yield* resolveSpawnCommand( + input.command, + input.args, + input.env === undefined ? {} : { env: input.env, extendEnv }, + ); const child = yield* spawner .spawn( - ChildProcess.make(input.command, [...input.args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), ...(input.env !== undefined ? { env: input.env, - extendEnv: true, + extendEnv, } : {}), - ...(input.shell !== undefined ? { shell: input.shell } : {}), + shell: spawnCommand.shell, }), ) .pipe( diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index c983aca4ba7..5c0e5d95742 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -7,9 +7,10 @@ import * as Path from "effect/Path"; import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; +import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index cdfddd5438a..a994d1a7e8c 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -7,6 +7,7 @@ import { ProjectFaviconResolver, type ProjectFaviconResolverShape, } from "../Services/ProjectFaviconResolver.ts"; +import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts"; // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ @@ -61,28 +62,32 @@ function extractIconHref(source: string): string | null { export const makeProjectFaviconResolver = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; - const resolveIconHref = (projectCwd: string, href: string): string[] => { + const resolveIconHref = (href: string): string[] => { const clean = href.replace(/^\//, ""); - return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; - }; - - const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => { - const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return [path.join("public", clean), clean]; }; const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, - candidates: ReadonlyArray, + relativeCandidates: ReadonlyArray, ): Effect.fn.Return { - for (const candidate of candidates) { - if (!isPathWithinProject(projectCwd, candidate)) { + for (const relativePath of relativeCandidates) { + const candidate = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (!candidate) { continue; } - const stats = yield* fileSystem.stat(candidate).pipe(Effect.orElseSucceed(() => null)); + const stats = yield* fileSystem + .stat(candidate.absolutePath) + .pipe(Effect.orElseSucceed(() => null)); if (stats?.type === "File") { - return candidate; + return candidate.absolutePath; } } return null; @@ -91,18 +96,31 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd: string): Effect.fn.Return { + const projectCwd = yield* workspacePaths + .normalizeWorkspaceRoot(cwd) + .pipe(Effect.orElseSucceed(() => null)); + if (!projectCwd) { + return null; + } for (const candidate of FAVICON_CANDIDATES) { - const resolved = path.join(cwd, candidate); - const existing = yield* findExistingFile(cwd, [resolved]); + const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { return existing; } } for (const sourceFile of ICON_SOURCE_FILES) { - const sourcePath = path.join(cwd, sourceFile); + const sourcePath = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath: sourceFile, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (!sourcePath) { + continue; + } const source = yield* fileSystem - .readFileString(sourcePath) + .readFileString(sourcePath.absolutePath) .pipe(Effect.orElseSucceed(() => null)); if (!source) { continue; @@ -111,7 +129,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { if (!href) { continue; } - const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href)); + const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); if (existing) { return existing; } diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 4fdaa71de22..d4ae073b953 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -89,6 +89,8 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCa const processRunner = yield* ProcessRunner.ProcessRunner; let cacheKey = cwd; + // git is a real executable on every platform — no cmd.exe shell mode, which + // would split paths containing spaces during cmd's re-tokenization. const topLevelResult = yield* processRunner .run({ command: "git", diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 76055f8b8be..b46a4ce1ba3 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,3 +1,14 @@ +const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` + +## T3 Code collaborative browser + +You are running inside T3 Code. The \`t3-code\` MCP server is the product-native collaborative browser shared with the user. When it exposes \`preview_*\` tools, prefer those tools for browser navigation, inspection, interaction, screenshots, and recordings. + +For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. + +Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. +`; + export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -118,6 +129,7 @@ plan content should be human and agent digestible. The final plan must be plan-o Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. +${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} `; export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default @@ -131,4 +143,5 @@ Your active mode changes only when new developer instructions with a different \ The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. +${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} `; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 9a4d1ce9cdf..65c74f9764a 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -16,13 +16,14 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( config: Pick, - baseEnv: NodeJS.ProcessEnv = process.env, + baseEnv?: NodeJS.ProcessEnv, ): Effect.fn.Return { + const resolvedBaseEnv = baseEnv ?? process.env; const homePath = config.homePath.trim(); - if (homePath.length === 0) return baseEnv; + if (homePath.length === 0) return resolvedBaseEnv; const resolvedHomePath = yield* resolveClaudeHomePath(config); return { - ...baseEnv, + ...resolvedBaseEnv, HOME: resolvedHomePath, }; }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index dd5e05b0dbd..95cb249e82c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -69,6 +69,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { @@ -3451,6 +3452,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), }; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -3476,6 +3478,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( env: claudeEnvironment, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), + ...(mcpSession + ? { + mcpServers: { + "t3-code": { + type: "http", + url: mcpSession.endpoint, + headers: { + Authorization: mcpSession.authorizationHeader, + }, + }, + }, + } + : {}), }; yield* Effect.annotateCurrentSpan({ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d5bbc8f6572..d677de7a313 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -18,6 +18,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, @@ -547,7 +548,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { */ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -603,12 +604,15 @@ const probeClaudeCapabilities = ( const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, args: ReadonlyArray, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { + const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { env: claudeEnvironment, - shell: process.platform === "win32", + }); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: claudeEnvironment, + shell: spawnCommand.shell, }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); @@ -618,12 +622,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( resolveCapabilities?: ( claudeSettings: ClaudeSettings, ) => Effect.Effect, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner | Path.Path > { + const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, @@ -648,10 +653,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + const versionProbe = yield* runClaudeCommand( + claudeSettings, + ["--version"], + resolvedEnvironment, + ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 03ff89fe0ea..98f5c4be6be 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -502,6 +502,64 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("labels MCP lifecycle entries with server and tool names", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-complete"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("mcp_1"), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "mcp_1", + server: "t3-code", + tool: "preview_status", + arguments: {}, + durationMs: 12, + error: null, + result: { content: [{ type: "text", text: "attached" }] }, + status: "completed", + }, + }, + }); + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + assert.equal(firstEvent.value.payload.itemType, "mcp_tool_call"); + assert.equal(firstEvent.value.payload.title, "t3-code · preview_status"); + assert.deepStrictEqual(firstEvent.value.payload.data, { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "mcp_1", + server: "t3-code", + tool: "preview_status", + arguments: {}, + durationMs: 12, + error: null, + result: { content: [{ type: "text", text: "attached" }] }, + status: "completed", + }, + }); + }), + ); + it.effect("maps completed plan items to canonical proposed-plan completion events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index b33b8c5e57b..1faacf766af 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -39,6 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterRequestError, @@ -240,7 +241,10 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType return "unknown"; } -function itemTitle(itemType: CanonicalItemType): string | undefined { +function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): string | undefined { + if (itemType === "mcp_tool_call" && item?.type === "mcpToolCall") { + return `${item.server} · ${item.tool}`; + } switch (itemType) { case "assistant_message": return "Assistant message"; @@ -481,7 +485,7 @@ function mapItemLifecycle( payload: { itemType, ...(status ? { status } : {}), - ...(itemTitle(itemType) ? { title: itemTitle(itemType) } : {}), + ...(itemTitle(itemType, item) ? { title: itemTitle(itemType, item) } : {}), ...(detail ? { detail } : {}), ...(event.payload !== undefined ? { data: event.payload } : {}), }, @@ -1428,6 +1432,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, @@ -1443,6 +1448,20 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { model: input.modelSelection.model } : {}), ...(serviceTier ? { serviceTier } : {}), + ...(mcpSession + ? { + environment: { + ...(options?.environment ?? process.env), + T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), + }, + appServerArgs: [ + "-c", + `mcp_servers.t3-code.url=${mcpSession.endpoint}`, + "-c", + 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', + ], + } + : {}), }; const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 89d7421b232..fb2f36f6438 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -7,7 +7,7 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -24,6 +24,7 @@ import type { import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -33,6 +34,8 @@ import { expandHomePath } from "../../pathExpansion.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); +const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; + const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, @@ -292,17 +295,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // "CODEX_HOME points to '~/.codex_work', but that path does not exist". // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; - const clientContext = yield* Layer.build( - CodexClient.layerCommand({ - command: input.binaryPath, - args: ["app-server"], - cwd: input.cwd, - env: { - ...(input.environment ?? process.env), - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }, - }), - ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { + env: environment, + extendEnv: true, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), ); @@ -449,12 +470,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu CodexErrors.CodexAppServerError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > = probeCodexAppServerProvider, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, ChildProcessSpawner.ChildProcessSpawner > { + const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); @@ -480,7 +502,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu homePath: codexSettings.homePath, cwd: process.cwd(), customModels: codexSettings.customModels, - environment, + environment: resolvedEnvironment, }).pipe( Effect.scoped, Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index d2e51139b9f..2d303039856 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -13,6 +13,7 @@ import { } from "../CodexDeveloperInstructions.ts"; import { buildTurnStartParams, + hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, } from "./CodexSessionRuntime.ts"; @@ -149,6 +150,31 @@ describe("buildTurnStartParams", () => { }); }); +describe("T3 browser developer instructions", () => { + it("prefers the product-native preview tools in both collaboration modes", () => { + for (const instructions of [ + CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + ]) { + assert.match(instructions, /t3-code/); + assert.match(instructions, /preview_status/); + assert.match(instructions, /preview_open/); + assert.match(instructions, /Do not switch to global browser skills/); + } + }); +}); + +describe("hasConfiguredMcpServer", () => { + it("detects inline Codex MCP configuration arguments", () => { + assert.equal(hasConfiguredMcpServer(undefined), false); + assert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false); + assert.equal( + hasConfiguredMcpServer(["-c", 'mcp_servers.t3-code.url="http://127.0.0.1/mcp"']), + true, + ); + }); +}); + describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { assert.equal( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 749f03d2989..f156e97daea 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -17,6 +17,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -71,6 +72,10 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "does not exist", ]; +export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray | undefined): boolean { + return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; +} + export const CodexResumeCursorSchema = Schema.Struct({ threadId: Schema.String, }); @@ -112,6 +117,7 @@ export interface CodexSessionRuntimeOptions { readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; + readonly appServerArgs?: ReadonlyArray; } export interface CodexSessionRuntimeSendTurnInput { @@ -732,16 +738,23 @@ export const makeCodexSessionRuntime = ( // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; const env = { - ...(options.environment ?? process.env), + ...options.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; + const extendEnv = options.environment === undefined; + const spawnCommand = yield* resolveSpawnCommand( + options.binaryPath, + ["app-server", ...(options.appServerArgs ?? [])], + { env, extendEnv }, + ); const child = yield* spawner .spawn( - ChildProcess.make(options.binaryPath, ["app-server"], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: options.cwd, env, + extendEnv, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, - shell: process.platform === "win32", + shell: spawnCommand.shell, }), ) .pipe( @@ -1272,6 +1285,15 @@ export const makeCodexSessionRuntime = ( sendTurn: (input) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; + if (hasConfiguredMcpServer(options.appServerArgs)) { + yield* client.request("config/mcpServer/reload", undefined).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { + cause, + }), + ), + ); + } const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 7d067c6ee17..383e9511223 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { ProviderAdapterProcessError, @@ -531,6 +532,7 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, environment: mergeProviderSessionEnvironment(options?.environment, input.env), @@ -538,6 +540,23 @@ export function makeCursorAdapter( cwd, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), ...acpNativeLoggers, }).pipe( Effect.provideService(Scope.Scope, sessionScope), diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index facdb5a5ff1..35d5413714c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -27,6 +27,7 @@ import { getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildBooleanOptionDescriptor, @@ -394,7 +395,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -407,7 +408,7 @@ const makeCursorAcpProbeRuntime = ( "acp", ], cwd: process.cwd(), - env: environment, + ...(environment ? { env: environment } : {}), }, cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, @@ -421,7 +422,7 @@ const makeCursorAcpProbeRuntime = ( const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( Effect.flatMap(useRuntime), @@ -542,7 +543,7 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => withCursorAcpProbeRuntime( cursorSettings, @@ -558,7 +559,7 @@ const discoverCursorModelsViaListAvailableModels = ( export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); export function getCursorFallbackModels( @@ -927,13 +928,18 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult const runCursorCommand = ( cursorSettings: CursorSettings, args: ReadonlyArray, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { - env: environment, - shell: process.platform === "win32", + const spawnCommand = yield* resolveSpawnCommand( + cursorSettings.binaryPath, + args, + environment ? { env: environment } : {}, + ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: spawnCommand.shell, }); const child = yield* spawner.spawn(command); @@ -949,10 +955,7 @@ const runCursorCommand = ( return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); -const runCursorAboutCommand = ( - cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, -) => +const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv) => Effect.gen(function* () { const jsonResult = yield* runCursorCommand( cursorSettings, @@ -967,7 +970,7 @@ const runCursorAboutCommand = ( export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, never, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 6a948e829ff..778dd2c6e0c 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -33,6 +33,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -375,6 +376,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: input.threadId, }); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, environment: mergeProviderSessionEnvironment(options?.environment, input.env), @@ -382,6 +384,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte cwd, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), ...acpNativeLoggers, }).pipe( Effect.provideService(Scope.Scope, sessionScope), diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index bead8b1a407..35611398b4b 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -15,6 +15,7 @@ import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildServerProvider, @@ -149,16 +150,20 @@ const discoverGrokModelsViaAcp = ( const runGrokVersionCommand = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, -) => { - const command = grokSettings.binaryPath || "grok"; - return spawnAndCollect( - command, - ChildProcess.make(command, ["--version"], { +) => + Effect.gen(function* () { + const command = grokSettings.binaryPath || "grok"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { env: environment, - shell: process.platform === "win32", - }), - ); -}; + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index b0c2fdb678e..85ab41f8f3c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -26,6 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { @@ -1054,6 +1055,22 @@ export function makeOpenCodeAdapter( directory, ...(server.external && serverPassword ? { serverPassword } : {}), }); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + if (mcpSession && !server.external) { + yield* runOpenCodeSdk("mcp.add", () => + client.mcp.add({ + name: "t3-code", + config: { + type: "remote", + url: mcpSession.endpoint, + headers: { + Authorization: mcpSession.authorizationHeader, + }, + oauth: false, + }, + }), + ); + } const openCodeSession = yield* runOpenCodeSdk("session.create", () => client.session.create({ title: `T3 Code ${input.threadId}`, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 8842b1da5ce..a8285e960fc 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -301,9 +301,10 @@ export const makePendingOpenCodeProvider = ( export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( openCodeSettings: OpenCodeSettings, cwd: string, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { const openCodeRuntime = yield* OpenCodeRuntime; + const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; @@ -364,7 +365,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu .runOpenCodeCommand({ binaryPath: openCodeSettings.binaryPath, args: ["--version"], - environment, + environment: resolvedEnvironment, }) .pipe( Effect.mapError( @@ -413,7 +414,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath: openCodeSettings.binaryPath, serverUrl: openCodeSettings.serverUrl, - environment, + environment: resolvedEnvironment, }); return yield* openCodeRuntime.loadOpenCodeInventory( openCodeRuntime.createOpenCodeSdkClient({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 56b80f6c4a2..5fe0f903686 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1039,7 +1039,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T // This test intentionally avoids `mockCommandSpawnerLayer` so the real // `probeCodexAppServerProvider` path runs — including the full - // `codex app-server` RPC handshake via `CodexClient.layerCommand`. + // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. // We point `binaryPath` at a name that cannot exist on any machine so // the real `ChildProcessSpawner` deterministically returns ENOENT; the // probe wraps that as `CodexAppServerSpawnError` and @@ -1159,6 +1159,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Effect.gen(function* () { const firstMissing = `t3code_codex_first_`; const secondMissing = `t3code_codex_second_`; + const spawnedCommands: Array = []; const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { @@ -1185,10 +1186,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), - // `it.live` does not inherit layers from the outer `it.layer` - // wrapper, so provide `NodeServices.layer` inline. This is the - // same real `ChildProcessSpawner` + `FileSystem` + `Path` - // services that production uses. + Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => + ChildProcessSpawner.make((command) => { + spawnedCommands.push((command as { readonly command: string }).command); + return spawner.spawn(command); + }), + ), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( @@ -1199,10 +1202,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T const registry = yield* ProviderRegistry; // Boot-time probe: the default codex instance is enabled with // `firstMissing`, so the real spawner yields ENOENT and the - // snapshot should be `status: "error"`. What *distinguishes* - // the two probe runs is `checkedAt` — each probe stamps a - // fresh DateTime, so we capture it and assert it advances - // after the settings mutation. + // snapshot should be `status: "error"`. let initialProviders = yield* registry.getProviders; for ( let attempts = 0; @@ -1220,13 +1220,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); - const initialCheckedAt = initialCodex?.checkedAt; - assert.notStrictEqual(initialCheckedAt, undefined); - - // The rebuilt instance may re-probe synchronously during the - // settings update. Advance the TestClock first so `checkedAt` - // can safely act as the fresh-probe marker this assertion uses. - yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(spawnedCommands, [firstMissing]); // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, @@ -1242,8 +1236,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }, }); - // Poll with TestClock until `checkedAt` advances or we hit a - // generous virtual 3-second ceiling. + // Poll until the injected process boundary observes the new + // executable. This verifies the public settings-to-probe behavior + // without depending on timestamps assigned by TestClock. const refreshed = yield* Effect.gen(function* () { for (let attempts = 0; attempts < 60; attempts += 1) { const providers = yield* registry.getProviders; @@ -1251,7 +1246,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T if ( codex !== undefined && codex.status === "error" && - codex.checkedAt !== initialCheckedAt + spawnedCommands.includes(secondMissing) ) { return providers; } @@ -1262,11 +1257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.notStrictEqual( - reprobedCodex?.checkedAt, - initialCheckedAt, - "Expected a fresh probe after settings change, got the stale snapshot", - ); + assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 91edf37a7cf..72d6f305ca9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -57,6 +57,8 @@ import { import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; const isModelSelection = Schema.is(ModelSelection); /** @@ -213,6 +215,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const directory = yield* ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => + McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( + Effect.tap((credential) => + credential + ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)) + : Effect.void, + ), + ); + const clearMcpSession = (threadId: ThreadId) => + McpSessionRegistry.revokeActiveMcpThread(threadId).pipe( + Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), + ); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Effect.succeed(event).pipe( @@ -384,16 +398,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - const resumed = yield* adapter.startSession({ - threadId: input.binding.threadId, - provider: input.binding.provider, - providerInstanceId: bindingInstanceId, - ...(persistedCwd ? { cwd: persistedCwd } : {}), - ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), - runtimeMode: input.binding.runtimeMode ?? "full-access", - }); + yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); + const resumed = yield* adapter + .startSession({ + threadId: input.binding.threadId, + provider: input.binding.provider, + providerInstanceId: bindingInstanceId, + ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), + ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + runtimeMode: input.binding.runtimeMode ?? "full-access", + }) + .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId))); if (resumed.provider !== adapter.provider) { + yield* clearMcpSession(input.binding.threadId); return yield* toValidationError( input.operation, `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, @@ -573,14 +591,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.cwd.effective": effectiveCwd ?? "", }); const adapter = yield* registry.getByInstance(resolvedInstanceId); - const session = yield* adapter.startSession({ - ...input, - providerInstanceId: resolvedInstanceId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), - }); + yield* prepareMcpSession(threadId, resolvedInstanceId); + const session = yield* adapter + .startSession({ + ...input, + providerInstanceId: resolvedInstanceId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), + }) + .pipe(Effect.onError(() => clearMcpSession(threadId))); if (session.provider !== adapter.provider) { + yield* clearMcpSession(threadId); return yield* toValidationError( "ProviderService.startSession", `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, @@ -872,6 +894,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (routed.isActive) { yield* routed.adapter.stopSession(routed.threadId); } + yield* clearMcpSession(input.threadId); yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, @@ -1043,6 +1066,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); + yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); + McpProviderSession.clearAllMcpProviderSessions(); const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); yield* Effect.forEach(bindings, (binding) => Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 8b2534c1b81..21a95c53b37 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -13,6 +13,7 @@ import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { @@ -48,6 +49,7 @@ export interface AcpSessionRuntimeOptions { readonly version: string; }; readonly authMethodId: string; + readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -201,14 +203,19 @@ const makeAcpSessionRuntime = ( ), ); + const spawnCommand = yield* resolveSpawnCommand( + options.spawn.command, + options.spawn.args, + options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, + ); const child = yield* spawner .spawn( - ChildProcess.make(options.spawn.command, [...options.spawn.args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), ...(options.spawn.env !== undefined ? { env: mergeProviderSessionEnvironment(process.env, options.spawn.env) } : {}), - shell: process.platform === "win32", + shell: spawnCommand.shell, }), ) .pipe( @@ -403,7 +410,7 @@ const makeAcpSessionRuntime = ( const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.LoadSessionRequest; const resumed = yield* runLoggedRequest( "session/load", @@ -416,7 +423,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", @@ -429,7 +436,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 9c48e441032..365884da85d 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -31,6 +31,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -276,13 +278,17 @@ function ensureRuntimeError( const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; + const hostPlatform = yield* HostProcessPlatform; + const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + resolveSpawnCommand(command, args, env ? { env } : {}); const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => Effect.gen(function* () { + const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); const child = yield* spawner.spawn( - ChildProcess.make(input.binaryPath, [...input.args], { - shell: process.platform === "win32", - env: input.environment ?? process.env, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + shell: spawnCommand.shell, + ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); const [stdout, stderr, code] = yield* Effect.all( @@ -290,7 +296,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { { concurrency: "unbounded" }, ); const exitCode = Number(code); - if (isWindowsCommandNotFound(exitCode, stderr)) { + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { return yield* new OpenCodeRuntimeError({ operation: "runOpenCodeCommand", detail: `spawn ${input.binaryPath} ENOENT`, @@ -334,16 +340,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { )); const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); const child = yield* spawner .spawn( - ChildProcess.make(input.binaryPath, args, { - detached: process.platform !== "win32", - shell: process.platform === "win32", + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", + shell: spawnCommand.shell, env: { - ...(input.environment ?? process.env), + ...input.environment, OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, }, + extendEnv: input.environment === undefined, }), ) .pipe( @@ -359,7 +367,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => - process.platform === "win32" + hostPlatform === "win32" ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) : Effect.sync(() => { try { diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 73428f0a445..c4ad2fa7509 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,19 +1,22 @@ // @effect-diagnostics nodeBuiltinImport:off -import { afterEach, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import os from "node:os"; +import * as NodeOS from "node:os"; import path from "node:path"; import { ProviderDriverKind } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import { HttpClient } from "effect/unstable/http"; import { - clearLatestProviderVersionCacheForTests, createProviderVersionAdvisory, makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, normalizeCommandPath, + ProviderVersionCache, + resolveLatestProviderVersion, resolveProviderMaintenanceCapabilitiesEffect, } from "./providerMaintenance.ts"; @@ -21,7 +24,7 @@ const driver = (value: string) => ProviderDriverKind.make(value); const makeTempDir = (name: string) => Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.map((id) => path.join(os.tmpdir(), `${name}-${id}`)), + Effect.map((id) => path.join(NodeOS.tmpdir(), `${name}-${id}`)), ); const isNativeTestCommandPath = (expectedPathSegment: string) => @@ -65,11 +68,33 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( }), ); -afterEach(() => { - clearLatestProviderVersionCacheForTests(); -}); - it.layer(NodeServices.layer)("providerMaintenance", (it) => { + it.effect("reads cached versions through the injectable cache reference", () => + resolveLatestProviderVersion(packageToolUpdate.resolve()).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + [ + "@example/package-tool", + { + expiresAt: Number.MAX_SAFE_INTEGER, + version: "9.9.9", + }, + ], + ]), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => + Effect.die("cached provider version should not make an HTTP request"), + ), + ), + Effect.map((version) => { + expect(version).toBe("9.9.9"); + }), + ), + ); + it("marks providers with unknown current versions as unknown", () => { expect( createProviderVersionAdvisory({ @@ -144,15 +169,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { writeFileSync(packageToolPath, "#!/bin/sh\n"); chmodSync(packageToolPath, 0o755); - expect( - packageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + packageToolUpdate, + { binaryPath: "package-tool", - platform: "darwin", env: { PATH: vitePlusBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", update: { @@ -177,16 +204,18 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { mkdirSync(bunBinDir, { recursive: true }); writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); - expect( - nativePackageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + nativePackageToolUpdate, + { binaryPath: "native-package-tool", - platform: "win32", env: { PATH: bunBinDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { @@ -213,15 +242,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); chmodSync(scopedPackageToolPath, 0o755); - expect( - scopedPackageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + scopedPackageToolUpdate, + { binaryPath: "scoped-package-tool", - platform: "darwin", env: { PATH: pnpmHomeDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { @@ -241,7 +272,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( packageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/package-tool", - platform: "darwin", env: { PATH: "", }, @@ -272,15 +302,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); chmodSync(nativePackageToolPath, 0o755); - expect( - nativePackageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + nativePackageToolUpdate, + { binaryPath: "native-package-tool", - platform: "darwin", env: { PATH: nativeBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { @@ -307,15 +339,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); chmodSync(scopedPackageToolPath, 0o755); - expect( - scopedPackageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + scopedPackageToolUpdate, + { binaryPath: "scoped-package-tool", - platform: "darwin", env: { PATH: nativeBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { @@ -335,7 +369,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( nativePackageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/native-package-tool", - platform: "darwin", env: { PATH: "", }, @@ -359,7 +392,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( scopedPackageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/scoped-package-tool", - platform: "darwin", env: { PATH: "", }, @@ -401,7 +433,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, - platform: "darwin", env: { PATH: "", }, @@ -449,7 +480,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, - platform: "darwin", env: { PATH: "", }, @@ -475,7 +505,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( packageToolUpdate.resolve({ binaryPath: "C:\\Tools\\package-tool\\package-tool.exe", - platform: "win32", env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD", diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 3b0fabf6a99..d1c4a7d6a71 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -5,6 +5,8 @@ import { } from "@t3tools/contracts"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -16,6 +18,25 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; +const compactEnv = (input: Record>): NodeJS.ProcessEnv => + Object.fromEntries( + Object.entries(input).flatMap(([key, value]) => + Option.match(value, { + onNone: () => [], + onSome: (resolved) => [[key, resolved]], + }), + ), + ); + +const CommandLookupEnvConfig = Config.all({ + PATH: Config.string("PATH").pipe(Config.option), + Path: Config.string("Path").pipe(Config.option), + path: Config.string("path").pipe(Config.option), + PATHEXT: Config.string("PATHEXT").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); + export interface ProviderMaintenanceCapabilities { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -32,7 +53,7 @@ export interface ProviderMaintenanceCommandAction { export interface ProviderMaintenanceCapabilityResolutionOptions { readonly binaryPath?: string | null; readonly env?: NodeJS.ProcessEnv; - readonly platform?: NodeJS.Platform; + readonly resolvedCommandPath?: string | null; readonly realCommandPath?: string | null; } @@ -54,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition { } | null; } -interface LatestVersionCacheEntry { +export interface ProviderVersionCacheEntry { readonly expiresAt: number; readonly version: string | null; } -const latestVersionCache = new Map(); +export const ProviderVersionCache = Context.Reference>( + "@t3tools/server/providerMaintenance/ProviderVersionCache", + { + defaultValue: () => new Map(), + }, +); const NpmLatestVersionResponse = Schema.Struct({ version: Schema.optional(Schema.String), }); -export function clearLatestProviderVersionCacheForTests(): void { - latestVersionCache.clear(); -} - function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -251,10 +273,7 @@ export function resolvePackageManagedProviderMaintenance( } const resolvedCommandPath = - resolveCommandPath(binaryPath, { - ...(options?.platform ? { platform: options.platform } : {}), - ...(options?.env ? { env: options.env } : {}), - }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + options?.resolvedCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (resolvedCommandPath) { const commandPaths = [ @@ -335,11 +354,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( return resolver.resolve(options); } + const env = options?.env ?? (yield* readCommandLookupEnv); const resolvedCommandPath = - resolveCommandPath(binaryPath, { - ...(options?.platform ? { platform: options.platform } : {}), - ...(options?.env ? { env: options.env } : {}), - }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + (yield* resolveCommandPath(binaryPath, { env }).pipe( + Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), + )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (!resolvedCommandPath) { return resolver.resolve(options); } @@ -350,6 +369,8 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); return resolver.resolve({ ...options, + env, + resolvedCommandPath, realCommandPath, }); }); @@ -430,6 +451,7 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers return null; } + const latestVersionCache = yield* ProviderVersionCache; const cached = latestVersionCache.get(packageName); const now = DateTime.toEpochMillis(yield* DateTime.now); if (cached && cached.expiresAt > now) { diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 5f5f975a4e3..5ffb69cd5f7 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, assert } from "@effect/vitest"; +import { describe, it, assert } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, @@ -21,8 +21,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { - clearLatestProviderVersionCacheForTests, makeProviderMaintenanceCapabilities, + ProviderVersionCache, type ProviderMaintenanceCapabilities, } from "./providerMaintenance.ts"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); @@ -35,10 +35,6 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); const encoder = new TextEncoder(); -afterEach(() => { - clearLatestProviderVersionCacheForTests(); -}); - function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { if (provider === CURSOR_DRIVER) { return makeProviderMaintenanceCapabilities({ @@ -202,7 +198,12 @@ const makeTestRunner = (registry: ProviderRegistryShape) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( - Layer.provide(Layer.succeed(ProviderRegistry, registry)), + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ProviderVersionCache, new Map()), + ), + ), ), ), ); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index ce43c5e6eab..2ecb3220773 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -75,7 +75,7 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman ); const result: CommandResult = { stdout, stderr, code: exitCode }; - if (isWindowsCommandNotFound(exitCode, stderr)) { + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); } return result; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 95700801954..9458dcc45de 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -17,6 +17,7 @@ import type { RelayAgentActivityState, } from "@t3tools/contracts/relay"; import { CommandId, ProviderInstanceId } from "@t3tools/contracts"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -25,6 +26,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; @@ -525,6 +527,20 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const runFork = Effect.runForkWith(context); const events = yield* Queue.unbounded(); const fetchSeen = yield* Deferred.make(); + const userSpans: Array = []; + const productSpans: Array = []; + const collectingTracer = (spans: Array) => + Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + spans.push(span.name); + }; + return span; + }, + }); const secrets = makeMemorySecretStore(); const now = "2026-05-25T00:00:00.000Z"; const projectId = "project-1" as ProjectId; @@ -652,6 +668,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds")); expect(url.origin).toBe("https://transport.example.test"); + expect(productSpans).toContain("makePublishProof"); + expect(userSpans).not.toContain("makePublishProof"); }).pipe( Effect.provide( AgentAwarenessRelay.layer.pipe( @@ -659,6 +677,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Layer.provideMerge(NodeServices.layer), ), ), + Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), + Effect.withTracer(collectingTracer(userSpans)), ); }), ), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 960f27e752b..d02c83d563e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -12,6 +12,7 @@ import type { } from "@t3tools/contracts"; import { projectThreadAwareness } from "@t3tools/shared/agentAwareness"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { RELAY_ACTIVITY_PUBLISH_TYP, signRelayJwt, @@ -409,6 +410,7 @@ const make = Effect.gen(function* () { }); }), Effect.withSpan("AgentAwarenessRelay.publishThread"), + withRelayClientTracing, ); const publishActiveThreadsUnsafe = Effect.gen(function* () { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2212d05cc07..20f32d0c1b4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -20,6 +20,7 @@ import { type OrchestrationCommand, type OrchestrationEvent, ORCHESTRATION_WS_METHODS, + type PreviewEvent, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -49,7 +50,9 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { FetchHttpClient, @@ -70,7 +73,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; import { CheckpointDiffQuery, type CheckpointDiffQueryShape, @@ -98,6 +100,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; +import * as PreviewManager from "./preview/Manager.ts"; +import * as PortScanner from "./preview/PortScanner.ts"; import { BrowserTraceCollector, type BrowserTraceCollectorShape, @@ -116,7 +120,7 @@ import { ServerEnvironment, type ServerEnvironmentShape, } from "./environment/Services/ServerEnvironment.ts"; -import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -505,7 +509,7 @@ const buildAppUnderTest = (options?: { const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); - const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( + const workspaceEntriesLayer = WorkspaceEntries.layer.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(vcsDriverRegistryLayer), ); @@ -516,7 +520,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePathsLive), Layer.provide(workspaceEntriesLayer), ), - ProjectFaviconResolverLive, + ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), @@ -580,6 +584,7 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), ...options?.layers?.externalLauncher, }), ), @@ -667,6 +672,29 @@ const buildAppUnderTest = (options?: { ...options?.layers?.terminalManager, }), ), + Layer.provide( + Layer.mergeAll( + Layer.mock(PreviewManager.PreviewManager)({ + open: () => Effect.die("PreviewManager not stubbed in this test"), + navigate: () => Effect.die("PreviewManager not stubbed in this test"), + reportStatus: () => Effect.void, + refresh: () => Effect.void, + close: () => Effect.void, + list: () => Effect.succeed({ sessions: [] }), + events: Stream.empty, + subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }), + Layer.mock(PortScanner.PortDiscovery)({ + scan: () => Effect.succeed([]), + subscribe: () => Effect.void, + retain: Effect.void, + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, + }), + ), + ), Layer.provide( Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, @@ -1256,61 +1284,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves project favicon requests before the dev URL redirect", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-", - }); - yield* fileSystem.writeFileString( - path.join(projectDir, "favicon.svg"), - "router-project-favicon", - ); - - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - - assert.equal(response.status, 200); - assert.equal(yield* response.text, "router-project-favicon"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("serves the fallback project favicon when no icon exists", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-fallback-", - }); - - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - - assert.equal(response.status, 200); - assert.include(yield* response.text, 'data-fallback="project-favicon"'); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("serves the public environment descriptor without requiring auth", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3172,28 +3145,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; - const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - }, - }); - const faviconBody = (yield* faviconResponse.json) as { - readonly _tag: string; - readonly code: string; - readonly requiredScope: string; - readonly traceId: string; - }; - assert.equal(overbroadPairingResponse.status, 403); assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); assert.equal(pairingResponse.status, 200); assert.equal(wsTicketResponse.status, 200); - assert.equal(faviconResponse.status, 403); - assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); - assert.equal(faviconBody.code, "insufficient_scope"); - assert.equal(faviconBody.requiredScope, "orchestration:read"); - assert.equal(typeof faviconBody.traceId, "string"); - const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; const rpcError = yield* Effect.flip( Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), @@ -3737,29 +3692,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "does not accept session tokens via query parameters on authenticated HTTP routes", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-query-token-", - }); - - yield* buildAppUnderTest(); - - const { cookie } = yield* bootstrapBrowserSession(); - assert.isDefined(cookie); - const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`, - ); - - assert.equal(response.status, 401); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3830,60 +3762,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves attachment files from state dir", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const attachmentId = "thread-11111111-1111-4111-8111-111111111111"; - - const config = yield* buildAppUnderTest(); - const attachmentPath = resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: `${attachmentId}.bin`, - }); - assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); - yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); - - const response = yield* HttpClient.get(`/attachments/${attachmentId}`, { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }); - assert.equal(response.status, 200); - assert.equal(yield* response.text, "attachment-ok"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("serves attachment files for URL-encoded paths", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const config = yield* buildAppUnderTest(); - const attachmentPath = resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: "thread%20folder/message%20folder/file%20name.png", - }); - assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); - yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok"); - - const response = yield* HttpClient.get( - "/attachments/thread%20folder/message%20folder/file%20name.png", - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - assert.equal(response.status, 200); - assert.equal(yield* response.text, "attachment-encoded-ok"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("proxies browser OTLP trace exports through the server", () => Effect.gen(function* () { const upstreamRequests: Array<{ @@ -4173,22 +4051,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("returns 404 for missing attachment id lookups", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const response = yield* HttpClient.get( - "/attachments/missing-11111111-1111-4111-8111-111111111111", - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - assert.equal(response.status, 404); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { @@ -4497,7 +4359,43 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.isAtLeast(response.entries.length, 1); assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts")); assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-files-" }); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, "src", "index.ts"), + "export const answer = 42;\n", + ); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all({ + listing: client[WS_METHODS.projectsListEntries]({ cwd: workspaceDir }), + file: client[WS_METHODS.projectsReadFile]({ + cwd: workspaceDir, + relativePath: "src/index.ts", + }), + }), + ), + ); + + assert.isTrue(response.listing.entries.some((entry) => entry.path === "src/index.ts")); + assert.deepEqual(response.file, { + relativePath: "src/index.ts", + contents: "export const answer = 42;\n", + byteLength: 26, + truncated: false, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => @@ -4554,7 +4452,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.entries.length, 0); assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); it.effect("routes websocket rpc projects.searchEntries errors", () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 685edebce8f..3d231253240 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -6,9 +6,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { ServerConfig } from "./config.ts"; import { - attachmentsRouteLayer, otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, + assetRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -35,6 +34,11 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; +import * as McpHttpServer from "./mcp/McpHttpServer.ts"; +import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; +import * as PreviewManager from "./preview/Manager.ts"; +import * as PortScanner from "./preview/PortScanner.ts"; +import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; @@ -50,7 +54,7 @@ import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import { ServerSettingsLive } from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; -import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -71,6 +75,7 @@ import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts"; +import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; @@ -161,9 +166,12 @@ const LaunchEnvLayerLive = LaunchEnvLive.pipe( Layer.provideMerge(PersistenceLayerLive), ); +const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); + const TerminalLayerLive = TerminalManagerLive.pipe( Layer.provide(PtyAdapterLive), Layer.provide(LaunchEnvLayerLive), + Layer.provide(PortScannerLayerLive), ); const ReactorLayerLive = Layer.empty.pipe( @@ -250,11 +258,13 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provideMerge(VcsDriverRegistryLayerLive), +const PreviewLayerLive = Layer.empty.pipe( + Layer.provideMerge(PreviewManager.layer), + Layer.provideMerge(PortScannerLayerLive), ); +const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive)); + const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provide(WorkspaceEntriesLayerLive), @@ -266,6 +276,10 @@ const WorkspaceLayerLive = Layer.mergeAll( WorkspaceFileSystemLayerLive, ); +const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe( + Layer.provide(WorkspacePathsLive), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), Layer.provide(ServerSecretStore.layer), @@ -291,7 +305,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(TerminalLayerLive), + Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), @@ -315,7 +329,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), - Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), @@ -344,25 +358,27 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( ); export const makeRoutesLayer = Layer.mergeAll( - HttpApiBuilder.layer(EnvironmentHttpApi).pipe( - Layer.provide(authHttpApiLayer), - Layer.provide(connectHttpApiLayer), - Layer.provide(orchestrationHttpApiLayer), - Layer.provide(serverEnvironmentHttpApiLayer), - Layer.provide(environmentAuthenticatedAuthLayer), + Layer.mergeAll( + HttpApiBuilder.layer(EnvironmentHttpApi).pipe( + Layer.provide(authHttpApiLayer), + Layer.provide(connectHttpApiLayer), + Layer.provide(orchestrationHttpApiLayer), + Layer.provide(serverEnvironmentHttpApiLayer), + Layer.provide(environmentAuthenticatedAuthLayer), + ), + otlpTracesProxyRouteLayer, + assetRouteLayer, + staticAndDevRouteLayer, + websocketRpcRouteLayer, ), - attachmentsRouteLayer, - otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, - staticAndDevRouteLayer, - websocketRpcRouteLayer, + McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; - fixPath(); + yield* fixPath(); const httpListeningLayer = Layer.effectDiscard( Effect.gen(function* () { @@ -488,6 +504,7 @@ export const makeServerLayer = Layer.unwrap( const serverConfigLayer = Layer.succeed(ServerConfig, config); return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive.pipe(Layer.provideMerge(serverConfigLayer))), + Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index e4b64325028..bbc59aaa236 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,4 +1,4 @@ -import { networkInterfaces } from "node:os"; +import * as NodeOS from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; @@ -14,7 +14,7 @@ export interface HeadlessServeAccessInfo { readonly pairingUrl: string; } -type NetworkInterfacesMap = ReturnType; +type NetworkInterfacesMap = ReturnType; export const isLoopbackHost = (host: string | undefined): boolean => { if (!host || host.length === 0) { @@ -45,7 +45,7 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" || export const resolveHeadlessConnectionHost = ( host: string | undefined, - interfaces: NetworkInterfacesMap = networkInterfaces(), + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { if (!host) { return "localhost"; @@ -73,7 +73,7 @@ export const resolveHeadlessConnectionString = ( host: string | undefined, port: number, basePath: NormalizedBasePath = ROOT_BASE_PATH, - interfaces: NetworkInterfacesMap = networkInterfaces(), + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { const connectionHost = resolveHeadlessConnectionHost(host, interfaces); return `http://${formatHostForUrl(connectionHost)}:${port}${basePath}`; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index da04bd0b266..364273a9e1d 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,10 +1,11 @@ +import * as NodeOS from "node:os"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { homedir } from "node:os"; + import { ServerConfig } from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ @@ -39,7 +40,7 @@ const getCodexAccountId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const authJsonPath = path.join(homedir(), ".codex", "auth.json"); + const authJsonPath = path.join(NodeOS.homedir(), ".codex", "auth.json"); const authJson = yield* Effect.flatMap( fileSystem.readFileString(authJsonPath), Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), @@ -52,7 +53,7 @@ const getClaudeUserId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const claudeJsonPath = path.join(homedir(), ".claude.json"); + const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json"); const claudeJson = yield* Effect.flatMap( fileSystem.readFileString(claudeJsonPath), Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 27bf64c7be0..0d51d7c66b1 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -7,10 +7,12 @@ * @module AnalyticsServiceLive */ +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; @@ -37,6 +39,7 @@ const TelemetryEnvConfig = Config.all({ maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( Config.withDefault(1_000), ), + wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option), }); const makeAnalyticsService = Effect.gen(function* () { @@ -46,6 +49,8 @@ const makeAnalyticsService = Effect.gen(function* () { const identifier = yield* getTelemetryIdentifier; const bufferRef = yield* Ref.make>([]); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; const enqueueBufferedEvent = (event: string, properties?: Readonly>) => Effect.flatMap(DateTime.now, (now) => @@ -87,9 +92,9 @@ const makeAnalyticsService = Effect.gen(function* () { properties: { ...event.properties, $process_person_profile: false, - platform: process.platform, - wsl: process.env.WSL_DISTRO_NAME, - arch: process.arch, + platform: hostPlatform, + wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), + arch: hostArchitecture, t3CodeVersion: packageJson.version, clientType, }, diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index 5fde1469193..82ea1dcb9b9 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { PtyAdapter } from "../Services/PTY.ts"; import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; @@ -95,7 +96,8 @@ class BunPtyProcess implements PtyProcess { export const layer = Layer.effect( PtyAdapter, Effect.gen(function* () { - if (process.platform === "win32") { + const platform = yield* HostProcessPlatform; + if (platform === "win32") { return yield* Effect.die( "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", ); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 96038c8d175..a4b56c05de8 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -9,6 +9,7 @@ import { type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -201,11 +202,11 @@ const multiTerminalHistoryLogPath = ( interface CreateManagerOptions { shellResolver?: () => string; - platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; + readonly processIds: ReadonlyArray; }>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -245,7 +246,6 @@ const createManager = ( projectId: ProjectId.make("project-1"), }), ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), - ...(options.platform !== undefined ? { platform: options.platform } : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } @@ -275,12 +275,13 @@ const createManager = ( }), ); +const withHostPlatform = (platform: NodeJS.Platform) => + Layer.succeed(HostProcessPlatform, platform); + it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, )("TerminalManager", (it) => { - const itEffectSkipOnWindows = process.platform === "win32" ? it.effect.skip : it.effect; - it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -420,8 +421,10 @@ it.layer( fs.writeFileString(filePath, contents), ); - itEffectSkipOnWindows("preserves non-notFound cwd stat failures", () => + it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const path = yield* Path.Path; const { manager, baseDir } = yield* createManager(); @@ -751,7 +754,8 @@ it.layer( let inspect: { readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; - } = { hasRunningSubprocess: false, childCommand: null }; + readonly processIds: ReadonlyArray; + } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; const { manager, getEvents } = yield* createManager(5, { subprocessInspector: () => Effect.succeed(inspect), subprocessPollIntervalMs: 20, @@ -760,7 +764,7 @@ it.layer( yield* manager.open(openInput()); expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); - inspect = { hasRunningSubprocess: true, childCommand: "vim" }; + inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; yield* waitFor( Effect.map(getEvents, (events) => events.some( @@ -773,7 +777,7 @@ it.layer( "1200 millis", ); - inspect = { hasRunningSubprocess: false, childCommand: null }; + inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; yield* waitFor( Effect.map(getEvents, (events) => events.some( @@ -794,7 +798,11 @@ it.layer( const { manager } = yield* createManager(5, { subprocessInspector: () => { checks += 1; - return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); + return Effect.succeed({ + hasRunningSubprocess: false, + childCommand: null, + processIds: [], + }); }, subprocessPollIntervalMs: 20, }); @@ -1082,10 +1090,9 @@ it.layer( it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { + const platform = yield* HostProcessPlatform; const missingShell = - process.platform === "win32" - ? "C:\\definitely\\missing-shell.exe" - : "/definitely/missing-shell -l"; + platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; const { manager, ptyAdapter } = yield* createManager(5, { shellResolver: () => missingShell, }); @@ -1096,10 +1103,10 @@ it.layer( assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); expect(ptyAdapter.spawnInputs[0]?.shell).toBe( - process.platform === "win32" ? missingShell : "/definitely/missing-shell", + platform === "win32" ? missingShell : "/definitely/missing-shell", ); - if (process.platform === "win32") { + if (platform === "win32") { expect( ptyAdapter.spawnInputs.some( (input) => @@ -1121,13 +1128,12 @@ it.layer( it.effect("prefers PowerShell over ComSpec for Windows terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { - platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", PATH: "C:\\Windows\\System32", SystemRoot: "C:\\Windows", }, - }); + }).pipe(Effect.provide(withHostPlatform("win32"))); yield* manager.open(openInput()); @@ -1142,15 +1148,16 @@ it.layer( it.effect("falls back to built-in PowerShell by absolute path on Windows", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - platform: "win32", + const ptyAdapter = new FakePtyAdapter(); + const { manager } = yield* createManager(5, { + ptyAdapter, + shellResolver: () => "C:\\missing\\custom-shell.exe", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", PATH: "C:\\Windows\\System32", SystemRoot: "C:\\Windows", }, - shellResolver: () => "C:\\missing\\custom-shell.exe", - }); + }).pipe(Effect.provide(withHostPlatform("win32"))); ptyAdapter.spawnFailures.push( new Error("spawn custom-shell.exe ENOENT"), new Error("spawn pwsh.exe ENOENT"), @@ -1170,46 +1177,25 @@ it.layer( it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { - const originalValues = new Map(); - const setEnv = (key: string, value: string | undefined) => { - if (!originalValues.has(key)) { - originalValues.set(key, process.env[key]); - } - if (value === undefined) { - delete process.env[key]; - return; - } - process.env[key] = value; - }; - const restoreEnv = () => { - for (const [key, value] of originalValues) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; - - setEnv("PORT", "5173"); - setEnv("T3CODE_PORT", "3773"); - setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); - setEnv("TEST_TERMINAL_KEEP", "keep-me"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PORT: "5173", + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://localhost:5173", + TEST_TERMINAL_KEEP: "keep-me", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; - try { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.env.PORT).toBeUndefined(); - expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); - expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); - expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); - } finally { - restoreEnv(); - } + expect(spawnInput.env.PORT).toBeUndefined(); + expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); + expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); + // Arbitrary host env vars must pass through — terminals inherit the + // user's environment apart from the explicit blocklist. + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); }), ); @@ -1237,7 +1223,7 @@ it.layer( it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { - if (process.platform === "win32") return; + if ((yield* HostProcessPlatform) === "win32") return; const { manager, ptyAdapter } = yield* createManager(5, { shellResolver: () => "/bin/zsh", }); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 5822267a6ff..fe00f099d9c 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -13,6 +13,7 @@ import { type TerminalSummary, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isManagedRuntimeEnvKey } from "../../launchEnv/launchEnvUtils.ts"; import { LaunchEnv, type LaunchEnvShape } from "../../launchEnv/Services/LaunchEnv.ts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; @@ -38,6 +39,7 @@ import { terminalSessionsTotal, } from "../../observability/Metrics.ts"; import * as ProcessRunner from "../../processRunner.ts"; +import * as PortScanner from "../../preview/PortScanner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -87,6 +89,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass; } interface TerminalSubprocessInspector { @@ -315,10 +318,7 @@ function enqueueProcessEvent( return true; } -function defaultShellResolver( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): string { +function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { if (platform === "win32") { return "pwsh.exe"; } @@ -327,7 +327,7 @@ function defaultShellResolver( function normalizeShellCommand( value: string | undefined, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, ): string | null { if (!value) return null; const trimmed = value.trim(); @@ -363,7 +363,7 @@ function joinWindowsPath(...parts: ReadonlyArray): string { function shellCandidateFromCommand( command: string | null, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, ): ShellCandidate | null { if (!command || command.length === 0) return null; const shellName = basenameForPlatform(command, platform).toLowerCase(); @@ -414,8 +414,8 @@ function uniqueShellCandidates(candidates: Array): ShellC function resolveShellCandidates( shellResolver: () => string, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, ): ShellCandidate[] { const requested = shellCandidateFromCommand( normalizeShellCommand(shellResolver(), platform), @@ -510,15 +510,14 @@ function windowsInspectSubprocess( TerminalSubprocessCheckError, ProcessRunner.ProcessRunner > { - const command = [ - `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, - "if ($null -eq $c) { exit 1 }", - "Write-Output $c.Name", - "exit 0", - ].join("; "); + const command = + 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; return Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; return yield* processRunner.run({ + // powershell.exe is a real executable — never spawn it through cmd.exe + // shell mode, which would re-tokenize the `-Command` payload (pipes, + // semicolons) before PowerShell ever sees it. command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: "1500 millis", @@ -529,16 +528,41 @@ function windowsInspectSubprocess( }).pipe( Effect.map((result) => { if (result.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null } as const; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; + } + const processNameById = new Map(); + const childrenByParent = new Map(); + for (const line of result.stdout.split(/\r?\n/g)) { + const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const parentPid = Number(parentPidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; + processNameById.set(pid, nameRaw?.trim() ?? ""); + const children = childrenByParent.get(parentPid) ?? []; + children.push(pid); + childrenByParent.set(parentPid, children); } - const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; - if (name.length === 0) { - return { hasRunningSubprocess: true, childCommand: null } as const; + const directChildren = childrenByParent.get(terminalPid) ?? []; + const childPid = directChildren[0]; + if (childPid === undefined) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; } - const normalized = normalizeChildCommandName(name, platform); + const processIds = new Set([terminalPid]); + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const pid of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(pid)) continue; + processIds.add(pid); + pending.push(pid); + } + } + const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], } as const; }), Effect.mapError( @@ -611,14 +635,14 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func if (pgrepResult.value.code === 0) { childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); } else if (pgrepResult.value.code === 1) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } } if (childPid === null) { const psResult = yield* Effect.exit(runPs); if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } for (const line of psResult.value.stdout.split(/\r?\n/g)) { const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); @@ -633,7 +657,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func } if (childPid === null) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } const runComm = processRunner.run({ @@ -668,16 +692,43 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func } const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; + const processIds = new Set([terminalPid]); + const psResult = yield* Effect.exit(runPs); + if (psResult._tag === "Success" && psResult.value.code === 0) { + const childrenByParent = new Map(); + for (const line of psResult.value.stdout.split(/\r?\n/g)) { + const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + const children = childrenByParent.get(ppid) ?? []; + children.push(pid); + childrenByParent.set(ppid, children); + } + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const child of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(child)) continue; + processIds.add(child); + pending.push(child); + } + } + } else { + processIds.add(childPid); + } return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], }; }); function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } if (platform === "win32") { return yield* windowsInspectSubprocess(terminalPid, platform); @@ -937,24 +988,34 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; - platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; subprocessInspector?: TerminalSubprocessInspector; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; launchEnv: LaunchEnvShape; + registerTerminalProcesses?: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray; + }) => Effect.Effect; + unregisterTerminal?: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; } const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; const launchEnv = yield* LaunchEnv; - + const portDiscovery = yield* PortScanner.PortDiscovery; return yield* makeTerminalManagerWithOptions({ logsDir: terminalLogsDir, ptyAdapter, launchEnv, + registerTerminalProcesses: portDiscovery.registerTerminalProcesses, + unregisterTerminal: portDiscovery.unregisterTerminal, }); }); @@ -1049,7 +1110,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const platform = options.platform ?? process.platform; + const platform = yield* HostProcessPlatform; + // Terminals must inherit the user's full environment (minus the blocklist + // applied in createTerminalSpawnEnv) — an allowlist here silently strips + // things like PSModulePath, DISPLAY, proxies, and toolchain variables. + // `options.env` is the test seam. const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; @@ -1064,6 +1129,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; const maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; + const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); + const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); @@ -1592,6 +1659,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } yield* clearKillFiber(action.process); + yield* unregisterTerminal({ + threadId: action.threadId, + terminalId: action.terminalId, + }); yield* publishEvent({ type: "exited", threadId: action.threadId, @@ -1628,6 +1699,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); yield* clearKillFiber(process); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); yield* startKillEscalation(process, session.threadId, session.terminalId); yield* evictInactiveSessionsIfNeeded(); }); @@ -1797,6 +1872,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith advanceEventSequence(session); return [undefined, state] as const; }); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); yield* evictInactiveSessionsIfNeeded(); @@ -1828,6 +1907,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith if (Option.isSome(session)) { yield* stopProcess(session.value); + yield* unregisterTerminal({ threadId, terminalId }); yield* persistHistory(threadId, terminalId, session.value.history); } @@ -1888,6 +1968,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } const next = inspectResult.value; + yield* registerTerminalProcesses({ + threadId: session.threadId, + terminalId: session.terminalId, + processIds: next.processIds, + }); const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; const event = yield* modifyManagerState((state) => { const liveSession: Option.Option = Option.fromNullishOr( diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts index 15d24360f7e..46840214b66 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -1,47 +1,58 @@ -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Effect from "effect/Effect"; -import { assert, it } from "@effect/vitest"; - -import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; - -it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { - it.effect("adds executable bits when helper exists but is not executable", () => - Effect.gen(function* () { - if (process.platform === "win32") return; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o644); - - yield* ensureNodePtySpawnHelperExecutable(helperPath); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); - - it.effect("keeps executable helper as executable", () => - Effect.gen(function* () { - if (process.platform === "win32") return; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o755); - - yield* ensureNodePtySpawnHelperExecutable(helperPath); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); -}); +import { assert, it } from "@effect/vitest"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import { PtyAdapter } from "../Services/PTY.ts"; +import { layer } from "./NodePTY.ts"; + +const spawn = vi.fn(() => ({ + pid: 42, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("node-pty", () => ({ spawn })); + +const testLayer = layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), +); + +it.effect("spawns through the public adapter with the provided host references", () => + Effect.gen(function* () { + const adapter = yield* PtyAdapter; + const process = yield* adapter.spawn({ + shell: "powershell.exe", + args: ["-NoLogo"], + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + }); + + assert.equal(process.pid, 42); + assert.equal(spawn.mock.calls.length, 1); + assert.deepEqual(spawn.mock.calls[0], [ + "powershell.exe", + ["-NoLogo"], + { + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + name: "xterm-color", + }, + ]); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index c81d76f5d1e..2b19fe4ac51 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { PtyAdapter } from "../Services/PTY.ts"; import { PtySpawnError, @@ -18,13 +19,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { const requireForNodePty = createRequire(import.meta.url); const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); const packageDir = path.dirname(packageJsonPath); const candidates = [ path.join(packageDir, "build", "Release", "spawn-helper"), path.join(packageDir, "build", "Debug", "spawn-helper"), - path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), + path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"), ]; for (const candidate of candidates) { @@ -35,16 +38,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { return null; }).pipe(Effect.orElseSucceed(() => null)); -export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { +const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { const fs = yield* FileSystem.FileSystem; - if (process.platform === "win32") return; - if (!explicitPath && didEnsureSpawnHelperExecutable) return; + const platform = yield* HostProcessPlatform; + if (platform === "win32") return; + if (didEnsureSpawnHelperExecutable) return; - const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); + const helperPath = yield* resolveNodePtySpawnHelperPath; if (!helperPath) return; - if (!explicitPath) { - didEnsureSpawnHelperExecutable = true; - } + didEnsureSpawnHelperExecutable = true; if (!(yield* fs.exists(helperPath))) { return; @@ -102,6 +104,8 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; const nodePty = yield* Effect.promise(() => import("node-pty")); @@ -109,6 +113,8 @@ export const layer = Layer.effect( ensureNodePtySpawnHelperExecutable().pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessArchitecture, architecture), Effect.orElseSucceed(() => undefined), ), ); @@ -123,7 +129,7 @@ export const layer = Layer.effect( cols: input.cols, rows: input.rows, env: input.env, - name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + name: platform === "win32" ? "xterm-color" : "xterm-256color", }), catch: (cause) => new PtySpawnError({ diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index c06a0bfc560..91ad90b786e 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -15,6 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape } from "./TextGeneration.ts"; @@ -59,7 +60,7 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); @@ -156,7 +157,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu : undefined; const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { - const command = ChildProcess.make( + const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", [ "-p", @@ -170,15 +171,16 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ...(settingsJson ? ["--settings", settingsJson] : []), "--dangerously-skip-permissions", ], - { - env: claudeEnvironment, - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, + { env: claudeEnvironment }, ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: claudeEnvironment, + cwd, + shell: spawnCommand.shell, + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }); const child = yield* commandSpawner .spawn(command) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index bebd0acf800..80b39af2584 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -9,6 +9,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; @@ -44,12 +45,13 @@ const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); */ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( codexConfig: CodexSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const resolvedEnvironment = environment ?? process.env; type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -180,7 +182,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const serviceTier = getCodexServiceTierOptionValue(modelSelection); - const command = ChildProcess.make( + const spawnCommand = yield* resolveSpawnCommand( codexConfig.binaryPath || "codex", [ "exec", @@ -200,18 +202,19 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], - { - env: { - ...environment, - ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), - }, - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, + { env: resolvedEnvironment }, ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: { + ...resolvedEnvironment, + ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), + }, + cwd, + shell: spawnCommand.shell, + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }); const child = yield* commandSpawner .spawn(command) diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index c4ef1af21d1..6d72178b8ae 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -59,9 +59,10 @@ function isTextGenerationError(error: unknown): error is TextGenerationError { */ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvedEnvironment = environment ?? process.env; const runCursorJson = ({ operation, @@ -84,7 +85,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const outputRef = yield* Ref.make(""); const runtime = yield* makeCursorAcpRuntime({ cursorSettings, - environment, + environment: resolvedEnvironment, childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index b865b2e5ef5..65d3854e945 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -99,10 +99,11 @@ interface SharedOpenCodeTextGenerationServerState { export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( openCodeSettings: OpenCodeSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; + const resolvedEnvironment = environment ?? process.env; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); @@ -208,7 +209,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" openCodeRuntime .startOpenCodeServerProcess({ binaryPath: input.binaryPath, - environment, + environment: resolvedEnvironment, }) .pipe( Effect.provideService(Scope.Scope, serverScope), diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts deleted file mode 100644 index 95d957136b7..00000000000 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ /dev/null @@ -1,538 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as OS from "node:os"; -import fsPromises from "node:fs/promises"; -import type { Dirent } from "node:fs"; - -import * as Cache from "effect/Cache"; -import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; -import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { - insertRankedSearchResult, - normalizeSearchQuery, - scoreQueryMatch, - type RankedSearchResult, -} from "@t3tools/shared/searchRanking"; - -import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; -import { - WorkspaceEntries, - WorkspaceEntriesBrowseError, - WorkspaceEntriesError, - type WorkspaceEntriesShape, -} from "../Services/WorkspaceEntries.ts"; -import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; - -const WORKSPACE_CACHE_TTL_MS = 15_000; -const WORKSPACE_CACHE_MAX_KEYS = 4; -const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; -const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; -const IGNORED_DIRECTORY_NAMES = new Set([ - ".git", - ".convex", - "node_modules", - ".next", - ".turbo", - ".vite-plus", - "dist", - "build", - "out", - ".cache", -]); - -interface WorkspaceIndex { - scannedAt: number; - entries: SearchableWorkspaceEntry[]; - truncated: boolean; -} - -interface SearchableWorkspaceEntry extends ProjectEntry { - normalizedPath: string; - normalizedName: string; -} - -type RankedWorkspaceEntry = RankedSearchResult; - -function toPosixPath(input: string): string { - return input.replaceAll("\\", "/"); -} - -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return OS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); - } - return input; -} - -function parentPathOf(input: string): string | undefined { - const separatorIndex = input.lastIndexOf("/"); - if (separatorIndex === -1) { - return undefined; - } - return input.slice(0, separatorIndex); -} - -function basenameOf(input: string): string { - const separatorIndex = input.lastIndexOf("/"); - if (separatorIndex === -1) { - return input; - } - return input.slice(separatorIndex + 1); -} - -function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { - const normalizedPath = entry.path.toLowerCase(); - return { - ...entry, - normalizedPath, - normalizedName: basenameOf(normalizedPath), - }; -} - -function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { - if (!query) { - return entry.kind === "directory" ? 0 : 1; - } - - const { normalizedPath, normalizedName } = entry; - - const scores = [ - scoreQueryMatch({ - value: normalizedName, - query, - exactBase: 0, - prefixBase: 2, - includesBase: 5, - fuzzyBase: 100, - }), - scoreQueryMatch({ - value: normalizedPath, - query, - exactBase: 1, - prefixBase: 3, - boundaryBase: 4, - includesBase: 6, - fuzzyBase: 200, - boundaryMarkers: ["/"], - }), - ].filter((score): score is number => score !== null); - - if (scores.length === 0) { - return null; - } - - return Math.min(...scores); -} - -function isPathInIgnoredDirectory(relativePath: string): boolean { - const firstSegment = relativePath.split("/")[0]; - if (!firstSegment) return false; - return IGNORED_DIRECTORY_NAMES.has(firstSegment); -} - -function directoryAncestorsOf(relativePath: string): string[] { - const segments = relativePath.split("/").filter((segment) => segment.length > 0); - if (segments.length <= 1) return []; - - const directories: string[] = []; - for (let index = 1; index < segments.length; index += 1) { - directories.push(segments.slice(0, index).join("/")); - } - return directories; -} - -const resolveBrowseTarget = ( - input: FilesystemBrowseInput, - pathService: Path.Path, -): Effect.Effect => - Effect.gen(function* () { - if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { - return yield* new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.resolveBrowseTarget", - detail: "Windows-style paths are only supported on Windows.", - }); - } - - if (!isExplicitRelativePath(input.partialPath)) { - return pathService.resolve(expandHomePath(input.partialPath, pathService)); - } - - if (!input.cwd) { - return yield* new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.resolveBrowseTarget", - detail: "Relative filesystem browse paths require a current project.", - }); - } - - return pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath); - }); - -export const makeWorkspaceEntries = Effect.gen(function* () { - const path = yield* Path.Path; - const vcsRegistry = yield* VcsDriverRegistry; - const workspacePaths = yield* WorkspacePaths; - - const isInsideVcsWorkTree = (cwd: string): Effect.Effect => - vcsRegistry.detect({ cwd }).pipe( - Effect.map((handle) => handle !== null), - Effect.orElseSucceed(() => false), - ); - - const filterVcsIgnoredPaths = ( - cwd: string, - relativePaths: string[], - ): Effect.Effect => - vcsRegistry.detect({ cwd }).pipe( - Effect.flatMap((handle) => - handle - ? handle.driver.filterIgnoredPaths(cwd, relativePaths).pipe( - Effect.map((paths) => [...paths]), - Effect.orElseSucceed(() => relativePaths), - ) - : Effect.succeed(relativePaths), - ), - Effect.orElseSucceed(() => relativePaths), - ); - - const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( - function* (cwd: string) { - const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.orElseSucceed(() => null)); - if (!vcs) { - return null; - } - - const listedFiles = yield* vcs.driver - .listWorkspaceFiles(cwd) - .pipe(Effect.orElseSucceed(() => null)); - - if (!listedFiles) { - return null; - } - - const listedPaths: Array = []; - for (const rawEntry of listedFiles.paths) { - const entry = toPosixPath(rawEntry); - if (entry.length > 0 && !isPathInIgnoredDirectory(entry)) { - listedPaths.push(entry); - } - } - const filePaths = yield* vcs.driver.filterIgnoredPaths(cwd, listedPaths).pipe( - Effect.map((paths) => [...paths]), - Effect.catch(() => filterVcsIgnoredPaths(cwd, listedPaths)), - ); - - const directorySet = new Set(); - for (const filePath of filePaths) { - for (const directoryPath of directoryAncestorsOf(filePath)) { - if (!isPathInIgnoredDirectory(directoryPath)) { - directorySet.add(directoryPath); - } - } - } - - const directoryEntries = [...directorySet] - .toSorted((left, right) => left.localeCompare(right)) - .map( - (directoryPath): ProjectEntry => ({ - path: directoryPath, - kind: "directory", - parentPath: parentPathOf(directoryPath), - }), - ) - .map(toSearchableWorkspaceEntry); - const fileEntries = [...new Set(filePaths)] - .toSorted((left, right) => left.localeCompare(right)) - .map( - (filePath): ProjectEntry => ({ - path: filePath, - kind: "file", - parentPath: parentPathOf(filePath), - }), - ) - .map(toSearchableWorkspaceEntry); - - const now = yield* DateTime.now; - const entries = [...directoryEntries, ...fileEntries]; - return { - scannedAt: now.epochMilliseconds, - entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), - truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, - }; - }, - ); - - const readDirectoryEntries = Effect.fn("WorkspaceEntries.readDirectoryEntries")(function* ( - cwd: string, - relativeDir: string, - ): Effect.fn.Return< - { readonly relativeDir: string; readonly dirents: Dirent[] | null }, - WorkspaceEntriesError - > { - return yield* Effect.tryPromise({ - try: async () => { - const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; - const dirents = await fsPromises.readdir(absoluteDir, { withFileTypes: true }); - return { relativeDir, dirents }; - }, - catch: (cause) => - new WorkspaceEntriesError({ - cwd, - operation: "workspaceEntries.readDirectoryEntries", - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }).pipe( - Effect.catchIf( - () => relativeDir.length > 0, - () => Effect.succeed({ relativeDir, dirents: null }), - ), - ); - }); - - const buildWorkspaceIndexFromFilesystem = Effect.fn( - "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", - )(function* (cwd: string): Effect.fn.Return { - const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd); - - let pendingDirectories: string[] = [""]; - const entries: SearchableWorkspaceEntry[] = []; - let truncated = false; - - while (pendingDirectories.length > 0 && !truncated) { - const currentDirectories = pendingDirectories; - pendingDirectories = []; - - const directoryEntries = yield* Effect.forEach( - currentDirectories, - (relativeDir) => readDirectoryEntries(cwd, relativeDir), - { concurrency: WORKSPACE_SCAN_READDIR_CONCURRENCY }, - ); - - const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { - const { relativeDir, dirents } = directoryEntry; - if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; - - dirents.sort((left, right) => left.name.localeCompare(right.name)); - const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; - for (const dirent of dirents) { - if (!dirent.name || dirent.name === "." || dirent.name === "..") { - continue; - } - if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { - continue; - } - if (!dirent.isDirectory() && !dirent.isFile()) { - continue; - } - - const relativePath = toPosixPath( - relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, - ); - if (isPathInIgnoredDirectory(relativePath)) { - continue; - } - candidates.push({ dirent, relativePath }); - } - return candidates; - }); - - const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => - candidateEntries.map((entry) => entry.relativePath), - ); - const allowedPathSet = shouldFilterWithGitIgnore - ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths)) - : null; - - for (const candidateEntries of candidateEntriesByDirectory) { - for (const candidate of candidateEntries) { - if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { - continue; - } - - const entry = toSearchableWorkspaceEntry({ - path: candidate.relativePath, - kind: candidate.dirent.isDirectory() ? "directory" : "file", - parentPath: parentPathOf(candidate.relativePath), - }); - entries.push(entry); - - if (candidate.dirent.isDirectory()) { - pendingDirectories.push(candidate.relativePath); - } - - if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { - truncated = true; - break; - } - } - - if (truncated) { - break; - } - } - } - - const now = yield* DateTime.now; - return { - scannedAt: now.epochMilliseconds, - entries, - truncated, - }; - }); - - const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( - cwd: string, - ): Effect.fn.Return { - const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd); - if (vcsIndexed) { - return vcsIndexed; - } - return yield* buildWorkspaceIndexFromFilesystem(cwd); - }); - - const workspaceIndexCache = yield* Cache.makeWith( - buildWorkspaceIndex, - { - capacity: WORKSPACE_CACHE_MAX_KEYS, - timeToLive: (exit) => - Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, - }, - ); - - const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( - cwd: string, - ): Effect.fn.Return { - return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd, - operation: "workspaceEntries.normalizeWorkspaceRoot", - detail: cause.message, - cause, - }), - ), - ); - }); - - const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")( - function* (cwd) { - const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( - Effect.orElseSucceed(() => cwd), - ); - yield* Cache.invalidate(workspaceIndexCache, cwd); - if (normalizedCwd !== cwd) { - yield* Cache.invalidate(workspaceIndexCache, normalizedCwd); - } - }, - ); - - const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")( - function* (input) { - const resolvedInputPath = yield* resolveBrowseTarget(input, path); - const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; - const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); - const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); - - const dirents = yield* Effect.tryPromise({ - try: () => fsPromises.readdir(parentPath, { withFileTypes: true }), - catch: (cause) => - new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.browse.readDirectory", - detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, - cause, - }), - }).pipe( - // The user can deny macOS TCC prompts for the target dir (Documents, - // Downloads, Music, etc.); surface an empty listing instead of an - // error so the caller doesn't retry-loop the prompt. - Effect.catchIf( - (error) => { - const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; - return code === "EACCES" || code === "EPERM"; - }, - () => Effect.succeed([]), - ), - ); - - const showHidden = endsWithSeparator || prefix.startsWith("."); - const lowerPrefix = prefix.toLowerCase(); - const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; - for (const dirent of dirents) { - if ( - dirent.isDirectory() && - dirent.name.toLowerCase().startsWith(lowerPrefix) && - (showHidden || !dirent.name.startsWith(".")) - ) { - entries.push({ - name: dirent.name, - fullPath: path.join(parentPath, dirent.name), - }); - } - } - - return { - parentPath, - entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), - }; - }, - ); - - const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( - function* (input) { - const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( - Effect.map((index) => { - const normalizedQuery = normalizeSearchQuery(input.query, { - trimLeadingPattern: /^[@./]+/, - }); - const limit = Math.max(0, Math.floor(input.limit)); - const rankedEntries: RankedWorkspaceEntry[] = []; - let matchedEntryCount = 0; - - for (const entry of index.entries) { - const score = scoreEntry(entry, normalizedQuery); - if (score === null) { - continue; - } - - matchedEntryCount += 1; - insertRankedSearchResult( - rankedEntries, - { item: entry, score, tieBreaker: entry.path }, - limit, - ); - } - - return { - entries: rankedEntries.map((candidate) => candidate.item), - truncated: index.truncated || matchedEntryCount > limit, - }; - }), - ); - }, - ); - - return { - browse, - invalidate, - search, - } satisfies WorkspaceEntriesShape; -}); - -export const WorkspaceEntriesLive = Layer.effect(WorkspaceEntries, makeWorkspaceEntries); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index 9b93b1e863b..5a4ec54686e 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -8,20 +8,19 @@ import * as Path from "effect/Path"; import { ServerConfig } from "../../config.ts"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; -import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; const ProjectLayer = WorkspaceFileSystemLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), ); const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), - Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( @@ -53,7 +52,65 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); -it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { +it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { + describe("readFile", () => { + it.effect("reads UTF-8 files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: "src/index.ts", + }); + + expect(result).toEqual({ + relativePath: "src/index.ts", + contents: "export const answer = 42;\n", + byteLength: 26, + truncated: false, + }); + }), + ); + + it.effect("rejects reads outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "../escape.md" }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + }), + ); + + it.effect("rejects symlinks that resolve outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); + yield* fileSystem.symlink( + path.join(outsideDir, "secret.txt"), + path.join(cwd, "linked-secret.txt"), + ); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "linked-secret.txt" }) + .pipe(Effect.flip); + + expect(error.message).toContain("resolves outside the project root"); + }), + ); + }); + describe("writeFile", () => { it.effect("writes files relative to the workspace root", () => Effect.gen(function* () { @@ -77,20 +134,15 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { it.effect("invalidates workspace entry search cache after writes", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); - const beforeWrite = yield* workspaceEntries.search({ - cwd, - query: "rpc", - limit: 10, - }); - expect(beforeWrite).toEqual({ - entries: [], - truncated: false, - }); + const beforeWrite = yield* workspaceEntries.list({ cwd }); + expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( + false, + ); yield* workspaceFileSystem.writeFile({ cwd, @@ -98,11 +150,7 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { contents: "# Plan\n", }); - const afterWrite = yield* workspaceEntries.search({ - cwd, - query: "rpc", - limit: 10, - }); + const afterWrite = yield* workspaceEntries.list({ cwd }); expect(afterWrite.entries).toEqual( expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), ); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index 9f53ade1bb9..61056042bf3 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off +import fsPromises from "node:fs/promises"; + import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -8,14 +11,76 @@ import { WorkspaceFileSystemError, type WorkspaceFileSystemShape, } from "../Services/WorkspaceFileSystem.ts"; -import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../WorkspaceEntries.ts"; import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; +const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; + export const makeWorkspaceFileSystem = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths; - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + + const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( + function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + const result = yield* Effect.tryPromise({ + try: async () => { + const [realWorkspaceRoot, realTargetPath] = await Promise.all([ + fsPromises.realpath(input.cwd), + fsPromises.realpath(target.absolutePath), + ]); + const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); + if ( + relativeRealPath.startsWith(`..${path.sep}`) || + relativeRealPath === ".." || + path.isAbsolute(relativeRealPath) + ) { + throw new Error("Workspace file path resolves outside the project root."); + } + + const handle = await fsPromises.open(realTargetPath, "r"); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error("Workspace path is not a file."); + } + const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); + const buffer = Buffer.alloc(bytesToRead); + const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); + const fileBytes = buffer.subarray(0, bytesRead); + if (fileBytes.includes(0)) { + throw new Error("Binary files cannot be previewed as text."); + } + const contents = new TextDecoder("utf-8").decode(fileBytes); + return { + relativePath: target.relativePath, + contents, + byteLength: stat.size, + truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, + }; + } finally { + await handle.close(); + } + }, + catch: (cause) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.readFile", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + + return result; + }, + ); const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", @@ -49,10 +114,10 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { }), ), ); - yield* workspaceEntries.invalidate(input.cwd); + yield* workspaceEntries.refresh(input.cwd); return { relativePath: target.relativePath }; }); - return { writeFile } satisfies WorkspaceFileSystemShape; + return { readFile, writeFile } satisfies WorkspaceFileSystemShape; }); export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index f994aa875ef..dfe02e8f67c 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -1,4 +1,4 @@ -import * as OS from "node:os"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -19,10 +19,10 @@ function toPosixRelativePath(input: string): string { function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return OS.homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); + return path.join(NodeOS.homedir(), input.slice(2)); } return input; } diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts deleted file mode 100644 index ba65da04f38..00000000000 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * WorkspaceEntries - Effect service contract for cached workspace entry search. - * - * Owns indexed workspace entry search plus cache invalidation for workspace - * roots when the underlying filesystem changes. - * - * @module WorkspaceEntries - */ -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { - FilesystemBrowseInput, - FilesystemBrowseResult, - ProjectSearchEntriesInput, - ProjectSearchEntriesResult, -} from "@t3tools/contracts"; - -export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesError", - { - cwd: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} - -export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesBrowseError", - { - cwd: Schema.optional(Schema.String), - partialPath: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} - -/** - * WorkspaceEntriesShape - Service API for workspace entry search and cache - * invalidation. - */ -export interface WorkspaceEntriesShape { - /** - * Browse matching directories for the provided partial path. - */ - readonly browse: ( - input: FilesystemBrowseInput, - ) => Effect.Effect; - - /** - * Search indexed workspace entries for files and directories matching the - * provided query. - */ - readonly search: ( - input: ProjectSearchEntriesInput, - ) => Effect.Effect; - - /** - * Drop any cached workspace entries for the given workspace root. - */ - readonly invalidate: (cwd: string) => Effect.Effect; -} - -/** - * WorkspaceEntries - Service tag for cached workspace entry search. - */ -export class WorkspaceEntries extends Context.Service()( - "t3/workspace/Services/WorkspaceEntries", -) {} diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts index 16fcdf0b57f..5126ec417bf 100644 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -10,7 +10,12 @@ import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts"; +import type { + ProjectReadFileInput, + ProjectReadFileResult, + ProjectWriteFileInput, + ProjectWriteFileResult, +} from "@t3tools/contracts"; import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( @@ -22,12 +27,26 @@ export class WorkspaceFileSystemError extends Schema.TaggedErrorClass Effect.Effect< + ProjectReadFileResult, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; + /** * Write a file relative to the workspace root. * diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts similarity index 74% rename from apps/server/src/workspace/Layers/WorkspaceEntries.test.ts rename to apps/server/src/workspace/WorkspaceEntries.test.ts index ffee4d56a52..f8a518d8b33 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,26 +1,24 @@ // @effect-diagnostics nodeBuiltinImport:off import fsPromises from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { FileFinder } from "@ff-labs/fff-node"; import { it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import { ServerConfig } from "../../config.ts"; -import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; -import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "./WorkspacePaths.ts"; +import { ServerConfig } from "../config.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( - Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", @@ -70,20 +68,50 @@ const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; return yield* workspaceEntries.search(input); }); const appendSeparator = (input: string) => - input.endsWith("/") || input.endsWith("\\") - ? input - : `${input}${process.platform === "win32" ? "\\" : "/"}`; + Effect.map(HostProcessPlatform, (platform) => + input.endsWith("/") || input.endsWith("\\") + ? input + : `${input}${platform === "win32" ? "\\" : "/"}`, + ); -it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { +it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { afterEach(() => { vi.restoreAllMocks(); }); + describe("list", () => { + it.effect("returns the complete cached workspace index", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "README.md"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.list({ cwd }); + + expect(result.entries).toEqual( + expect.arrayContaining([ + { path: "src", kind: "directory" }, + { path: "src/components", kind: "directory" }, + { + path: "src/components/Composer.tsx", + kind: "file", + }, + { path: "README.md", kind: "file" }, + ]), + ); + expect(result.entries.some((entry) => entry.path.startsWith("node_modules"))).toBe(false); + expect(result.truncated).toBe(false); + }), + ); + }); + describe("search", () => { it.effect("returns files and directories relative to cwd", () => Effect.gen(function* () { @@ -221,94 +249,39 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); - it.effect("deduplicates concurrent index builds for the same cwd", () => + it.effect("supports typo-resistant file search through fff", () => Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-concurrent-build-" }); + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fff-typo-" }); yield* writeTextFile(cwd, "src/components/Composer.tsx"); - let rootReadCount = 0; - let releaseRootRead: (() => void) | undefined; - const rootReadGate = new Promise((resolve) => { - releaseRootRead = resolve; - }); - const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation((async ( - ...args: Parameters - ) => { - if (args[0] === cwd) { - rootReadCount += 1; - await rootReadGate; - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir); - - const searches = yield* Effect.all( - [ - searchWorkspaceEntries({ cwd, query: "", limit: 100 }), - searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), - searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.forkScoped); - for (let attempt = 0; attempt < 50; attempt += 1) { - if (rootReadCount > 0) { - break; - } - yield* Effect.yieldNow; - } - releaseRootRead?.(); - yield* Fiber.join(searches); - - expect(rootReadCount).toBe(1); + const result = yield* searchWorkspaceEntries({ cwd, query: "compoesr", limit: 10 }); + + expect(result.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "src/components/Composer.tsx" }), + ]), + ); }), ); - it.effect("limits concurrent directory reads while walking the filesystem", () => + it.effect("rebuilds the cached index after refresh fails", () => Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-read-concurrency-" }); - yield* Effect.forEach( - Array.from({ length: 80 }, (_, index) => index), - (index) => writeTextFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"), - { discard: true }, - ); + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-refresh-failure-" }); + yield* writeTextFile(cwd, "src/index.ts", "export {};\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const createSpy = vi.spyOn(FileFinder, "create"); + yield* workspaceEntries.list({ cwd }); + expect(createSpy).toHaveBeenCalledTimes(1); - let activeReads = 0; - let peakReads = 0; - let releaseReads: (() => void) | undefined; - const readsGate = new Promise((resolve) => { - releaseReads = resolve; + vi.spyOn(FileFinder.prototype, "scanFiles").mockReturnValueOnce({ + ok: false, + error: "scan failed", }); - const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation((async ( - ...args: Parameters - ) => { - const target = args[0]; - if (typeof target === "string" && target.startsWith(cwd)) { - activeReads += 1; - peakReads = Math.max(peakReads, activeReads); - await readsGate; - try { - return await originalReaddir(...args); - } finally { - activeReads -= 1; - } - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir); - - const search = yield* searchWorkspaceEntries({ cwd, query: "", limit: 200 }).pipe( - Effect.forkScoped, - ); - for (let attempt = 0; attempt < 50; attempt += 1) { - if (activeReads > 0) { - break; - } - yield* Effect.yieldNow; - } - releaseReads?.(); - yield* Fiber.join(search); - - expect(peakReads).toBeLessThanOrEqual(32); + yield* workspaceEntries.refresh(cwd); + + yield* workspaceEntries.list({ cwd }); + expect(createSpy).toHaveBeenCalledTimes(2); }), ); }); @@ -316,7 +289,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); @@ -339,17 +312,18 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("shows dot directories in directory mode and hidden-prefix mode", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); yield* writeTextFile(cwd, ".config/settings.json", "{}"); yield* writeTextFile(cwd, "config/settings.json", "{}"); + const cwdWithSeparator = yield* appendSeparator(cwd); const directoryResult = yield* workspaceEntries.browse({ - partialPath: appendSeparator(cwd), + partialPath: cwdWithSeparator, }); const hiddenPrefixResult = yield* workspaceEntries.browse({ - partialPath: `${appendSeparator(cwd)}.c`, + partialPath: `${cwdWithSeparator}.c`, }); expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); @@ -362,7 +336,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("supports relative paths when cwd is provided", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); yield* writeTextFile(cwd, "packages/pkg.json", "{}"); @@ -381,7 +355,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("rejects relative paths without cwd", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const error = yield* workspaceEntries .browse({ @@ -395,14 +369,14 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("returns an empty listing when the OS denies directory access", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); const result = yield* workspaceEntries.browse({ - partialPath: appendSeparator(cwd), + partialPath: yield* appendSeparator(cwd), }); expect(result).toEqual({ parentPath: cwd, entries: [] }); }), diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts new file mode 100644 index 00000000000..bf9a51c74db --- /dev/null +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -0,0 +1,252 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as RcMap from "effect/RcMap"; +import * as Schema from "effect/Schema"; + +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + ProjectListEntriesInput, + ProjectListEntriesResult, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; + +import * as WorkspacePaths from "./Services/WorkspacePaths.ts"; +import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; + +export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesError", + { + cwd: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesBrowseError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class WorkspaceEntries extends Context.Service< + WorkspaceEntries, + { + readonly browse: ( + input: FilesystemBrowseInput, + ) => Effect.Effect; + readonly list: ( + input: ProjectListEntriesInput, + ) => Effect.Effect; + readonly search: ( + input: ProjectSearchEntriesInput, + ) => Effect.Effect; + readonly refresh: (cwd: string) => Effect.Effect; + } +>()("t3/workspace/WorkspaceEntries") {} + +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return NodeOS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(NodeOS.homedir(), input.slice(2)); + } + return input; +} + +const resolveBrowseTarget = ( + input: FilesystemBrowseInput, + path: Path.Path, +): Effect.Effect => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Windows-style paths are only supported on Windows.", + }); + } + + if (!isExplicitRelativePath(input.partialPath)) { + return path.resolve(expandHomePath(input.partialPath, path)); + } + + if (!input.cwd) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Relative filesystem browse paths require a current project.", + }); + } + + return path.resolve(expandHomePath(input.cwd, path), input.partialPath); + }); + +const make = Effect.gen(function* () { + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; + + const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( + cwd: string, + ): Effect.fn.Return { + return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( + Effect.mapError( + (cause) => + new WorkspaceEntriesError({ + cwd, + operation: "workspaceEntries.normalizeWorkspaceRoot", + detail: cause.message, + cause, + }), + ), + ); + }); + + const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( + function* (cwd) { + const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( + Effect.orElseSucceed(() => cwd), + ); + if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { + return; + } + yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + yield* searchIndex.refresh(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), + Effect.catch((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + cause, + }); + yield* workspaceSearchIndexes.invalidate(normalizedCwd); + }), + ), + ); + }, + ); + + const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( + function* (input) { + const resolvedInputPath = yield* resolveBrowseTarget(input, path); + const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; + const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); + const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + + const dirents = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.browse.readDirectory", + detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }).pipe( + Effect.catchIf( + (error) => { + const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; + return code === "EACCES" || code === "EPERM"; + }, + () => Effect.succeed([]), + ), + ); + + const showHidden = endsWithSeparator || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; + for (const dirent of dirents) { + if ( + dirent.isDirectory() && + dirent.name.toLowerCase().startsWith(lowerPrefix) && + (showHidden || !dirent.name.startsWith(".")) + ) { + entries.push({ + name: dirent.name, + fullPath: path.join(parentPath, dirent.name), + }); + } + } + + return { + parentPath, + entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), + }; + }, + ); + + const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( + function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + const normalizedQuery = input.query + .trim() + .toLowerCase() + .replace(/^[@./]+/, ""); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.search(normalizedQuery, input.limit); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), + Effect.mapError( + (cause) => + new WorkspaceEntriesError({ + cwd: input.cwd, + operation: "workspaceEntries.search", + detail: cause.message, + cause, + }), + ), + ); + }, + ); + + const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( + function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.list(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), + Effect.mapError( + (cause) => + new WorkspaceEntriesError({ + cwd: input.cwd, + operation: "workspaceEntries.list", + detail: cause.message, + cause, + }), + ), + ); + }, + ); + + return WorkspaceEntries.of({ browse, list, refresh, search }); +}); + +export const layer = Layer.effect(WorkspaceEntries, make).pipe( + Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts new file mode 100644 index 00000000000..4bee3cbc089 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -0,0 +1,245 @@ +import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as LayerMap from "effect/LayerMap"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; + +import type { + ProjectEntry, + ProjectListEntriesResult, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; + +const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; +const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; +const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; +const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; +const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; + +export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexCreateFailed", + { + cwd: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return `Failed to create the workspace search index for '${this.cwd}': ${this.reason}`; + } +} + +export class WorkspaceSearchIndexScanTimedOut extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexScanTimedOut", + { + cwd: Schema.String, + timeout: Schema.String, + }, +) { + override get message(): string { + return `Workspace search index for '${this.cwd}' did not finish scanning within ${this.timeout}`; + } +} + +export class WorkspaceSearchIndexSearchFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexSearchFailed", + { + cwd: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return `Workspace search failed for '${this.cwd}': ${this.reason}`; + } +} + +export class WorkspaceSearchIndexRefreshFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexRefreshFailed", + { + cwd: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return `Failed to refresh the workspace search index for '${this.cwd}': ${this.reason}`; + } +} + +export type WorkspaceSearchIndexError = + | WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndexSearchFailed + | WorkspaceSearchIndexRefreshFailed; + +export class WorkspaceSearchIndex extends Context.Service< + WorkspaceSearchIndex, + { + readonly list: () => Effect.Effect; + readonly search: ( + query: string, + limit: number, + ) => Effect.Effect; + readonly refresh: () => Effect.Effect< + void, + WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut + >; + } +>()("t3/workspace/WorkspaceSearchIndex") {} + +function toPosixPath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function trimDirectorySeparator(input: string): string { + return input.endsWith("/") ? input.slice(0, -1) : input; +} + +function parentPathOf(input: string): string | undefined { + const separatorIndex = input.lastIndexOf("/"); + return separatorIndex === -1 ? undefined : input.slice(0, separatorIndex); +} + +function toProjectEntry(item: MixedItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.item.relativePath)); + if (!normalizedPath) { + return null; + } + + return { + path: normalizedPath, + kind: item.type, + }; +} + +function mapMixedSearchResult( + result: MixedSearchResult, + limit: number, +): { readonly entries: ProjectEntry[]; readonly truncated: boolean } { + const entries: ProjectEntry[] = []; + for (const item of result.items) { + const entry = toProjectEntry(item); + if (entry) { + entries.push(entry); + } + if (entries.length >= limit) { + break; + } + } + + const rootDirectoryCount = result.items.some( + (item) => item.type === "directory" && item.item.relativePath.length === 0, + ) + ? 1 + : 0; + return { + entries, + truncated: result.totalMatched - rootDirectoryCount > limit, + }; +} + +function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { + const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); + for (const entry of entries) { + let parentPath = parentPathOf(entry.path); + while (parentPath) { + if (!entryByPath.has(parentPath)) { + entryByPath.set(parentPath, { path: parentPath, kind: "directory" }); + } + parentPath = parentPathOf(parentPath); + } + } + return [...entryByPath.values()]; +} + +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { + const result = FileFinder.create({ + basePath: cwd, + disableMmapCache: true, + disableContentIndexing: true, + aiMode: false, + enableFsRootScanning: true, + enableHomeDirScanning: true, + }); + if (result.ok) return result.value; + return yield* new WorkspaceSearchIndexCreateFailed({ cwd, reason: result.error }); +}); + +const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( + cwd: string, + finder: FileFinder, +) { + yield* Effect.sync(() => finder.isScanning()).pipe( + Effect.repeat({ + while: (scanning) => scanning, + schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), + }), + Effect.timeoutOrElse({ + duration: WORKSPACE_INDEX_SCAN_TIMEOUT, + orElse: () => + new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), + }), + ); +}); + +const makeWorkspaceSearchIndex = (cwd: string) => + Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy())).pipe( + Effect.tap((finder) => waitForScan(cwd, finder)), + Effect.map((finder) => { + const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( + query: string, + pageSize: number, + ) { + const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); + if (!result.ok) { + return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); + } + return result.value; + }); + + const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( + "WorkspaceSearchIndex.refresh", + )(function* () { + const result = yield* Effect.sync(() => finder.scanFiles()); + if (!result.ok) { + return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); + } + yield* waitForScan(cwd, finder); + }); + + const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( + function* () { + const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); + const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); + const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => + left.path.localeCompare(right.path), + ); + const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); + return { + entries, + truncated: mapped.truncated || entries.length < sortedEntries.length, + }; + }, + ); + + const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( + "WorkspaceSearchIndex.search", + )(function* (query, limit) { + const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); + return mapMixedSearchResult(result, limit); + }); + + return WorkspaceSearchIndex.of({ list, refresh, search }); + }), + ); + +const workspaceSearchIndexLayer = (cwd: string) => + Layer.effect(WorkspaceSearchIndex, makeWorkspaceSearchIndex(cwd)); + +export class WorkspaceSearchIndexMap extends LayerMap.Service()( + "t3/workspace/WorkspaceSearchIndexMap", + { + lookup: workspaceSearchIndexLayer, + idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL, + }, +) {} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bf..34c993de84f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -22,6 +22,7 @@ import { type AuthEnvironmentScope, AuthSessionId, CommandId, + type DiscoveredLocalServerList, EventId, type OrchestrationCommand, type GitActionProgressEvent, @@ -33,12 +34,15 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + ProjectListEntriesError, + ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, FilesystemBrowseError, + AssetAccessError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -70,7 +74,11 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; -import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; +import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; +import * as PreviewManager from "./preview/Manager.ts"; +import { issueAssetUrl } from "./assets/AssetAccess.ts"; +import * as PortScanner from "./preview/PortScanner.ts"; +import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; @@ -154,10 +162,13 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], + [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], + [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], + [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], @@ -180,6 +191,18 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.terminalClose, AuthTerminalOperateScope], [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope], [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], + [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], + [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope], + [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], + [WS_METHODS.previewClose, AuthOrchestrationOperateScope], + [WS_METHODS.previewList, AuthOrchestrationReadScope], + [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope], + [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], + [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], @@ -240,13 +263,16 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const vcsProvisioning = yield* VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; + const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const previewManager = yield* PreviewManager.PreviewManager; + const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const startup = yield* ServerRuntimeStartup; - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; @@ -739,7 +765,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: ExternalLauncher.resolveAvailableEditors(), + availableEditors: yield* externalLauncher.resolveAvailableEditors(), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, @@ -1150,6 +1176,33 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsListEntries]: (input) => + observeRpcEffect( + WS_METHODS.projectsListEntries, + workspaceEntries.list(input).pipe( + Effect.mapError( + (cause) => + new ProjectListEntriesError({ + message: `Failed to list workspace entries: ${cause.detail}`, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.projectsReadFile]: (input) => + observeRpcEffect( + WS_METHODS.projectsReadFile, + workspaceFileSystem.readFile(input).pipe( + Effect.mapError((cause) => { + const message = isWorkspacePathOutsideRootError(cause) + ? "Workspace file path must stay within the project root." + : `Failed to read workspace file: ${cause.detail}`; + return new ProjectReadFileError({ message, cause }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect( WS_METHODS.projectsWriteFile, @@ -1184,6 +1237,52 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.assetsCreateUrl]: (input) => + observeRpcEffect( + WS_METHODS.assetsCreateUrl, + Effect.gen(function* () { + if (input.resource._tag !== "workspace-file") { + return yield* issueAssetUrl({ resource: input.resource }); + } + const thread = yield* projectionSnapshotQuery + .getThreadShellById(input.resource.threadId) + .pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to resolve workspace context.", + cause, + }), + ), + ); + if (Option.isNone(thread)) { + return yield* new AssetAccessError({ + message: "Workspace context was not found.", + }); + } + const project = yield* projectionSnapshotQuery + .getProjectShellById(thread.value.projectId) + .pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to resolve workspace context.", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetAccessError({ + message: "Workspace context was not found.", + }); + } + return yield* issueAssetUrl({ + resource: input.resource, + workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, + }); + }), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( WS_METHODS.subscribeVcsStatus, @@ -1350,6 +1449,80 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "terminal" }, ), + [WS_METHODS.previewOpen]: (input) => + observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewNavigate]: (input) => + observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewRefresh]: (input) => + observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewClose]: (input) => + observeRpcEffect(WS_METHODS.previewClose, previewManager.close(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewList]: (input) => + observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewReportStatus]: (input) => + observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewAutomationConnect]: (input) => + observeRpcStreamEffect( + WS_METHODS.previewAutomationConnect, + previewAutomationBroker.connect(input.clientId), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.previewAutomationRespond]: (input) => + observeRpcEffect( + WS_METHODS.previewAutomationRespond, + previewAutomationBroker.respond(input), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.previewAutomationReportOwner]: (input) => + observeRpcEffect( + WS_METHODS.previewAutomationReportOwner, + previewAutomationBroker.reportOwner(input), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.previewAutomationClearOwner]: (input) => + observeRpcEffect( + WS_METHODS.previewAutomationClearOwner, + previewAutomationBroker.clearOwner(input.clientId), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.subscribePreviewEvents]: (_input) => + observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { + "rpc.aggregate": "preview", + }), + [WS_METHODS.subscribeDiscoveredLocalServers]: (_input) => + observeRpcStream( + WS_METHODS.subscribeDiscoveredLocalServers, + Stream.callback((queue) => + Effect.gen(function* () { + yield* portDiscovery.retain; + const initial = yield* portDiscovery.scan(); + const initialScannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { + servers: initial, + scannedAt: initialScannedAt, + }); + yield* portDiscovery.subscribe((servers) => + Effect.gen(function* () { + const scannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { servers, scannedAt }); + }), + ); + }), + ), + { "rpc.aggregate": "preview" }, + ), [WS_METHODS.subscribeServerConfig]: (_input) => observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, @@ -1473,6 +1646,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 7e88ac7756a..473df069ed7 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -49,6 +49,15 @@ export default mergeConfig( __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify( repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "", ), + __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: JSON.stringify( + repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() ?? "", + ), + __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: JSON.stringify( + repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() ?? "", + ), + __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: JSON.stringify( + repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() ?? "", + ), }, }, test: { diff --git a/apps/web/THIRD_PARTY_NOTICES.md b/apps/web/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000000..c9a675ef41c --- /dev/null +++ b/apps/web/THIRD_PARTY_NOTICES.md @@ -0,0 +1,11 @@ +# Third-Party Notices + +## vscode-icons + +The custom file icon symbols in `src/pierre-icons.ts` are adapted from the +[`vscode-icons`](https://github.com/vscode-icons/vscode-icons) project. + +Copyright (c) 2016 Roberto Huertas + +Licensed under the MIT License. The full license text is available in the +upstream repository: . diff --git a/apps/web/package.json b/apps/web/package.json index cbf554a6679..7704365b0ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", + "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts new file mode 100644 index 00000000000..e4fba2c5b99 --- /dev/null +++ b/apps/web/src/assets/assetUrls.ts @@ -0,0 +1,89 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { readEnvironmentApi } from "~/environmentApi"; +import { readEnvironmentConnection } from "~/environments/runtime"; + +const REFRESH_MARGIN_MS = 30_000; + +interface CachedAssetUrl { + readonly url: string; + readonly expiresAt: number; +} + +const assetUrlCache = new Map(); +const assetUrlRequests = new Map>(); + +function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string { + return `${environmentId}:${JSON.stringify(resource)}`; +} + +export async function resolveAssetUrl( + environmentId: EnvironmentId, + resource: AssetResource, +): Promise { + const key = assetCacheKey(environmentId, resource); + const cached = assetUrlCache.get(key); + if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) { + return cached; + } + + const inFlight = assetUrlRequests.get(key); + if (inFlight) { + return inFlight; + } + + const request = (async () => { + const api = readEnvironmentApi(environmentId); + const connection = readEnvironmentConnection(environmentId); + if (!api || !connection) { + throw new Error("Environment is not connected."); + } + const result = await api.assets.createUrl({ resource }); + const cachedResult = { + url: new URL(result.relativeUrl, connection.knownEnvironment.target.httpBaseUrl).toString(), + expiresAt: result.expiresAt, + }; + assetUrlCache.set(key, cachedResult); + return cachedResult; + })().finally(() => { + assetUrlRequests.delete(key); + }); + assetUrlRequests.set(key, request); + return request; +} + +export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { + const resourceJson = JSON.stringify(resource); + const stableResource = useMemo(() => JSON.parse(resourceJson) as AssetResource, [resourceJson]); + const key = assetCacheKey(environmentId, stableResource); + const [url, setUrl] = useState(() => assetUrlCache.get(key)?.url ?? null); + + useEffect(() => { + let cancelled = false; + let refreshTimer: ReturnType | undefined; + + const load = () => { + void resolveAssetUrl(environmentId, stableResource) + .then((result) => { + if (cancelled) return; + setUrl(result.url); + refreshTimer = setTimeout( + load, + Math.max(0, result.expiresAt - Date.now() - REFRESH_MARGIN_MS), + ); + }) + .catch(() => { + if (!cancelled) setUrl(null); + }); + }; + load(); + + return () => { + cancelled = true; + if (refreshTimer) clearTimeout(refreshTimer); + }; + }, [environmentId, key, stableResource]); + + return url; +} diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx new file mode 100644 index 00000000000..90769f8fb69 --- /dev/null +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; + +export function BrowserSurfaceSlot(props: { + readonly tabId: string; + readonly visible: boolean; + readonly className?: string; +}) { + const { tabId, visible, className } = props; + const elementRef = useRef(null); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + const update = () => { + const rect = element.getBoundingClientRect(); + useBrowserSurfaceStore.getState().present( + tabId, + { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + }, + visible && rect.width > 0 && rect.height > 0, + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + window.addEventListener("resize", update); + window.addEventListener("scroll", update, true); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + window.removeEventListener("scroll", update, true); + useBrowserSurfaceStore.getState().hide(tabId); + }; + }, [tabId, visible]); + + return
; +} diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx new file mode 100644 index 00000000000..feac8ed0f22 --- /dev/null +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { parseScopedThreadKey } from "@t3tools/client-runtime"; +import { useEffect, useMemo } from "react"; + +import { isElectron } from "~/env"; +import { useTheme } from "~/hooks/useTheme"; +import { usePreviewStateStore } from "~/previewStateStore"; + +import { readPreviewAnnotationTheme } from "./annotationTheme"; +import { useBrowserPointerStore } from "./browserPointerStore"; +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +export function ElectronBrowserHost() { + const { resolvedTheme } = useTheme(); + const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); + const sessions = useMemo( + () => + Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef + ? Object.values(previewState.sessions).map((snapshot) => ({ + threadRef, + snapshot, + active: previewState.activeTabId === snapshot.tabId, + })) + : []; + }), + [previewByThreadKey], + ); + + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + + let lastSerializedTheme = ""; + const syncTheme = () => { + const theme = readPreviewAnnotationTheme(); + const serializedTheme = JSON.stringify(theme); + if (serializedTheme === lastSerializedTheme) return; + lastSerializedTheme = serializedTheme; + void preview.setAnnotationTheme(theme).catch(() => { + lastSerializedTheme = ""; + }); + }; + const frameId = window.requestAnimationFrame(syncTheme); + const observer = new MutationObserver(syncTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + const headObserver = new MutationObserver(syncTheme); + headObserver.observe(document.head, { + childList: true, + subtree: true, + characterData: true, + }); + return () => { + window.cancelAnimationFrame(frameId); + observer.disconnect(); + headObserver.disconnect(); + }; + }, [resolvedTheme]); + + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + return preview.onPointerEvent((event) => { + useBrowserPointerStore.getState().apply(event); + }); + }, []); + + if (!isElectron) return null; + return ( +
+ {sessions.map(({ threadRef, snapshot }) => { + const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx new file mode 100644 index 00000000000..276a9090af2 --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useShallow } from "zustand/react/shallow"; +import { useCallback, useEffect, useRef } from "react"; + +import { previewBridge } from "~/components/preview/previewBridge"; +import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; + +import { useBrowserRecordingStore } from "./browserRecording"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { acquireDesktopTab } from "./desktopTabLifetime"; +import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; + +interface ElectronWebview extends HTMLElement { + src: string; + partition: string; + preload?: string; + webpreferences?: string; + getWebContentsId: () => number; +} + +declare global { + interface HTMLElementTagNameMap { + webview: ElectronWebview; + } +} + +export function HostedBrowserWebview(props: { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly initialUrl: string | null; +}) { + const { threadRef, tabId, initialUrl } = props; + const config = usePreviewWebviewConfig(threadRef.environmentId); + const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const webviewRef = useRef(null); + const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); + const recording = useBrowserRecordingStore((state) => state.activeTabId === tabId); + + usePreviewBridge({ threadRef, tabId }); + + useEffect(() => acquireDesktopTab(tabId), [tabId]); + + const setWebviewRef = useCallback((node: HTMLElement | null) => { + webviewRef.current = node as ElectronWebview | null; + if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); + }, []); + + useEffect(() => { + const webview = webviewRef.current; + const bridge = previewBridge; + if (!webview || !config || !bridge) return; + const register = () => { + try { + const webContentsId = webview.getWebContentsId(); + if (Number.isInteger(webContentsId) && webContentsId > 0) { + void bridge.registerWebview(tabId, webContentsId); + } + } catch { + // A later dom-ready will retry registration. + } + }; + webview.addEventListener("dom-ready", register); + register(); + return () => webview.removeEventListener("dom-ready", register); + }, [config, tabId]); + + if (!config) return null; + const active = presentation?.visible === true && presentation.rect !== null; + const lastRect = presentation?.rect; + const style = + active && lastRect + ? { + left: lastRect.x, + top: lastRect.y, + width: lastRect.width, + height: lastRect.height, + zIndex: 30, + pointerEvents: "auto" as const, + } + : { + left: 0, + top: 0, + width: lastRect?.width ?? 1280, + height: lastRect?.height ?? 800, + zIndex: recording ? 0 : -1, + pointerEvents: "none" as const, + }; + + return ( + + ); +} diff --git a/apps/web/src/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts new file mode 100644 index 00000000000..e12c667d23d --- /dev/null +++ b/apps/web/src/browser/annotationTheme.ts @@ -0,0 +1,28 @@ +import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; + +const readVariable = (styles: CSSStyleDeclaration, name: string, fallback: string): string => + styles.getPropertyValue(name).trim() || fallback; + +export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { + const root = document.documentElement; + const styles = getComputedStyle(root); + return { + colorScheme: root.classList.contains("dark") ? "dark" : "light", + radius: readVariable(styles, "--radius", "0.625rem"), + background: readVariable(styles, "--background", "white"), + foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"), + popover: readVariable(styles, "--popover", "white"), + popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"), + primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"), + primaryForeground: readVariable(styles, "--primary-foreground", "white"), + muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"), + mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"), + accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"), + accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"), + border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"), + input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"), + ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"), + fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"), + fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), + }; +} diff --git a/apps/web/src/browser/browserPointerStore.test.ts b/apps/web/src/browser/browserPointerStore.test.ts new file mode 100644 index 00000000000..de9c173dc2d --- /dev/null +++ b/apps/web/src/browser/browserPointerStore.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { useBrowserPointerStore } from "./browserPointerStore"; + +beforeEach(() => { + useBrowserPointerStore.setState({ byTabId: {} }); +}); + +describe("browserPointerStore", () => { + it("tracks the latest pointer target independently for each tab", () => { + const store = useBrowserPointerStore.getState(); + store.apply({ + tabId: "tab_a", + phase: "move", + x: 20, + y: 30, + sequence: 0, + createdAt: "2026-06-12T00:00:00.000Z", + }); + store.apply({ + tabId: "tab_b", + phase: "move", + x: 40, + y: 50, + sequence: 1, + createdAt: "2026-06-12T00:00:01.000Z", + }); + store.apply({ + tabId: "tab_a", + phase: "click", + x: 60, + y: 70, + sequence: 2, + createdAt: "2026-06-12T00:00:02.000Z", + }); + + expect(useBrowserPointerStore.getState().byTabId).toMatchObject({ + tab_a: { phase: "click", x: 60, y: 70, sequence: 2 }, + tab_b: { phase: "move", x: 40, y: 50, sequence: 1 }, + }); + }); + + it("clears one tab without affecting the others", () => { + const store = useBrowserPointerStore.getState(); + store.apply({ + tabId: "tab_a", + phase: "move", + x: 20, + y: 30, + sequence: 0, + createdAt: "2026-06-12T00:00:00.000Z", + }); + store.apply({ + tabId: "tab_b", + phase: "move", + x: 40, + y: 50, + sequence: 1, + createdAt: "2026-06-12T00:00:01.000Z", + }); + + store.clear("tab_a"); + + expect(useBrowserPointerStore.getState().byTabId).toEqual({ + tab_b: expect.objectContaining({ x: 40, y: 50 }), + }); + }); +}); diff --git a/apps/web/src/browser/browserPointerStore.ts b/apps/web/src/browser/browserPointerStore.ts new file mode 100644 index 00000000000..f9f905ddc8f --- /dev/null +++ b/apps/web/src/browser/browserPointerStore.ts @@ -0,0 +1,25 @@ +import type { DesktopPreviewPointerEvent } from "@t3tools/contracts"; +import { create } from "zustand"; + +interface BrowserPointerStoreState { + readonly byTabId: Record; + readonly apply: (event: DesktopPreviewPointerEvent) => void; + readonly clear: (tabId: string) => void; +} + +export const useBrowserPointerStore = create()((set) => ({ + byTabId: {}, + apply: (event) => + set((state) => ({ + byTabId: { + ...state.byTabId, + [event.tabId]: event, + }, + })), + clear: (tabId) => + set((state) => { + if (!(tabId in state.byTabId)) return state; + const { [tabId]: _removed, ...byTabId } = state.byTabId; + return { byTabId }; + }), +})); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts new file mode 100644 index 00000000000..8a1c6f41327 --- /dev/null +++ b/apps/web/src/browser/browserRecording.ts @@ -0,0 +1,115 @@ +import type { + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, +} from "@t3tools/contracts"; +import { create } from "zustand"; + +import { previewBridge } from "~/components/preview/previewBridge"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; + +interface ActiveRecording { + readonly tabId: string; + readonly canvas: HTMLCanvasElement; + readonly context: CanvasRenderingContext2D; + readonly recorder: MediaRecorder; + readonly chunks: Blob[]; + readonly mimeType: string; + readonly startedAt: string; +} + +interface BrowserRecordingState { + activeTabId: string | null; + startedAt: string | null; + lastArtifact: DesktopPreviewRecordingArtifact | null; + setActive: (tabId: string | null, startedAt: string | null) => void; + setArtifact: (artifact: DesktopPreviewRecordingArtifact) => void; +} + +export const useBrowserRecordingStore = create()((set) => ({ + activeTabId: null, + startedAt: null, + lastArtifact: null, + setActive: (activeTabId, startedAt) => set({ activeTabId, startedAt }), + setArtifact: (lastArtifact) => set({ lastArtifact }), +})); + +let active: ActiveRecording | null = null; +let unsubscribeFrames: (() => void) | null = null; + +const preferredMimeType = (): string => { + const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; + return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; +}; + +const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { + const recording = active; + if (!recording || recording.tabId !== frame.tabId) return; + const image = new Image(); + image.addEventListener( + "load", + () => { + if (active !== recording) return; + recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height); + }, + { once: true }, + ); + image.src = `data:image/jpeg;base64,${frame.data}`; +}; + +export async function startBrowserRecording(tabId: string): Promise { + const bridge = previewBridge; + if (!bridge || active) return; + const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, rect?.width ?? 1280); + canvas.height = Math.max(1, rect?.height ?? 800); + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Browser recording canvas is unavailable."); + const mimeType = preferredMimeType(); + const recorder = new MediaRecorder(canvas.captureStream(12), { + mimeType, + videoBitsPerSecond: 4_000_000, + }); + const startedAt = new Date().toISOString(); + const chunks: Blob[] = []; + recorder.addEventListener("dataavailable", (event) => { + if (event.data.size > 0) chunks.push(event.data); + }); + active = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; + unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); + recorder.start(1_000); + try { + await bridge.recording.startScreencast(tabId); + useBrowserRecordingStore.getState().setActive(tabId, startedAt); + } catch (error) { + active = null; + recorder.stop(); + throw error; + } +} + +export async function stopBrowserRecording( + tabId: string, +): Promise { + const bridge = previewBridge; + const recording = active; + if (!bridge || !recording || recording.tabId !== tabId) return null; + await bridge.recording.stopScreencast(tabId); + const stopped = new Promise((resolve) => + recording.recorder.addEventListener("stop", () => resolve(), { once: true }), + ); + recording.recorder.stop(); + await stopped; + const blob = new Blob(recording.chunks, { type: recording.mimeType }); + const artifact = await bridge.recording.save( + tabId, + recording.mimeType, + new Uint8Array(await blob.arrayBuffer()), + ); + active = null; + unsubscribeFrames?.(); + unsubscribeFrames = null; + useBrowserRecordingStore.getState().setActive(null, null); + useBrowserRecordingStore.getState().setArtifact(artifact); + return artifact; +} diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts new file mode 100644 index 00000000000..64fd8e2df2b --- /dev/null +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -0,0 +1,53 @@ +import { create } from "zustand"; + +export interface BrowserSurfaceRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface BrowserSurfacePresentation { + readonly rect: BrowserSurfaceRect | null; + readonly visible: boolean; + readonly updatedAt: number; +} + +interface BrowserSurfaceStoreState { + readonly byTabId: Record; + readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; + readonly hide: (tabId: string) => void; +} + +const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => + left !== null && + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height; + +export const useBrowserSurfaceStore = create()((set) => ({ + byTabId: {}, + present: (tabId, rect, visible) => + set((state) => { + const current = state.byTabId[tabId]; + if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; + return { + byTabId: { + ...state.byTabId, + [tabId]: { rect, visible, updatedAt: Date.now() }, + }, + }; + }), + hide: (tabId) => + set((state) => { + const current = state.byTabId[tabId]; + if (!current || !current.visible) return state; + return { + byTabId: { + ...state.byTabId, + [tabId]: { ...current, visible: false, updatedAt: Date.now() }, + }, + }; + }), +})); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts new file mode 100644 index 00000000000..a50275eb8c0 --- /dev/null +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -0,0 +1,81 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const readEnvironmentConnection = vi.fn(); + +vi.mock("~/environments/runtime", () => ({ readEnvironmentConnection })); + +describe("browser target resolver", () => { + beforeEach(() => readEnvironmentConnection.mockReset()); + + it("maps environment ports onto a private network host", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "http://192.168.1.25:3773" } }, + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path: "/dashboard", + }), + ).toEqual({ + requestedUrl: "http://localhost:5173/dashboard", + resolvedUrl: "http://192.168.1.25:5173/dashboard", + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }); + + it("refuses public relay hosts until the authenticated gateway exists", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "https://relay.example.com" } }, + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect(() => + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }), + ).toThrow(/authenticated preview gateway/); + }); + + it("normalizes schemeless localhost server-picker values", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "http://localhost:3773" } }, + }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( + "http://localhost:5173/", + ); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "0.0.0.0:3000/app"), + ).toBe("http://localhost:3000/app"); + }); + + it("normalizes public URLs without treating them as environment ports", async () => { + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( + "https://example.com/app", + ); + }); + + it("supports private IPv6 environment hosts", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "http://[::1]:3773" } }, + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path: "/app?mode=test", + }).resolvedUrl, + ).toBe("http://[::1]:5173/app?mode=test"); + }); + + it("leaves malformed input for the normal navigation error path", async () => { + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); + }); +}); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts new file mode 100644 index 00000000000..12276673002 --- /dev/null +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -0,0 +1,81 @@ +import type { + BrowserNavigationTarget, + EnvironmentId, + PreviewUrlResolution, +} from "@t3tools/contracts"; +import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; + +import { readEnvironmentConnection } from "~/environments/runtime"; + +const isPrivateNetworkHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { + return true; + } + if (normalized.endsWith(".ts.net")) return true; + const parts = normalized.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + return ( + parts[0] === 10 || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + parts[0] === 127 || + (parts[0] === 169 && parts[1] === 254) + ); +}; + +export function resolveBrowserNavigationTarget( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): PreviewUrlResolution { + if (target.kind === "url") { + return { + requestedUrl: target.url, + resolvedUrl: target.url, + resolutionKind: "direct", + environmentId, + }; + } + const connection = readEnvironmentConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const environmentUrl = new URL(connection.knownEnvironment.target.httpBaseUrl); + if (!isPrivateNetworkHost(environmentUrl.hostname)) { + throw new Error( + "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", + ); + } + const protocol = target.protocol ?? "http"; + const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; + const requestedUrl = `${protocol}://localhost:${target.port}${path}`; + const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); + const resolvedHost = normalizedEnvironmentHost.includes(":") + ? `[${normalizedEnvironmentHost}]` + : normalizedEnvironmentHost; + const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + return { + requestedUrl, + resolvedUrl: resolved.toString(), + resolutionKind: + normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" + ? "direct" + : "direct-private-network", + environmentId, + }; +} + +export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { + try { + const normalizedUrl = normalizePreviewUrl(rawUrl); + const parsed = new URL(normalizedUrl); + if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); + return resolveBrowserNavigationTarget(environmentId, { + kind: "environment-port", + port, + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }).resolvedUrl; + } catch { + return rawUrl; + } +} diff --git a/apps/web/src/browser/desktopTabLifetime.ts b/apps/web/src/browser/desktopTabLifetime.ts new file mode 100644 index 00000000000..4254c7e6afc --- /dev/null +++ b/apps/web/src/browser/desktopTabLifetime.ts @@ -0,0 +1,30 @@ +import { previewBridge } from "~/components/preview/previewBridge"; + +interface DesktopTabLease { + references: number; + closeTimer: number | null; +} + +const leases = new Map(); + +export function acquireDesktopTab(tabId: string): () => void { + const current = leases.get(tabId) ?? { references: 0, closeTimer: null }; + if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); + current.references += 1; + current.closeTimer = null; + leases.set(tabId, current); + if (current.references === 1) void previewBridge?.createTab(tabId); + + return () => { + const lease = leases.get(tabId); + if (!lease) return; + lease.references = Math.max(0, lease.references - 1); + if (lease.references > 0) return; + lease.closeTimer = window.setTimeout(() => { + const latest = leases.get(tabId); + if (!latest || latest.references > 0) return; + leases.delete(tabId); + void previewBridge?.closeTab(tabId); + }, 0); + }; +} diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts new file mode 100644 index 00000000000..6fcc8ec9954 --- /dev/null +++ b/apps/web/src/browser/openFileInPreview.ts @@ -0,0 +1,36 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { readEnvironmentApi } from "~/environmentApi"; +import { resolveAssetUrl } from "~/assets/assetUrls"; +import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +export const isBrowserPreviewFile = (path: string): boolean => + /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); + +export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise { + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + throw new Error("Environment is not connected."); + } + + const snapshot = await api.preview.open({ threadId: threadRef.threadId, url }); + usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); + usePreviewStateStore.getState().rememberUrl(threadRef, url); + useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); +} + +export async function openFileInPreview( + threadRef: ScopedThreadRef, + filePath: string, +): Promise { + if (!isPreviewSupportedInRuntime()) { + throw new Error("The integrated browser is unavailable in this runtime."); + } + const asset = await resolveAssetUrl(threadRef.environmentId, { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: filePath, + }); + await openUrlInPreview(threadRef, asset.url); +} diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts new file mode 100644 index 00000000000..99a8388ec5a --- /dev/null +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -0,0 +1,48 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { DesktopPreviewWebviewConfig, EnvironmentId } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { previewBridge } from "~/components/preview/previewBridge"; + +const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000; +const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000; + +class PreviewWebviewConfigError extends Data.TaggedError("PreviewWebviewConfigError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + Effect.tryPromise({ + try: () => { + if (!previewBridge) { + throw new Error("Desktop preview bridge is unavailable."); + } + return previewBridge.getPreviewConfig(environmentId); + }, + catch: (cause) => + new PreviewWebviewConfigError({ + message: "Could not load desktop preview configuration.", + cause, + }), + }), + ).pipe( + Atom.swr({ + staleTime: PREVIEW_CONFIG_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS), + Atom.withLabel(`preview:webview-config:${environmentId}`), + ), +); + +export function usePreviewWebviewConfig( + environmentId: EnvironmentId, +): DesktopPreviewWebviewConfig | null { + const result = useAtomValue(previewWebviewConfigAtom(environmentId)); + return Option.getOrNull(AsyncResult.value(result)); +} diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index dc09a7fa043..30cb596781a 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -318,6 +318,14 @@ describe("web cloud link environment client", () => { method: "POST", url: "https://managed.example.test/oauth/token", }); + const traceparents = fetchMock.mock.calls.map( + (call) => call[1]?.headers.traceparent as string | undefined, + ); + expect(traceparents.every((traceparent) => typeof traceparent === "string")).toBe(true); + expect(new Set(traceparents.map((traceparent) => traceparent?.split("-")[1])).size).toBe(1); + expect(connection.relayTraceHeaders.traceparent?.split("-")[1]).toBe( + traceparents[0]?.split("-")[1], + ); }), ); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index b13b324a411..4c94ab41660 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -1,7 +1,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, type EnvironmentCloudLinkStateResult, @@ -29,6 +29,7 @@ import { ManagedRelayDpopSigner, type WsRpcClient, } from "@t3tools/client-runtime"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { ensureLocalApi } from "../localApi"; import { @@ -245,6 +246,7 @@ export interface CloudManagedConnection { readonly wsBaseUrl: string; readonly relayUrl: string; readonly accessToken: string; + readonly relayTraceHeaders: Headers.Headers; } export function collectCloudLinkTargets(input: { @@ -437,8 +439,15 @@ export function connectManagedCloudEnvironment(input: { wsBaseUrl: connected.endpoint.wsBaseUrl, relayUrl: configuredRelayUrl, accessToken: session.access_token, + relayTraceHeaders: HttpTraceContext.toHeaders(yield* Effect.currentSpan.pipe(Effect.orDie)), }; - }); + }).pipe( + Effect.withSpan("relay.environment.connect", { + root: true, + attributes: { "relay.environment_id": input.environment.environmentId }, + }), + withRelayClientTracing, + ); } export function readPrimaryCloudLinkState(): Effect.Effect< diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index 291f1830ca3..f7b3ca6bc31 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -5,12 +5,26 @@ export interface CloudPublicConfig { readonly clerkPublishableKey: string | null; readonly clerkJwtTemplate: string | null; readonly relayUrl: string | null; + readonly relayTracing: { + readonly tracesUrl: string | null; + readonly tracesDataset: string | null; + readonly tracesToken: string | null; + }; } function trimNonEmpty(value: string | undefined): string | null { return value?.trim() || null; } +function normalizeSecureUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + export function resolveCloudPublicConfig(): CloudPublicConfig { return { clerkPublishableKey: trimNonEmpty( @@ -20,9 +34,29 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { relayUrl: normalizeSecureRelayUrl( (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "", ), + relayTracing: { + tracesUrl: normalizeSecureUrl( + (import.meta.env.VITE_RELAY_OTLP_TRACES_URL as string | undefined) ?? "", + ), + tracesDataset: trimNonEmpty( + import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET as string | undefined, + ), + tracesToken: trimNonEmpty(import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN as string | undefined), + }, }; } +export function resolveRelayTracingConfig() { + const { relayTracing } = resolveCloudPublicConfig(); + return relayTracing.tracesUrl && relayTracing.tracesDataset && relayTracing.tracesToken + ? { + tracesUrl: relayTracing.tracesUrl, + tracesDataset: relayTracing.tracesDataset, + tracesToken: relayTracing.tracesToken, + } + : null; +} + export function hasCloudPublicConfig(): boolean { const config = resolveCloudPublicConfig(); return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index e047392c12d..4eeab3e8075 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -4,11 +4,24 @@ import { page } from "vite-plus/test/browser"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; -const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({ +const { + contextMenuShowMock, + openFileInPreviewMock, + openInPreferredEditorMock, + openUrlInPreviewMock, + readLocalApiMock, +} = vi.hoisted(() => ({ + contextMenuShowMock: vi.fn(), + openFileInPreviewMock: vi.fn(async () => undefined), openInPreferredEditorMock: vi.fn(async () => "vscode"), + openUrlInPreviewMock: vi.fn(async () => undefined), readLocalApiMock: vi.fn(() => ({ + contextMenu: { show: contextMenuShowMock }, server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, - shell: { openInEditor: vi.fn(async () => undefined) }, + shell: { + openExternal: vi.fn(async () => undefined), + openInEditor: vi.fn(async () => undefined), + }, })), })); @@ -23,13 +36,35 @@ vi.mock("../localApi", () => ({ readLocalApi: readLocalApiMock, })); +vi.mock("../previewStateStore", async (importOriginal) => ({ + ...(await importOriginal()), + isPreviewSupportedInRuntime: () => true, +})); + +vi.mock("../browser/openFileInPreview", async (importOriginal) => ({ + ...(await importOriginal()), + openFileInPreview: openFileInPreviewMock, + openUrlInPreview: openUrlInPreviewMock, +})); + import ChatMarkdown from "./ChatMarkdown"; import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; + +const threadRef = { + environmentId: EnvironmentId.make("environment-test"), + threadId: ThreadId.make("thread-test"), +}; describe("ChatMarkdown", () => { afterEach(() => { openInPreferredEditorMock.mockClear(); + openFileInPreviewMock.mockClear(); + openUrlInPreviewMock.mockClear(); + contextMenuShowMock.mockReset(); readLocalApiMock.mockClear(); + useRightPanelStore.setState({ byThreadKey: {} }); localStorage.clear(); document.body.innerHTML = ""; }); @@ -155,6 +190,152 @@ describe("ChatMarkdown", () => { } }); + it("opens web links in the integrated browser from the context menu", async () => { + contextMenuShowMock.mockResolvedValue("open-in-browser"); + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "OpenAI" }).element(); + link.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 12, + clientY: 24, + }), + ); + + await vi.waitFor(() => { + expect(contextMenuShowMock).toHaveBeenCalled(); + expect(openUrlInPreviewMock).toHaveBeenCalledWith(threadRef, "https://openai.com/docs"); + }); + } finally { + await screen.unmount(); + } + }); + + it("offers integrated browser opening for HTML file links", async () => { + contextMenuShowMock.mockResolvedValue("open-in-browser"); + const filePath = "/repo/project/report.html"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "report.html" }).element(); + link.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }), + ); + + await vi.waitFor(() => { + expect(contextMenuShowMock).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: "open-in-browser", + label: "Open in integrated browser", + }), + ]), + { x: 4, y: 8 }, + ); + expect(openFileInPreviewMock).toHaveBeenCalledWith(threadRef, filePath); + }); + } finally { + await screen.unmount(); + } + }); + + it("opens code file links in the right-panel file preview", async () => { + const screen = await render( + , + ); + + try { + await page.getByRole("link", { name: "ChatMarkdown.tsx · L978" }).click(); + + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: "file:apps/web/src/components/ChatMarkdown.tsx", + }); + expect(openInPreferredEditorMock).not.toHaveBeenCalled(); + expect(openFileInPreviewMock).not.toHaveBeenCalled(); + }); + } finally { + await screen.unmount(); + } + }); + + it("opens HTML and PDF file links in the integrated browser preview", async () => { + const screen = await render( + , + ); + + try { + await page.getByRole("link", { name: "report.html" }).click(); + await page.getByRole("link", { name: "report.pdf" }).click(); + + await vi.waitFor(() => { + expect(openFileInPreviewMock).toHaveBeenNthCalledWith( + 1, + threadRef, + "/repo/project/report.html", + ); + expect(openFileInPreviewMock).toHaveBeenNthCalledWith( + 2, + threadRef, + "/repo/project/report.pdf", + ); + expect(openInPreferredEditorMock).not.toHaveBeenCalled(); + }); + } finally { + await screen.unmount(); + } + }); + + it("keeps opening file links in the editor from the context menu", async () => { + contextMenuShowMock.mockResolvedValue("open"); + const filePath = "/repo/project/src/index.ts"; + const screen = await render( + , + ); + + try { + page + .getByRole("link", { name: "index.ts" }) + .element() + .dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 4, + clientY: 8, + }), + ); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath); + }); + } finally { + await screen.unmount(); + } + }); + it("keeps a favicon with the leading segment of a wrapping URL", async () => { const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; const screen = await render( @@ -336,7 +517,7 @@ describe("ChatMarkdown", () => { // Language with a known icon: icon XOR text — never the redundant pair. const languageOnly = titles[0]!; - const hasIcon = languageOnly.querySelector("img") != null; + const hasIcon = languageOnly.querySelector("svg[data-pierre-icon]") != null; const hasText = (languageOnly.textContent ?? "").includes("ts"); expect(hasIcon || hasText).toBe(true); expect(hasIcon && hasText).toBe(false); @@ -353,7 +534,7 @@ describe("ChatMarkdown", () => { expect(titles[1]!.textContent).toBe("src/main.ts"); // Unknown language: no icon attempt, text label. - expect(titles[2]!.querySelector("img")).toBeNull(); + expect(titles[2]!.querySelector("svg[data-pierre-icon]")).toBeNull(); expect(titles[2]!.textContent).toBe("text"); } finally { await screen.unmount(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a9b4ae5372b..ecd6bc40ffe 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -8,7 +8,7 @@ import { Minimize2Icon, WrapTextIcon, } from "lucide-react"; -import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; import React, { Children, Suspense, @@ -33,12 +33,8 @@ import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; -import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; -import { - getVscodeIconUrlForEntry, - hasSpecificVscodeIconForFileName, - syntheticFileNameForLanguageId, -} from "../vscode-icons"; +import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; @@ -62,6 +58,13 @@ import { } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; +import { useRightPanelStore } from "../rightPanelStore"; +import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { + isBrowserPreviewFile, + openFileInPreview, + openUrlInPreview, +} from "../browser/openFileInPreview"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -87,6 +90,7 @@ class CodeHighlightErrorBoundary extends React.Component< interface ChatMarkdownProps { text: string; cwd: string | undefined; + threadRef?: ScopedThreadRef | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; className?: string; @@ -436,22 +440,17 @@ function MarkdownCodeBlockTitleContent({ language: string; theme: "light" | "dark"; }) { - const [failedIconUrl, setFailedIconUrl] = useState(null); - if (fenceTitle) { return ( <> - + {fenceTitle} ); } const fileName = syntheticFileNameForLanguageId(language); - const iconUrl = hasSpecificVscodeIconForFileName(fileName, theme) - ? getVscodeIconUrlForEntry(fileName, "file", theme) - : null; - if (!iconUrl || failedIconUrl === iconUrl) { + if (!hasSpecificPierreIconForFileName(fileName)) { return {language}; } return ( @@ -461,15 +460,7 @@ function MarkdownCodeBlockTitleContent({ } > - setFailedIconUrl(iconUrl)} - /> + {language} @@ -667,9 +658,11 @@ interface MarkdownFileLinkProps { targetPath: string; iconPath: string; displayPath: string; + workspaceRelativePath: string | null; label: string; copyMarkdown: string; theme: "light" | "dark"; + threadRef?: ScopedThreadRef | undefined; className?: string | undefined; } @@ -936,17 +929,67 @@ function MarkdownExternalLinkContent({ ); } +function MarkdownExternalLink({ + href, + threadRef, + children, + ...props +}: React.ComponentProps<"a"> & { + href: string; + threadRef?: ScopedThreadRef | undefined; +}) { + const handleContextMenu = useCallback( + async (event: ReactMouseEvent) => { + if (!threadRef || !isPreviewSupportedInRuntime()) return; + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); + if (clicked === "open-in-browser") { + void openUrlInPreview(threadRef, href).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + } else if (clicked === "open-external") { + void api.shell.openExternal(href); + } + }, + [href, threadRef], + ); + + return ( +
+ {children} + + ); +} + const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, iconPath, displayPath, + workspaceRelativePath, label, copyMarkdown, theme, + threadRef, className, }: MarkdownFileLinkProps) { - const handleOpen = useCallback(() => { + const handleOpenInEditor = useCallback(() => { const api = readLocalApi(); if (!api) { toastManager.add({ @@ -967,6 +1010,27 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }); }, [targetPath]); + const handleOpenInFilePreview = useCallback(() => { + if (!threadRef || !workspaceRelativePath) { + handleOpenInEditor(); + return; + } + useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath); + }, [handleOpenInEditor, threadRef, workspaceRelativePath]); + + const handleOpenInBrowser = useCallback(() => { + if (!threadRef) return; + void openFileInPreview(threadRef, iconPath).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, [iconPath, threadRef]); + const handleCopy = useCallback((value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -1007,9 +1071,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; + const canOpenInBrowser = + Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath); const clicked = await api.contextMenu.show( [ { id: "open", label: "Open in editor" }, + ...(canOpenInBrowser + ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, @@ -1017,7 +1086,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); if (clicked === "open") { - handleOpen(); + handleOpenInEditor(); + return; + } + if (clicked === "open-in-browser") { + handleOpenInBrowser(); return; } if (clicked === "copy-relative") { @@ -1028,7 +1101,15 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleCopy(targetPath, "Full path"); } }, - [displayPath, handleCopy, handleOpen, targetPath], + [ + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + iconPath, + targetPath, + threadRef, + ], ); return ( @@ -1042,7 +1123,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - handleOpen(); + if (threadRef && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath)) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); }} onContextMenu={handleContextMenu} > @@ -1071,9 +1156,12 @@ function areMarkdownFileLinkPropsEqual( previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && previous.displayPath === next.displayPath && + previous.workspaceRelativePath === next.workspaceRelativePath && previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && + previous.threadRef?.environmentId === next.threadRef?.environmentId && + previous.threadRef?.threadId === next.threadRef?.threadId && previous.className === next.className ); } @@ -1081,6 +1169,7 @@ function areMarkdownFileLinkPropsEqual( function ChatMarkdown({ text, cwd, + threadRef, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, @@ -1137,9 +1226,10 @@ function ChatMarkdown({ const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const link = ( - { @@ -1156,7 +1246,7 @@ function ChatMarkdown({ ) : ( children )} - + ); if (!faviconHost || !href) { return link; @@ -1191,9 +1281,11 @@ function ChatMarkdown({ targetPath={fileLinkMeta.targetPath} iconPath={fileLinkMeta.filePath} displayPath={fileLinkMeta.displayPath} + workspaceRelativePath={fileLinkMeta.workspaceRelativePath} label={labelParts.join(" · ")} copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} theme={resolvedTheme} + threadRef={threadRef} className={props.className} /> ); @@ -1238,6 +1330,7 @@ function ChatMarkdown({ fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, + threadRef, resolvedTheme, skills, ], diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a55c531799f..3fa5c67794b 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -54,7 +54,7 @@ import { resetSavedEnvironmentRuntimeStoreForTests, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; +} from "../environments/runtime/catalog"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, removeInlineTerminalContextPlaceholder, @@ -66,6 +66,7 @@ import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; +import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { terminalSessionManager } from "../terminalSessionState"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -100,6 +101,7 @@ vi.mock("../lib/vcsStatusState", () => { }; return { + getVcsStatusDataForTarget: (state: typeof status) => state.data, getVcsStatusSnapshot: () => status, useVcsStatus: () => status, useVcsStatuses: () => new Map(), @@ -250,6 +252,18 @@ function createMockEnvironmentApi(input: { filesystem: { browse: input.browse, }, + assets: { + createUrl: vi.fn(async ({ resource }) => ({ + relativeUrl: `/api/assets/test/${encodeURIComponent( + resource._tag === "attachment" + ? resource.attachmentId + : resource._tag === "project-favicon" + ? "favicon.svg" + : (resource.path.split(/[\\/]/).at(-1) ?? "asset"), + )}`, + expiresAt: Date.now() + 60_000, + })), + }, sourceControl: {} as EnvironmentApi["sourceControl"], vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], @@ -269,6 +283,32 @@ function createMockEnvironmentApi(input: { subscribeThread: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeThread"], }, + preview: { + open: () => { + throw new Error("Not implemented in browser test."); + }, + navigate: () => { + throw new Error("Not implemented in browser test."); + }, + refresh: () => { + throw new Error("Not implemented in browser test."); + }, + close: () => { + throw new Error("Not implemented in browser test."); + }, + list: () => Promise.resolve({ sessions: [] }), + reportStatus: () => { + throw new Error("Not implemented in browser test."); + }, + automation: { + connect: () => () => undefined, + respond: () => Promise.resolve(), + reportOwner: () => Promise.resolve(), + clearOwner: () => Promise.resolve(), + }, + onEvent: () => () => undefined, + subscribePorts: () => () => undefined, + } as EnvironmentApi["preview"], }; } @@ -347,7 +387,6 @@ function createSnapshotForTargetUser(options: { name: `attachment-${attachmentIndex + 1}.png`, mimeType: "image/png", sizeBytes: 128, - previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, })) : undefined; @@ -1132,14 +1171,13 @@ const worker = setupWorker( }); }), ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), - http.get("*/attachments/:attachmentId", () => + http.get("*/api/assets/test/:assetName", () => HttpResponse.text(ATTACHMENT_SVG, { headers: { "Content-Type": "image/svg+xml", }, }), ), - http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); async function nextFrame(): Promise { @@ -1514,6 +1552,18 @@ function dispatchChatNewShortcut(): void { ); } +function dispatchConfiguredDiffToggleShortcut(): void { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "g", + shiftKey: true, + altKey: true, + bubbles: true, + cancelable: true, + }), + ); +} + function releaseModShortcut(key?: string): void { window.dispatchEvent( new KeyboardEvent("keyup", { @@ -1782,6 +1832,8 @@ describe("ChatView timeline estimator parity (full app)", () => { useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, }); + useRightPanelStore.persist.clearStorage(); + useRightPanelStore.setState({ byThreadKey: {} }); }); afterEach(() => { @@ -2032,12 +2084,12 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const toggle = await waitForElement( + const terminalToggle = await waitForElement( () => document.querySelector('button[aria-label="Toggle terminal drawer"]'), "Unable to find terminal drawer toggle.", ); - toggle.click(); + terminalToggle.click(); await vi.waitFor( () => { @@ -2050,6 +2102,10 @@ describe("ChatView timeline estimator parity (full app)", () => { terminalId: DEFAULT_TERMINAL_ID, cwd: "/repo/project", }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .isOpen, + ).toBe(false); }, { timeout: 8_000, interval: 16 }, ); @@ -2058,6 +2114,579 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps panel toggles fixed and can maximize the right panel", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-maximize-right-panel" as MessageId, + targetText: "maximize right panel", + }), + }); + + try { + const terminalToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle terminal drawer"]'), + "Unable to find terminal drawer toggle.", + ); + const rightPanelToggle = await waitForElement( + () => document.querySelector('button[aria-label="Toggle right panel"]'), + "Unable to find right panel toggle.", + ); + const chatHeader = await waitForElement( + () => document.querySelector("[data-chat-header]"), + "Unable to find chat header.", + ); + const panelLayoutControls = await waitForElement( + () => document.querySelector("[data-panel-layout-controls]"), + "Unable to find panel layout controls.", + ); + expect(chatHeader.getBoundingClientRect().height).toBe(52); + expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); + expect(panelLayoutControls.getBoundingClientRect().top).toBe( + chatHeader.getBoundingClientRect().top, + ); + expect( + window.getComputedStyle(panelLayoutControls).getPropertyValue("-webkit-app-region"), + ).toBe("no-drag"); + expect(chatHeader.classList.contains("drag-region")).toBe(false); + expect(chatHeader.contains(panelLayoutControls)).toBe(true); + expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); + const initialTerminalRect = terminalToggle.getBoundingClientRect(); + const initialRightPanelRect = rightPanelToggle.getBoundingClientRect(); + const initialControlRects = [initialTerminalRect, initialRightPanelRect]; + expect(document.querySelector('button[aria-label="Maximize panel"]')).toBeNull(); + expect(initialControlRects.every((rect) => rect.width === 28 && rect.height === 28)).toBe( + true, + ); + expect(initialControlRects.every((rect) => rect.top === initialControlRects[0]?.top)).toBe( + true, + ); + expect(initialRightPanelRect.left - initialTerminalRect.right).toBe(4); + + document.documentElement.classList.add("wco"); + expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); + expect(panelLayoutControls.getBoundingClientRect().top).toBe( + chatHeader.getBoundingClientRect().top, + ); + expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); + document.documentElement.classList.remove("wco"); + + rightPanelToggle.click(); + + const maximizeButton = await waitForElement( + () => document.querySelector('button[aria-label="Maximize panel"]'), + "Unable to find maximize panel button.", + ); + const rightPanelTabbar = await waitForElement( + () => document.querySelector("[data-right-panel-tabbar]"), + "Unable to find right panel tab bar.", + ); + const rightPanelTabList = await waitForElement( + () => document.querySelector("[data-right-panel-tab-list]"), + "Unable to find right panel tab list.", + ); + const maximizeRect = maximizeButton.getBoundingClientRect(); + const rightPanelTabbarRect = rightPanelTabbar.getBoundingClientRect(); + const openPanelLayoutControls = await waitForElement( + () => document.querySelector("[data-panel-layout-controls]"), + "Unable to find open panel layout controls.", + ); + const openTerminalToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle terminal drawer"]'), + "Unable to find open panel terminal toggle.", + ); + const openRightPanelToggle = await waitForElement( + () => document.querySelector('button[aria-label="Toggle right panel"]'), + "Unable to find open panel right panel toggle.", + ); + expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); + expect(rightPanelTabbarRect.height).toBe(52); + expect(rightPanelTabbarRect.top).toBe(chatHeader.getBoundingClientRect().top); + expect(chatHeader.contains(openPanelLayoutControls)).toBe(false); + expect( + window.getComputedStyle(rightPanelTabbar).getPropertyValue("-webkit-app-region"), + ).not.toBe("drag"); + expect(rightPanelTabList.classList.contains("drag-region")).toBe(false); + expect(window.getComputedStyle(maximizeButton).getPropertyValue("-webkit-app-region")).toBe( + "no-drag", + ); + expect( + window.getComputedStyle(openTerminalToggle).getPropertyValue("-webkit-app-region"), + ).toBe("no-drag"); + expect( + window.getComputedStyle(openRightPanelToggle).getPropertyValue("-webkit-app-region"), + ).toBe("no-drag"); + expect(maximizeRect.width).toBe(28); + expect(maximizeRect.height).toBe(28); + expect(maximizeRect.top).toBe(initialTerminalRect.top); + expect(initialTerminalRect.left - maximizeRect.right).toBe(4); + expect(openTerminalToggle.getBoundingClientRect().left).toBeCloseTo( + initialTerminalRect.left, + 1, + ); + expect(openRightPanelToggle.getBoundingClientRect().left).toBeCloseTo( + initialRightPanelRect.left, + 1, + ); + + useRightPanelStore.getState().openFile(THREAD_REF, "components.json"); + const fileTabIcon = await waitForElement( + () => + document.querySelector( + '[data-right-panel-tabbar] [data-pierre-icon][data-icon-token="json"]', + ), + "Unable to find the Pierre file icon in the file tab.", + ); + expect(fileTabIcon.closest("button")?.textContent).toContain("components.json"); + + document.documentElement.classList.add("wco"); + expect(rightPanelTabbar.getBoundingClientRect().height).toBe( + openPanelLayoutControls.getBoundingClientRect().height, + ); + expect(rightPanelTabbar.getBoundingClientRect().top).toBe( + openPanelLayoutControls.getBoundingClientRect().top, + ); + document.documentElement.classList.remove("wco"); + + maximizeButton.click(); + + await vi.waitFor(() => { + const chatColumn = document.querySelector( + '[data-chat-column-maximized-away="true"]', + ); + const panel = document.querySelector( + '[data-preview-panel-mode="inline"][data-preview-panel-maximized="true"]', + ); + expect(chatColumn?.getBoundingClientRect().width).toBe(0); + expect(panel?.getBoundingClientRect().width).toBeGreaterThan(1_000); + expect( + document.querySelector('button[aria-label="Restore panel size"]'), + ).not.toBeNull(); + expect( + document + .querySelector('button[aria-label="Toggle terminal drawer"]') + ?.getBoundingClientRect().left, + ).toBeCloseTo(initialTerminalRect.left, 1); + expect( + document + .querySelector('button[aria-label="Toggle right panel"]') + ?.getBoundingClientRect().left, + ).toBeCloseTo(initialRightPanelRect.left, 1); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("loads file previews from the active thread worktree", async () => { + const worktreePath = "/repo/worktrees/file-preview-thread"; + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-worktree-file-preview" as MessageId, + targetText: "open the worktree file preview", + }); + const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); + if (!targetThread) { + throw new Error("Missing target thread fixture."); + } + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, worktreePath } : thread, + ), + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.projectsListEntries) { + return { entries: [{ path: "src/index.ts", kind: "file" }], truncated: false }; + } + if (body._tag === WS_METHODS.projectsReadFile) { + return { + relativePath: "src/index.ts", + contents: "export const worktree = true;\n", + byteLength: 30, + truncated: false, + }; + } + return undefined; + }, + }); + + try { + useRightPanelStore.getState().open(THREAD_REF, "files"); + await waitForElement( + () => document.querySelector("[data-file-browser-panel]"), + "Unable to find the worktree file explorer.", + ); + + useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); + await waitForElement( + () => document.querySelector(".file-preview-virtualizer"), + "Unable to find the worktree file preview.", + ); + + const listRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.projectsListEntries, + ); + const readRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.projectsReadFile, + ); + expect(listRequest).toMatchObject({ cwd: worktreePath }); + expect(readRequest).toMatchObject({ cwd: worktreePath, relativePath: "src/index.ts" }); + } finally { + await mounted.cleanup(); + } + }); + + it("scrolls file tabs and preserves the workspace explorer across file previews", async () => { + const workspaceEntries = [ + { path: "src", kind: "directory" as const }, + { path: "src/index.ts", kind: "file" as const }, + { path: "src/router.ts", kind: "file" as const }, + { path: "src/store.ts", kind: "file" as const }, + { path: "src/styles.css", kind: "file" as const }, + { path: "src/large.ts", kind: "file" as const }, + { path: "e2e", kind: "directory" as const }, + { path: "e2e/test-results", kind: "directory" as const }, + { + path: "e2e/test-results/playwright-integration-results", + kind: "directory" as const, + }, + { + path: "e2e/test-results/playwright-integration-results/chromium-desktop-project", + kind: "directory" as const, + }, + { + path: "e2e/test-results/playwright-integration-results/chromium-desktop-project/.last-run.json", + kind: "file" as const, + }, + { path: "README.md", kind: "file" as const }, + { path: "AGENTS.md", kind: "file" as const }, + { path: "package.json", kind: "file" as const }, + { path: "tsconfig.json", kind: "file" as const }, + ]; + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-file-tabs-and-tree-state" as MessageId, + targetText: "keep file tabs readable and preserve tree state", + }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.projectsListEntries) { + return { entries: workspaceEntries, truncated: false }; + } + if (body._tag === WS_METHODS.projectsReadFile) { + const relativePath = + typeof body.relativePath === "string" ? body.relativePath : "file.ts"; + const contents = + relativePath === "src/large.ts" + ? Array.from( + { length: 5_000 }, + (_, index) => `export const line${index + 1} = ${index + 1};`, + ).join("\n") + : `// ${relativePath}\n`; + return { + relativePath, + contents, + byteLength: new TextEncoder().encode(contents).byteLength, + truncated: false, + }; + } + return undefined; + }, + }); + + try { + useRightPanelStore.getState().open(THREAD_REF, "files"); + + const explorer = await waitForElement( + () => document.querySelector("[data-file-browser-panel]"), + "Unable to find the workspace file explorer.", + ); + + for (const entry of workspaceEntries) { + if (entry.kind === "file") { + useRightPanelStore.getState().openFile(THREAD_REF, entry.path); + } + } + + const tabList = await waitForElement( + () => document.querySelector("[data-right-panel-tab-list]"), + "Unable to find the right panel tab list.", + ); + const tabViewport = await waitForElement( + () => tabList.querySelector('[data-slot="scroll-area-viewport"]'), + "Unable to find the right panel tab viewport.", + ); + + await vi.waitFor(() => { + const fileTabs = Array.from(tabList.querySelectorAll("[data-active-tab]")); + expect(fileTabs.length).toBe( + workspaceEntries.filter((entry) => entry.kind === "file").length, + ); + expect(tabViewport.scrollWidth).toBeGreaterThan(tabViewport.clientWidth); + expect(tabViewport.scrollLeft).toBeGreaterThan(0); + expect(tabList.querySelector('[data-slot="scroll-area-scrollbar"]')).toBeNull(); + expect( + fileTabs.every((tab) => { + const width = tab.getBoundingClientRect().width; + return width >= 100 && width <= 176; + }), + ).toBe(true); + expect(document.querySelector("[data-file-browser-panel]")).toBe(explorer); + }); + + useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); + await vi.waitFor(() => { + expect(document.querySelector("[data-file-browser-panel]")).toBe(explorer); + }); + + useRightPanelStore + .getState() + .openFile( + THREAD_REF, + "e2e/test-results/playwright-integration-results/chromium-desktop-project/.last-run.json", + ); + await mounted.setContainerSize({ width: 800, height: WIDE_FOOTER_VIEWPORT.height }); + const breadcrumbs = await waitForElement( + () => document.querySelector("[data-file-breadcrumbs]"), + "Unable to find the responsive file breadcrumbs.", + ); + const fileSubheader = breadcrumbs.closest("[data-surface-subheader]"); + const breadcrumbViewport = await waitForElement( + () => breadcrumbs.querySelector('[data-slot="scroll-area-viewport"]'), + "Unable to find the file breadcrumb viewport.", + ); + const currentCrumb = await waitForElement( + () => + Array.from( + breadcrumbs.querySelectorAll("[data-current-file-crumb='true']"), + ).find((crumb) => crumb.textContent === ".last-run.json") ?? null, + "Unable to find the current file breadcrumb.", + ); + const explorerToggle = await waitForElement( + () => document.querySelector('button[aria-label="Hide file explorer"]'), + "Unable to find the file explorer toggle.", + ); + + await vi.waitFor(() => { + const viewportRect = breadcrumbViewport.getBoundingClientRect(); + const currentCrumbRect = currentCrumb.getBoundingClientRect(); + expect(breadcrumbViewport.scrollWidth).toBeGreaterThan(breadcrumbViewport.clientWidth); + expect(breadcrumbViewport.scrollLeft).toBeGreaterThan(0); + expect(breadcrumbs.querySelector('[data-slot="scroll-area-scrollbar"]')).toBeNull(); + expect(currentCrumbRect.right).toBeLessThanOrEqual(viewportRect.right + 1); + expect(viewportRect.right).toBeLessThan(explorerToggle.getBoundingClientRect().left); + expect(explorerToggle.getAttribute("aria-pressed")).toBe("true"); + expect(explorerToggle.getBoundingClientRect().width).toBe(28); + expect(explorerToggle.getBoundingClientRect().height).toBe(28); + expect(fileSubheader?.getBoundingClientRect().height).toBe(40); + expect(window.getComputedStyle(fileSubheader!).borderTopWidth).toBe("0px"); + expect(window.getComputedStyle(fileSubheader!).borderBottomWidth).toBe("1px"); + }); + + const fileSearchButton = await waitForElement( + () => + document.querySelector('button[aria-label="Search workspace files"]'), + "Unable to find the workspace file search button.", + ); + fileSearchButton.click(); + const fileTree = await waitForElement( + () => document.querySelector("file-tree-container"), + "Unable to find the file tree host.", + ); + const fileSearchInput = await waitForElement( + () => + fileTree.shadowRoot?.querySelector("[data-file-tree-search-input]") ?? + null, + "Unable to find the file tree search input.", + ); + fileSearchInput.focus(); + const searchKeyEvent = new KeyboardEvent("keydown", { + key: "r", + bubbles: true, + cancelable: true, + composed: true, + }); + fileSearchInput.dispatchEvent(searchKeyEvent); + await waitForLayout(); + expect(searchKeyEvent.defaultPrevented).toBe(false); + expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput); + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); + + useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts"); + const codeVirtualizer = await waitForElement( + () => document.querySelector(".file-preview-virtualizer"), + "Unable to find the virtualized file preview.", + ); + expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull(); + expect(codeVirtualizer.classList.contains("overflow-auto")).toBe(true); + } finally { + await mounted.cleanup(); + } + }); + + it("removes persisted file tabs when a draft workspace no longer exists", async () => { + const orphanedDraftId = DraftId.make("draft-orphaned-file-panel"); + const orphanedThreadId = "thread-orphaned-file-panel" as ThreadId; + const orphanedThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, orphanedThreadId); + useComposerDraftStore.getState().setProjectDraftThreadId( + { + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: "project-deleted" as ProjectId, + }, + orphanedDraftId, + { threadId: orphanedThreadId }, + ); + useRightPanelStore.getState().openFile(orphanedThreadRef, "conductor.json"); + + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-orphaned-file-panel" as MessageId, + targetText: "orphaned persisted file panel", + }), + initialPath: `/draft/${orphanedDraftId}`, + }); + + try { + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, orphanedThreadRef), + ).toEqual({ + isOpen: false, + activeSurfaceId: null, + surfaces: [], + }); + expect(document.querySelector("[data-right-panel-tabbar]")).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, + targetText: "open inline terminal panel", + }), + }); + + try { + const rightPanelToggle = await waitForElement( + () => document.querySelector('button[aria-label="Toggle right panel"]'), + "Unable to find right panel toggle.", + ); + rightPanelToggle.click(); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Open a surface"); + }); + expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false); + + const emptyStateTerminalButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes("Start a shell in this workspace."), + ) ?? null, + "Unable to find the empty-state Terminal button.", + ); + emptyStateTerminalButton.click(); + + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .surfaces.filter((surface) => surface.kind === "terminal") + .map((surface) => surface.resourceId), + ).toEqual(["term-1"]); + }); + + const addSurface = await waitForElement( + () => document.querySelector('button[aria-label="Add panel surface"]'), + "Unable to find add panel surface button beside the tabs.", + ); + addSurface.click(); + const secondTerminalItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent?.trim() === "Terminal", + ) ?? null, + "Unable to find Terminal panel menu item.", + ); + secondTerminalItem.click(); + + await vi.waitFor( + () => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .surfaces.filter((surface) => surface.kind === "terminal") + .map((surface) => surface.resourceId), + ).toEqual(["term-1", "term-2"]); + expect( + document.querySelector('[data-preview-panel-mode="inline"] .thread-terminal-drawer'), + ).not.toBeNull(); + expect( + wsRequests + .filter((request) => request._tag === WS_METHODS.terminalOpen) + .map((request) => ("terminalId" in request ? request.terminalId : null)), + ).toEqual(expect.arrayContaining(["term-1", "term-2"])); + const attachRequest = wsRequests.find( + (request) => + request._tag === WS_METHODS.terminalAttach && + "terminalId" in request && + request.terminalId === "term-2", + ); + expect(attachRequest).toMatchObject({ + _tag: WS_METHODS.terminalAttach, + threadId: THREAD_ID, + terminalId: "term-2", + cwd: "/repo/project", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + const drawerToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle terminal drawer"]'), + "Unable to find terminal drawer toggle.", + ); + drawerToggle.click(); + + await vi.waitFor(() => { + expect( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey[THREAD_KEY], + ).toMatchObject({ + terminalOpen: true, + terminalIds: ["term-3"], + }); + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.terminalAttach && + "terminalId" in request && + request.terminalId === "term-3", + ), + ).toBe(true); + }); + } finally { + await mounted.cleanup(); + } + }); + it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { setDraftThreadWithoutWorktree(); @@ -3221,6 +3850,76 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("uses the configured diff toggle binding without discarding its surface", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-target-diff-hotkey" as MessageId, + targetText: "diff hotkey target", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "diff.toggle", + shortcut: { + key: "g", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: true, + modKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + dispatchConfiguredDiffToggleShortcut(); + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ + isOpen: true, + activeSurfaceId: "diff", + surfaces: [{ id: "diff", kind: "diff" }], + }); + }); + + dispatchConfiguredDiffToggleShortcut(); + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ + isOpen: false, + activeSurfaceId: "diff", + surfaces: [{ id: "diff", kind: "diff" }], + }); + }); + + dispatchConfiguredDiffToggleShortcut(); + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ + isOpen: true, + activeSurfaceId: "diff", + surfaces: [{ id: "diff", kind: "diff" }], + }); + }); + } finally { + await mounted.cleanup(); + } + }); + it("focuses the composer and inserts printable text typed from the page background", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -3733,7 +4432,6 @@ describe("ChatView timeline estimator parity (full app)", () => { { path: "path/to/package.json", kind: "file", - parentPath: "path/to", }, ], truncated: false, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b3950c4b1d3..0a7810929f9 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,6 +12,7 @@ import { type EnvironmentState, useStore } from "../store"; import { type Thread } from "../types"; import { + MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, @@ -19,6 +20,7 @@ import { getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + reconcileRetainedMountedThreadIds, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, @@ -73,6 +75,30 @@ describe("deriveComposerSendState", () => { expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); + + it("treats element contexts as sendable content (no text, no images, no terminals)", () => { + const state = deriveComposerSendState({ + prompt: "", + imageCount: 0, + terminalContexts: [], + elementContextCount: 1, + }); + + expect(state.trimmedPrompt).toBe(""); + expect(state.expiredTerminalContextCount).toBe(0); + expect(state.hasSendableContent).toBe(true); + }); + + it("does NOT treat zero element contexts as sendable", () => { + expect( + deriveComposerSendState({ + prompt: "", + imageCount: 0, + terminalContexts: [], + elementContextCount: 0, + }).hasSendableContent, + ).toBe(false); + }); }); describe("buildExpiredTerminalContextToastCopy", () => { @@ -250,6 +276,50 @@ describe("reconcileMountedTerminalThreadIds", () => { }); }); +describe("reconcileRetainedMountedThreadIds", () => { + it("retains hidden open threads and adds the active open thread", () => { + expect( + reconcileRetainedMountedThreadIds({ + currentThreadIds: [ThreadId.make("thread-hidden")], + openThreadIds: [ThreadId.make("thread-hidden")], + activeThreadId: ThreadId.make("thread-active"), + activeThreadOpen: true, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, + }), + ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); + }); + + it("can retain the active thread as hidden when it is inactive", () => { + expect( + reconcileRetainedMountedThreadIds({ + currentThreadIds: [ThreadId.make("thread-active")], + openThreadIds: [ThreadId.make("thread-active")], + activeThreadId: ThreadId.make("thread-active"), + activeThreadOpen: false, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, + retainInactiveActiveThread: true, + }), + ).toEqual([ThreadId.make("thread-active")]); + }); + + it("evicts the oldest hidden threads beyond the configured cap", () => { + const currentThreadIds = Array.from( + { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 }, + (_, index) => ThreadId.make(`thread-${index + 1}`), + ); + + expect( + reconcileRetainedMountedThreadIds({ + currentThreadIds, + openThreadIds: currentThreadIds, + activeThreadId: null, + activeThreadOpen: false, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, + }), + ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_PREVIEW_THREADS)); + }); +}); + describe("shouldWriteThreadErrorToCurrentServerThread", () => { it("routes errors to the active server thread when route and target match", () => { const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 11d44ef1f54..8e78c0f3a3e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -22,6 +22,7 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; +export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -80,15 +81,31 @@ export function reconcileMountedTerminalThreadIds(input: { activeThreadId: string | null; activeThreadTerminalOpen: boolean; maxHiddenThreadCount?: number; +}): string[] { + return reconcileRetainedMountedThreadIds({ + currentThreadIds: input.currentThreadIds, + openThreadIds: input.openThreadIds, + activeThreadId: input.activeThreadId, + activeThreadOpen: input.activeThreadTerminalOpen, + maxHiddenThreadCount: input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + }); +} + +export function reconcileRetainedMountedThreadIds(input: { + currentThreadIds: ReadonlyArray; + openThreadIds: ReadonlyArray; + activeThreadId: string | null; + activeThreadOpen: boolean; + maxHiddenThreadCount: number; + retainInactiveActiveThread?: boolean; }): string[] { const openThreadIdSet = new Set(input.openThreadIds); const hiddenThreadIds = input.currentThreadIds.filter( - (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), - ); - const maxHiddenThreadCount = Math.max( - 0, - input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + (threadId) => + (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) && + openThreadIdSet.has(threadId), ); + const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount); const nextThreadIds = hiddenThreadIds.length > maxHiddenThreadCount ? hiddenThreadIds.slice(-maxHiddenThreadCount) @@ -96,7 +113,7 @@ export function reconcileMountedTerminalThreadIds(input: { if ( input.activeThreadId && - input.activeThreadTerminalOpen && + input.activeThreadOpen && !nextThreadIds.includes(input.activeThreadId) ) { nextThreadIds.push(input.activeThreadId); @@ -186,6 +203,12 @@ export function deriveComposerSendState(options: { prompt: string; imageCount: number; terminalContexts: ReadonlyArray; + /** + * Optional element-pick attachment count. Element contexts contribute to + * "sendable content" exactly like images and (text-bearing) terminal + * contexts do: a prompt of just element chips is still a valid send. + */ + elementContextCount?: number; }): { trimmedPrompt: string; sendableTerminalContexts: TerminalContextDraft[]; @@ -196,12 +219,16 @@ export function deriveComposerSendState(options: { const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); const expiredTerminalContextCount = options.terminalContexts.length - sendableTerminalContexts.length; + const elementContextCount = options.elementContextCount ?? 0; return { trimmedPrompt, sendableTerminalContexts, expiredTerminalContextCount, hasSendableContent: - trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, + trimmedPrompt.length > 0 || + options.imageCount > 0 || + sendableTerminalContexts.length > 0 || + elementContextCount > 0, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cebd1998f65..c22179d6ae3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,29 +21,25 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; -import { - parseScopedThreadKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { stripManagedRuntimeEnvKeys } from "@t3tools/shared/launchEnv"; -import { projectScriptCwd } from "@t3tools/shared/projectScripts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useVcsStatus } from "~/lib/vcsStatusState"; -import { usePrimaryEnvironmentId } from "../environments/primary"; +import { usePrimaryEnvironmentId } from "../environments/primary/context"; import { readEnvironmentApi } from "../environmentApi"; +import { resolveAssetUrl } from "../assets/assetUrls"; import { isElectron } from "../env"; +import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -73,11 +69,7 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { - selectProjectsAcrossEnvironments, - selectThreadsAcrossEnvironments, - useStore, -} from "../store"; +import { selectEnvironmentState, selectProjectsAcrossEnvironments, useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { @@ -99,13 +91,38 @@ import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { formatGoalStatusToastDescription, goalStatusToastTitle } from "../goalPresentation"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; +import { + selectActiveRightPanelKindWithUrl, + selectActiveRightPanelSurface, + selectThreadRightPanelState, + type RightPanelSurface, + useRightPanelStore, +} from "../rightPanelStore"; +import { + isPreviewSupportedInRuntime, + selectThreadPreviewState, + usePreviewStateStore, +} from "../previewStateStore"; +import { subscribePreviewAction } from "./preview/previewActionBus"; +import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +// Lazy: keeps the entire preview component graph (webview host, favicon +// helper, Chromium error icon) out of the web bundle until first open. +const PreviewPanel = lazy(() => + import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), +); +const DiffPanel = lazy(() => import("./DiffPanel")); +const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); +const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { RightPanelTabs } from "./RightPanelTabs"; +import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -119,16 +136,16 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; -import { isTerminalFocused } from "../lib/terminalFocus"; +import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; import { - reconnectSavedEnvironment, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; +} from "../environments/runtime/catalog"; +import { reconnectSavedEnvironment } from "../environments/runtime/service"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -142,14 +159,20 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; -import { formatGoalStatusToastDescription, goalStatusToastTitle } from "../goalPresentation"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; +import { + appendElementContextsToPrompt, + type ElementContextDraft, + formatElementContextLabel, +} from "../lib/elementContext"; +import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; +import { PanelLayoutControls } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -157,7 +180,6 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, @@ -172,7 +194,6 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, - reconcileMountedTerminalThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -241,6 +262,14 @@ type EnvironmentUnavailableState = { type ThreadPlanCatalogEntry = Pick; +function eventPathContainsSelector(event: Event, selector: string): boolean { + const path = event.composedPath(); + if (path.length === 0 && event.target) { + path.push(event.target); + } + return path.some((target) => target instanceof Element && target.closest(selector)); +} + function showGoalStatusToast(goal: Thread["goal"]): void { if (!goal) { toastManager.add({ @@ -256,20 +285,13 @@ function showGoalStatusToast(goal: Thread["goal"]): void { }); } -function eventTargetElement(target: EventTarget | null): Element | null { - if (target instanceof Element) return target; - if (target instanceof Node) return target.parentElement; - return null; -} - function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (event.defaultPrevented || event.isComposing) return false; if (event.metaKey || event.ctrlKey || event.altKey) return false; if (event.key.length !== 1) return false; - const target = eventTargetElement(event.target); - if (target?.closest(TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; - if (target?.closest(TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; + if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; + if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; return true; @@ -538,9 +560,11 @@ interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; + mode?: "drawer" | "panel"; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; @@ -551,9 +575,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadRef, threadId, visible, + mode = "drawer", launchContext, focusRequestId, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, keybindings, @@ -574,29 +600,47 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra environmentId: threadRef.environmentId, threadId, }); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const panelTerminalIds = useMemo( + () => + new Set( + panelSurfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [panelSurfaces], + ); + const drawerTerminalSessions = useMemo( + () => + knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), + [knownTerminalSessions, panelTerminalIds], + ); const terminalLabelsById = useMemo(() => { const next = new Map(); - for (const session of knownTerminalSessions) { + for (const session of drawerTerminalSessions) { next.set( session.target.terminalId, resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } return next; - }, [knownTerminalSessions]); + }, [drawerTerminalSessions]); const terminalLaunchLocationsById = useMemo(() => { const next = new Map< string, { readonly cwd: string; readonly worktreePath: string | null; + readonly runtimeEnv: Record; } >(); if (!project) { return next; } - for (const session of knownTerminalSessions) { + for (const session of drawerTerminalSessions) { const summary = session.state.summary; if (!summary) { continue; @@ -606,17 +650,24 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra next.set(session.target.terminalId, { cwd: launchContext?.cwd ?? summary.cwd, worktreePath: worktreePathForLaunch, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath: worktreePathForLaunch, + }), }); } return next; - }, [knownTerminalSessions, launchContext, project]); + }, [drawerTerminalSessions, launchContext, project]); const serverOrderedTerminalIds = useMemo( - () => knownTerminalSessions.map((session) => session.target.terminalId), - [knownTerminalSessions], + () => drawerTerminalSessions.map((session) => session.target.terminalId), + [drawerTerminalSessions], ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore( + (state) => state.splitTerminalVertical, + ); const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); @@ -652,6 +703,17 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra : null), [effectiveWorktreePath, launchContext?.cwd, project], ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath: effectiveWorktreePath, + }) + : {}, + [effectiveWorktreePath, project], + ); + const bumpFocusRequestId = useCallback(() => { if (!visible) { return; @@ -668,7 +730,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const splitTerminal = useCallback(() => { const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd || !project) { + if (!api || !cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); @@ -679,9 +741,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra await api.terminal.open({ threadId, terminalId, - projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. @@ -691,16 +753,43 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, + runtimeEnv, serverOrderedTerminalIds, storeSplitTerminal, threadId, threadRef, - project, + ]); + const splitTerminalVertical = useCallback(() => { + const api = readEnvironmentApi(threadRef.environmentId); + if (!api || !cwd) { + return; + } + const terminalId = nextTerminalId(serverOrderedTerminalIds); + storeSplitTerminalVertical(threadRef, terminalId); + bumpFocusRequestId(); + void api.terminal + .open({ + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }) + .catch(() => undefined); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + serverOrderedTerminalIds, + storeSplitTerminalVertical, + threadId, + threadRef, ]); const createNewTerminal = useCallback(() => { const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd || !project) { + if (!api || !cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); @@ -711,9 +800,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra await api.terminal.open({ threadId, terminalId, - projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. @@ -723,11 +812,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, + runtimeEnv, serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, - project, ]); const activateTerminal = useCallback( @@ -782,13 +871,15 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra } return ( -
+
; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; + onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; +} + +const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ + threadRef, + surface, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, + onSplitTerminal, + onSplitTerminalVertical, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, +}: PersistentThreadTerminalPanelProps) { + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const terminalSummary = + knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) + ?.state.summary ?? null; + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const worktreePath = + launchContext?.worktreePath ?? terminalSummary?.worktreePath ?? threadWorktreePath; + const cwd = useMemo( + () => + launchContext?.cwd ?? + terminalSummary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.cwd }, + worktreePath, + }) + : null), + [launchContext?.cwd, project, terminalSummary?.cwd, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + const terminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); + } + return labels; + }, [knownTerminalSessions, surface.terminalIds]); + const terminalLaunchLocationsById = useMemo(() => { + const locations = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + const terminalWorktreePath = + launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.cwd }, + worktreePath: terminalWorktreePath, + }) + : null); + if (!terminalCwd || !project) continue; + locations.set(terminalId, { + cwd: terminalCwd, + worktreePath: terminalWorktreePath, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath: terminalWorktreePath, + }), + }); + } + return locations; + }, [ + knownTerminalSessions, + launchContext?.cwd, + launchContext?.worktreePath, + project, + surface.terminalIds, + threadWorktreePath, + ]); + + if (!project || !cwd) { + return null; + } + + return ( + undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); + +function ChatViewContent(props: ChatViewProps) { const { environmentId, threadId, @@ -868,6 +1133,12 @@ export default function ChatView(props: ChatViewProps) { const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); + const setComposerDraftElementContexts = useComposerDraftStore( + (store) => store.setElementContexts, + ); + const setComposerDraftPreviewAnnotations = useComposerDraftStore( + (store) => store.setPreviewAnnotations, + ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -892,6 +1163,7 @@ export default function ChatView(props: ChatViewProps) { const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); + const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -904,6 +1176,9 @@ export default function ChatView(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( + null, + ); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] @@ -913,7 +1188,6 @@ export default function ChatView(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); @@ -946,43 +1220,12 @@ export default function ChatView(props: ChatViewProps) { const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalUiStateStore( - useShallow((state) => - Object.entries(state.terminalUiStateByThreadKey).flatMap( - ([nextThreadKey, nextTerminalUiState]) => - nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); - const serverThreadKeys = useStore( - useShallow((state) => - selectThreadsAcrossEnvironments(state).map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), - ); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = useMemo( - () => - Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ), - [draftThreadsByThreadKey], - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); - const mountedTerminalThreadRefs = useMemo( - () => - mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }), - [mountedTerminalThreadKeys], - ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1042,40 +1285,88 @@ export default function ChatView(props: ChatViewProps) { () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], [activeServerOrderedTerminalIds, terminalUiState.terminalIds], ); + const activeTerminalLabelsById = useMemo(() => { + const next = new Map(); + for (const session of activeThreadKnownSessions) { + next.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return next; + }, [activeThreadKnownSessions]); const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; - + const activeRightPanelKind = useRightPanelStore((store) => + selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), + ); + const rightPanelState = useRightPanelStore((store) => + selectThreadRightPanelState(store.byThreadKey, activeThreadRef), + ); + const activeRightPanelSurface = useRightPanelStore((store) => + selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), + ); + const activePreviewState = usePreviewStateStore((state) => + selectThreadPreviewState(state.byThreadKey, activeThreadRef), + ); + const panelTerminalIds = useMemo( + () => + new Set( + rightPanelState.surfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [rightPanelState.surfaces], + ); + const drawerServerOrderedTerminalIds = useMemo( + () => activeServerOrderedTerminalIds.filter((terminalId) => !panelTerminalIds.has(terminalId)), + [activeServerOrderedTerminalIds, panelTerminalIds], + ); useEffect(() => { if (!activeThreadRef) { return; } - if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { + if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) { return; } if ( serverTerminalIdsStrictSubsetOfClient( - activeServerOrderedTerminalIds, + drawerServerOrderedTerminalIds, terminalUiState.terminalIds, ) ) { return; } - reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); + reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds); }, [ activeThreadRef, - activeServerOrderedTerminalIds, + drawerServerOrderedTerminalIds, reconcileTerminalIds, terminalUiState.terminalIds, ]); + const planSidebarOpen = activeRightPanelKind === "plan"; + const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); + const rightPanelOpen = rightPanelState.isOpen; + const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const rightPanelMaximized = + canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; - const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); + useEffect(() => { + if (!activeThreadRef) return; + useRightPanelStore + .getState() + .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); + }, [activePreviewState.sessions, activeThreadRef]); + + useEffect(() => { + if (!activeThreadRef || !diffOpen) return; + useRightPanelStore.getState().open(activeThreadRef, "diff"); + }, [activeThreadRef, diffOpen]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -1090,21 +1381,6 @@ export default function ChatView(props: ChatViewProps) { return threadIds; }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), ); - useEffect(() => { - setMountedTerminalThreadKeys((currentThreadIds) => { - const nextThreadIds = reconcileMountedTerminalThreadIds({ - currentThreadIds, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - return currentThreadIds.length === nextThreadIds.length && - currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) - ? currentThreadIds - : nextThreadIds; - }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -1112,6 +1388,57 @@ export default function ChatView(props: ChatViewProps) { const activeProject = useStore( useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); + const activeProjectKey = activeProject + ? `${activeProject.environmentId}:${activeProject.cwd}` + : null; + const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< + ReadonlyMap> + >(() => new Map()); + const pendingFileSurfaceIds = activeProjectKey + ? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS) + : EMPTY_PENDING_FILE_SURFACE_IDS; + const handleFilePendingChange = useCallback( + (relativePath: string, pending: boolean) => { + if (!activeProjectKey) return; + setPendingFileSurfaceIdsByProject((currentByProject) => { + const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; + const surfaceId = `file:${relativePath}`; + if (current.has(surfaceId) === pending) return currentByProject; + + const next = new Set(current); + if (pending) { + next.add(surfaceId); + } else { + next.delete(surfaceId); + } + + const nextByProject = new Map(currentByProject); + if (next.size === 0) { + nextByProject.delete(activeProjectKey); + } else { + nextByProject.set(activeProjectKey, next); + } + return nextByProject; + }); + }, + [activeProjectKey], + ); + const activeEnvironmentBootstrapComplete = useStore((state) => + activeThread + ? selectEnvironmentState(state, activeThread.environmentId).bootstrapComplete + : false, + ); + const configuredPreviewUrls = useMemo( + () => getConfiguredPreviewUrls(activeProject?.scripts), + [activeProject?.scripts], + ); + + useEffect(() => { + if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; + useRightPanelStore + .getState() + .reconcileFileSurfaces(activeThreadRef, activeProject !== undefined); + }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); useEffect(() => { if (routeKind !== "server") { @@ -1675,8 +2002,64 @@ export default function ChatView(props: ChatViewProps) { }); }, []); const serverMessages = activeThread?.messages; + const [attachmentAssetUrlById, setAttachmentAssetUrlById] = useState>({}); + useEffect(() => { + if (!serverMessages) return; + const attachmentIds = [ + ...new Set( + serverMessages.flatMap( + (message) => + message.attachments?.flatMap((attachment) => + attachment.type === "image" && !attachment.previewUrl ? [attachment.id] : [], + ) ?? [], + ), + ), + ].filter((attachmentId) => !attachmentAssetUrlById[attachmentId]); + if (attachmentIds.length === 0) return; + + let cancelled = false; + void Promise.all( + attachmentIds.map(async (attachmentId) => { + const asset = await resolveAssetUrl(environmentId, { + _tag: "attachment", + attachmentId, + }); + return [attachmentId, asset.url] as const; + }), + ) + .then((entries) => { + if (!cancelled) { + setAttachmentAssetUrlById((current) => ({ ...current, ...Object.fromEntries(entries) })); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, [attachmentAssetUrlById, environmentId, serverMessages]); + const serverMessagesWithAssetUrls = useMemo(() => { + if (!serverMessages || Object.keys(attachmentAssetUrlById).length === 0) { + return serverMessages; + } + return serverMessages.map((message) => { + if (!message.attachments) return message; + let changed = false; + const attachments = message.attachments.map((attachment) => { + const previewUrl = attachmentAssetUrlById[attachment.id]; + if (!previewUrl || attachment.previewUrl === previewUrl) return attachment; + changed = true; + return { ...attachment, previewUrl }; + }); + return changed ? { ...message, attachments } : message; + }); + }, [attachmentAssetUrlById, serverMessages]); useEffect(() => { - if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { + if ( + typeof Image === "undefined" || + !serverMessagesWithAssetUrls || + serverMessagesWithAssetUrls.length === 0 + ) { return; } @@ -1689,7 +2072,7 @@ export default function ChatView(props: ChatViewProps) { continue; } - const serverMessage = serverMessages.find( + const serverMessage = serverMessagesWithAssetUrls.find( (message) => message.id === messageId && message.role === "user", ); if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { @@ -1755,9 +2138,13 @@ export default function ChatView(props: ChatViewProps) { cleanup(); } }; - }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); + }, [ + attachmentPreviewHandoffByMessageId, + clearAttachmentPreviewHandoff, + serverMessagesWithAssetUrls, + ]); const timelineMessages = useMemo(() => { - const messages = serverMessages ?? []; + const messages = serverMessagesWithAssetUrls ?? []; const serverMessagesWithPreviewHandoff = Object.keys(attachmentPreviewHandoffByMessageId).length === 0 ? messages @@ -1808,7 +2195,7 @@ export default function ChatView(props: ChatViewProps) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + }, [serverMessagesWithAssetUrls, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), @@ -1904,23 +2291,17 @@ export default function ChatView(props: ChatViewProps) { }), [terminalUiState.terminalOpen], ); - const nonTerminalShortcutLabelOptions = useMemo( - () => ({ - context: { - terminalFocus: false, - terminalOpen: Boolean(terminalUiState.terminalOpen), - }, - }), - [terminalUiState.terminalOpen], - ); - const terminalToggleShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.toggle"), - [keybindings], - ); + const terminalToggleShortcutLabel = shortcutLabelForCommand(keybindings, "terminal.toggle"); + const rightPanelToggleShortcutLabel = shortcutLabelForCommand(keybindings, "rightPanel.toggle"); const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); + const splitTerminalVerticalShortcutLabel = useMemo( + () => + shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], + ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], @@ -1929,17 +2310,95 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); - const diffPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), - [keybindings, nonTerminalShortcutLabelOptions], - ); + const createBrowserSurface = useCallback(() => { + if (!activeThreadRef) return; + const api = readEnvironmentApi(activeThreadRef.environmentId); + if (!api) return; + void api.preview + .open({ threadId: activeThreadRef.threadId }) + .then((snapshot) => { + usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); + useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); + }) + .catch(() => undefined); + }, [activeThreadRef]); + const addDiffSurface = useCallback(() => { + if (!activeThreadRef || !isServerThread || !isGitRepo) return; + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), + }); + }, [ + activeThreadRef, + environmentId, + isGitRepo, + isServerThread, + navigate, + onDiffPanelOpen, + threadId, + ]); + const addFilesSurface = () => { + if (!activeThreadRef || !activeProject) return; + useRightPanelStore.getState().open(activeThreadRef, "files"); + }; + const openFileSurface = (relativePath: string) => { + if (!activeThreadRef || !activeProject) return; + useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + }; + // Right-panel arbitration: + // - The diff panel's openness is mirrored by the `?diff=1` URL search + // param so it deep-links cleanly. The store still records preview/plan + // openness; when both fight, `selectActiveRightPanelKindWithUrl` lets + // diff win (URL is truth). + // - The two toggles below treat the panels as mutually exclusive: opening + // one updates BOTH the URL and the store. Without this, e.g. clicking the + // browser button while diff is URL-pinned would just toggle the + // (overridden) store value and look like a no-op. + const onTogglePreview = useCallback(() => { + if (!activeThreadRef) return; + if (previewPanelOpen) { + useRightPanelStore.getState().close(activeThreadRef); + return; + } + if (diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + const activeTabId = activePreviewState.activeTabId; + if (!activeTabId) { + createBrowserSurface(); + return; + } + useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); + }, [ + activePreviewState.activeTabId, + activeThreadRef, + createBrowserSurface, + diffOpen, + environmentId, + navigate, + previewPanelOpen, + threadId, + ]); const onToggleDiff = useCallback(() => { if (!isServerThread) { return; } - if (!diffOpen) { + const diffPanelOpen = activeRightPanelKind === "diff"; + if (!diffPanelOpen) { onDiffPanelOpen?.(); } + if (activeThreadRef) { + useRightPanelStore.getState().toggle(activeThreadRef, "diff"); + } void navigate({ to: "/$environmentId/$threadId", params: { @@ -1949,10 +2408,28 @@ export default function ChatView(props: ChatViewProps) { replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; + return diffPanelOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); - }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); + }, [ + activeRightPanelKind, + activeThreadRef, + environmentId, + isServerThread, + navigate, + onDiffPanelOpen, + threadId, + ]); + + // Route the global mod+shift+J shortcut (dispatched from `routes/_chat.tsx` + // via `previewActionBus`) through the URL-aware toggle defined above. + useEffect(() => { + return subscribePreviewAction((action) => { + if (action !== "toggle-panel") return; + if (!isPreviewSupportedInRuntime()) return; + onTogglePreview(); + }); + }, [onTogglePreview]); const envLocked = Boolean( activeThread && @@ -2031,53 +2508,184 @@ export default function ChatView(props: ChatViewProps) { const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; + if (open && terminalUiState.terminalIds.length === 0) { + storeNewTerminal( + activeThreadRef, + nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]), + ); + return; + } storeSetTerminalOpen(activeThreadRef, open); }, - [activeThreadRef, storeSetTerminalOpen], + [ + activeKnownTerminalIds, + activeThreadRef, + panelTerminalIds, + storeNewTerminal, + storeSetTerminalOpen, + terminalUiState.terminalIds.length, + ], ); - const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadRef) return; - setTerminalOpen(!terminalUiState.terminalOpen); - }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); - const splitTerminal = useCallback(() => { - if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { - return; - } - const cwdForOpen = gitCwd ?? activeProject.cwd; - if (!cwdForOpen) { - return; - } - const api = readEnvironmentApi(environmentId); - if (!api) { - return; - } - const terminalId = nextTerminalId(activeKnownTerminalIds); - storeSplitTerminal(activeThreadRef, terminalId); + const addTerminalSurface = useCallback(() => { + if (!activeThreadRef || !activeThreadId || !activeProject) return; + const api = readEnvironmentApi(activeThreadRef.environmentId); + const cwd = gitCwd ?? activeProject.cwd; + if (!api || !cwd) return; + const panelIds = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ).surfaces.flatMap((surface) => (surface.kind === "terminal" ? surface.terminalIds : [])); + const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelIds]); + useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - void (async () => { - try { - await api.terminal.open({ - threadId: activeThreadId, - terminalId, - projectId: activeProject.id, - cwd: cwdForOpen, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - }); - } catch { - // Opening failed; the tab is already in the store — user can retry or close it. - } - })(); + void api.terminal + .open({ + threadId: activeThreadId, + terminalId, + cwd, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }) + .catch(() => undefined); }, [ - activeProject, activeKnownTerminalIds, + activeProject, activeThreadId, activeThreadRef, activeThreadWorktreePath, - environmentId, gitCwd, - hasReachedSplitLimit, - storeSplitTerminal, ]); + const splitPanelTerminal = useCallback( + (direction: "horizontal" | "vertical" = "horizontal") => { + if ( + !activeThreadRef || + !activeThreadId || + !activeProject || + activeRightPanelSurface?.kind !== "terminal" || + activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP + ) { + return; + } + const api = readEnvironmentApi(activeThreadRef.environmentId); + const cwd = gitCwd ?? activeProject.cwd; + if (!api || !cwd) return; + const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); + useRightPanelStore + .getState() + .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); + setTerminalFocusRequestId((value) => value + 1); + void api.terminal + .open({ + threadId: activeThreadId, + terminalId, + cwd, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }) + .catch(() => undefined); + }, + [ + activeKnownTerminalIds, + activeProject, + activeRightPanelSurface, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + gitCwd, + panelTerminalIds, + ], + ); + const splitPanelTerminalVertical = useCallback(() => { + splitPanelTerminal("vertical"); + }, [splitPanelTerminal]); + const activatePanelTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + useRightPanelStore + .getState() + .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeRightPanelSurface, activeThreadRef], + ); + const closePanelTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + const api = readEnvironmentApi(activeThreadRef.environmentId); + void api?.terminal + .close({ + threadId: activeThreadRef.threadId, + terminalId, + deleteHistory: true, + }) + .catch(() => undefined); + useRightPanelStore + .getState() + .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeRightPanelSurface, activeThreadRef], + ); + const toggleTerminalVisibility = useCallback(() => { + if (!activeThreadRef) return; + setTerminalOpen(!terminalUiState.terminalOpen); + }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); + const splitTerminal = useCallback( + (direction: "horizontal" | "vertical" = "horizontal") => { + if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + return; + } + const cwdForOpen = gitCwd ?? activeProject.cwd; + if (!cwdForOpen) { + return; + } + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + const terminalId = nextTerminalId(activeKnownTerminalIds); + if (direction === "vertical") { + storeSplitTerminalVertical(activeThreadRef, terminalId); + } else { + storeSplitTerminal(activeThreadRef, terminalId); + } + setTerminalFocusRequestId((value) => value + 1); + void (async () => { + try { + await api.terminal.open({ + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }); + } catch { + // Opening failed; the tab is already in the store — user can retry or close it. + } + })(); + }, + [ + activeProject, + activeKnownTerminalIds, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + environmentId, + gitCwd, + hasReachedSplitLimit, + storeSplitTerminal, + storeSplitTerminalVertical, + ], + ); const createNewTerminal = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) { return; @@ -2098,9 +2706,12 @@ export default function ChatView(props: ChatViewProps) { await api.terminal.open({ threadId: activeThreadId, terminalId, - projectId: activeProject.id, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. @@ -2184,8 +2795,13 @@ export default function ChatView(props: ChatViewProps) { } setTerminalFocusRequestId((value) => value + 1); - const customEnv = options?.env ? stripManagedRuntimeEnvKeys(options.env) : {}; - const customRuntimeEnv = Object.keys(customEnv).length > 0 ? { env: customEnv } : {}; + const runtimeEnv = projectScriptRuntimeEnv({ + project: { + cwd: activeProject.cwd, + }, + worktreePath: targetWorktreePath, + ...(options?.env ? { extraEnv: options.env } : {}), + }); const targetTerminalId = shouldCreateNewTerminal ? nextTerminalId(activeKnownTerminalIds) : baseTerminalId; @@ -2193,20 +2809,18 @@ export default function ChatView(props: ChatViewProps) { ? { threadId: activeThreadId, terminalId: targetTerminalId, - projectId: activeProject.id, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - ...customRuntimeEnv, + env: runtimeEnv, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, } : { threadId: activeThreadId, terminalId: targetTerminalId, - projectId: activeProject.id, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - ...customRuntimeEnv, + env: runtimeEnv, }; if (shouldCreateNewTerminal) { @@ -2222,6 +2836,23 @@ export default function ChatView(props: ChatViewProps) { terminalId: targetTerminalId, data: `${script.command}\r`, }); + if ( + script.autoOpenPreview && + script.previewUrl && + isPreviewSupportedInRuntime() && + activeThreadRef + ) { + try { + const snapshot = await api.preview.open({ + threadId: activeThreadId, + url: script.previewUrl, + }); + usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); + useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); + } catch { + // Preview open failures are surfaced via the panel itself. + } + } } catch (error) { setThreadError( activeThreadId, @@ -2294,6 +2925,8 @@ export default function ChatView(props: ChatViewProps) { command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, + ...(input.previewUrl ? { previewUrl: input.previewUrl } : {}), + ...(input.autoOpenPreview ? { autoOpenPreview: input.autoOpenPreview } : {}), }; const nextScripts = input.runOnWorktreeCreate ? [ @@ -2329,6 +2962,10 @@ export default function ChatView(props: ChatViewProps) { command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, + ...(input.previewUrl ? { previewUrl: input.previewUrl } : { previewUrl: undefined }), + ...(input.autoOpenPreview + ? { autoOpenPreview: input.autoOpenPreview } + : { autoOpenPreview: undefined }), }; const nextScripts = activeProject.scripts.map((script) => script.id === scriptId @@ -2423,22 +3060,110 @@ export default function ChatView(props: ChatViewProps) { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); const togglePlanSidebar = useCallback(() => { - setPlanSidebarOpen((open) => { - if (open) { - planSidebarDismissedForTurnRef.current = - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - } else { - planSidebarDismissedForTurnRef.current = null; - } - return !open; - }); - }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + if (!activeThreadRef) return; + if (planSidebarOpen) { + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + } else { + planSidebarDismissedForTurnRef.current = null; + } + useRightPanelStore.getState().toggle(activeThreadRef, "plan"); + }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]); const closePlanSidebar = useCallback(() => { - setPlanSidebarOpen(false); + if (!activeThreadRef) return; + setMaximizedRightPanelThreadKey(null); + useRightPanelStore.getState().close(activeThreadRef); planSidebarDismissedForTurnRef.current = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); - + }, [activePlan?.turnId, activeThreadRef, sidebarProposedPlan?.turnId]); + const closePreviewPanel = useCallback(() => { + if (!activeThreadRef) return; + setMaximizedRightPanelThreadKey(null); + useRightPanelStore.getState().close(activeThreadRef); + }, [activeThreadRef]); + const activateRightPanelSurface = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + if (surface.kind === "preview" && surface.resourceId) { + usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); + } + if (surface.kind === "terminal") { + setTerminalFocusRequestId((value) => value + 1); + } + if (surface.kind === "diff" && !diffOpen) { + onDiffPanelOpen?.(); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), + }); + } else if (surface.kind !== "diff" && diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + }, + [activeThreadRef, diffOpen, environmentId, navigate, onDiffPanelOpen, threadId], + ); + const toggleRightPanel = useCallback(() => { + if (!activeThreadRef) return; + if (rightPanelOpen) { + setMaximizedRightPanelThreadKey(null); + } + useRightPanelStore.getState().toggleVisibility(activeThreadRef); + }, [activeThreadRef, rightPanelOpen]); + const toggleRightPanelMaximized = () => { + if (!canMaximizeRightPanel) return; + setMaximizedRightPanelThreadKey((threadKey) => + threadKey === routeThreadKey ? null : routeThreadKey, + ); + }; + const closeRightPanelSurface = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + if (surface.kind === "preview" && surface.resourceId) { + usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId); + const api = readEnvironmentApi(activeThreadRef.environmentId); + void api?.preview + .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) + .catch(() => undefined); + } + useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + const nextActiveSurface = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ); + if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { + usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId); + } + if (surface.kind === "terminal") { + const api = readEnvironmentApi(activeThreadRef.environmentId); + for (const terminalId of surface.terminalIds) { + void api?.terminal + .close({ + threadId: activeThreadRef.threadId, + terminalId, + deleteHistory: true, + }) + .catch(() => undefined); + } + } + if (surface.kind === "diff" && diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + }, + [activeThreadRef, diffOpen, environmentId, navigate, threadId], + ); const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; @@ -2522,12 +3247,12 @@ export default function ChatView(props: ChatViewProps) { setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; - setPlanSidebarOpen(true); - } else { - planSidebarOpenOnNextThreadRef.current = false; - setPlanSidebarOpen(false); + if (activeThreadRef) { + useRightPanelStore.getState().open(activeThreadRef, "plan"); + } } planSidebarDismissedForTurnRef.current = null; + // eslint-disable-next-line react-hooks/exhaustive-deps -- activeThreadRef is reset transitively }, [activeThread?.id]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. @@ -2540,10 +3265,13 @@ export default function ChatView(props: ChatViewProps) { if (latestTurnId && activePlan.turnId !== latestTurnId) return; const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; - setPlanSidebarOpen(true); + if (activeThreadRef) { + useRightPanelStore.getState().open(activeThreadRef, "plan"); + } }, [ activePlan, activeLatestTurn?.turnId, + activeThreadRef, autoOpenPlanSidebar, planSidebarOpen, sidebarProposedPlan?.turnId, @@ -2714,8 +3442,9 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { return; } + const terminalFocusOwner = getTerminalFocusOwner(); const shortcutContext = { - terminalFocus: isTerminalFocused(), + terminalFocus: terminalFocusOwner !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; @@ -2744,9 +3473,20 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "rightPanel.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleRightPanel(); + return; + } + if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); + if (terminalFocusOwner === "right-panel") { + splitPanelTerminal(); + return; + } if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } @@ -2754,9 +3494,27 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "terminal.splitVertical") { + event.preventDefault(); + event.stopPropagation(); + if (terminalFocusOwner === "right-panel") { + splitPanelTerminal("vertical"); + return; + } + if (!terminalUiState.terminalOpen) { + setTerminalOpen(true); + } + splitTerminal("vertical"); + return; + } + if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); + if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { + closePanelTerminal(activeRightPanelSurface.activeTerminalId); + return; + } if (!terminalUiState.terminalOpen) return; closeTerminal(terminalUiState.activeTerminalId); return; @@ -2765,6 +3523,10 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); + if (terminalFocusOwner === "right-panel") { + addTerminalSurface(); + return; + } if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } @@ -2797,17 +3559,22 @@ export default function ChatView(props: ChatViewProps) { window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, [ + activeRightPanelSurface, activeProject, + addTerminalSurface, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, + closePanelTerminal, closeTerminal, createNewTerminal, setTerminalOpen, runProjectScript, splitTerminal, + splitPanelTerminal, keybindings, onToggleDiff, + toggleRightPanel, toggleTerminalVisibility, composerRef, ]); @@ -2892,6 +3659,8 @@ export default function ChatView(props: ChatViewProps) { const { images: composerImages, terminalContexts: composerTerminalContexts, + elementContexts: composerElementContexts, + previewAnnotations: composerPreviewAnnotations, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -2908,6 +3677,7 @@ export default function ChatView(props: ChatViewProps) { prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, + elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ @@ -2924,7 +3694,10 @@ export default function ChatView(props: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -2937,7 +3710,9 @@ export default function ChatView(props: ChatViewProps) { const goalSlashCommand = ctxSelectedProvider === "codex" && composerImages.length === 0 && - sendableComposerTerminalContexts.length === 0 + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 ? parseComposerGoalSlashCommand(trimmed) : null; if (goalSlashCommand) { @@ -3078,9 +3853,15 @@ export default function ChatView(props: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const messageTextForSend = appendTerminalContextsToPrompt( - promptForSend, - composerTerminalContextsSnapshot, + const composerElementContextsSnapshot = [...composerElementContexts]; + const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const messageTextWithContexts = appendElementContextsToPrompt( + appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), + composerElementContextsSnapshot, + ); + const messageTextForSend = composerPreviewAnnotationsSnapshot.reduce( + (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), + messageTextWithContexts, ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -3161,6 +3942,8 @@ export default function ChatView(props: ChatViewProps) { titleSeed = `Image: ${firstComposerImageName}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); + } else if (composerElementContextsSnapshot.length > 0) { + titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!); } else { titleSeed = "New thread"; } @@ -3246,7 +4029,10 @@ export default function ChatView(props: ChatViewProps) { !turnStartSucceeded && promptRef.current.length === 0 && composerImagesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 + composerTerminalContextsRef.current.length === 0 && + composerElementContextsRef.current.length === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations + .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -3260,9 +4046,12 @@ export default function ChatView(props: ChatViewProps) { const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; + composerElementContextsRef.current = composerElementContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); + setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); + setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, @@ -3618,7 +4407,9 @@ export default function ChatView(props: ChatViewProps) { // step-tracking activities that the sidebar will display. if (nextInteractionMode === "default" && autoOpenPlanSidebar) { planSidebarDismissedForTurnRef.current = null; - setPlanSidebarOpen(true); + if (activeThreadRef) { + useRightPanelStore.getState().open(activeThreadRef, "plan"); + } } sendInFlightRef.current = false; } catch (err) { @@ -3951,289 +4742,456 @@ export default function ChatView(props: ChatViewProps) { return ; } + const panelLayoutControls = ( + + ); + return ( -
- {/* Top bar */} -
+ {isElectron && activeThreadRef ? ( + + ) : null} + {rightPanelOpen ? panelLayoutControls : null} +
- + {!rightPanelOpen ? panelLayoutControls : null} + +
+ + {/* Error banner */} + + setThreadError(activeThread.id, null)} /> - + {/* Main content area with optional plan sidebar */} +
+ {/* Chat column */} +
+ {/* Messages Wrapper */} +
+ {/* Messages — LegendList handles virtualization and scrolling internally */} + - {/* Error banner */} - - setThreadError(activeThread.id, null)} - /> - {/* Main content area with optional plan sidebar */} -
- {/* Chat column */} -
- {/* Messages Wrapper */} -
- {/* Messages — LegendList handles virtualization and scrolling internally */} - + {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} + {showScrollToBottom && ( +
+ +
+ )} +
- {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} - {showScrollToBottom && ( -
- + {/* Input bar */} +
+
+ +
+ +
- )} -
- - {/* Input bar */} -
-
- -
- -
+ )}
- {isGitRepo && ( - { + if (!open) { + closePullRequestDialog(); + } + }} + onPrepared={handlePreparedPullRequestThread} /> - )} + ) : null}
- - {pullRequestDialogState ? ( - { - if (!open) { - closePullRequestDialog(); - } - }} - onPrepared={handlePreparedPullRequestThread} - /> - ) : null} + {/* end chat column */}
- {/* end chat column */} - - {/* Plan sidebar */} - {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( - ) : null}
- {/* end horizontal flex container */} - - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} - {shouldUsePlanSidebarSheet ? ( - - + {activeRightPanelSurface?.kind === "preview" ? ( + + + + ) : activeRightPanelSurface?.kind === "terminal" ? ( + + ) : activeRightPanelSurface?.kind === "diff" ? ( + + + + ) : activeRightPanelSurface?.kind === "plan" ? ( + + ) : (activeRightPanelSurface?.kind === "files" || + activeRightPanelSurface?.kind === "file") && + activeProject && + activeWorkspaceRoot ? ( + + + + ) : null} + + ) : null} + + {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + + + surfaces={rightPanelState.surfaces} + activeSurfaceId={activeRightPanelSurface?.id ?? null} + pendingSurfaceIds={pendingFileSurfaceIds} + previewSessions={activePreviewState.sessions} + terminalLabelsById={activeTerminalLabelsById} + onActivate={activateRightPanelSurface} + onCloseSurface={closeRightPanelSurface} + onAddBrowser={createBrowserSurface} + onAddTerminal={addTerminalSurface} + onAddDiff={addDiffSurface} + onAddFiles={addFilesSurface} + browserAvailable={isPreviewSupportedInRuntime()} + diffAvailable={isServerThread && isGitRepo} + filesAvailable={Boolean(activeProject)} + > + {activeRightPanelSurface?.kind === "preview" ? ( + + + + ) : activeRightPanelSurface?.kind === "terminal" ? ( + + ) : activeRightPanelSurface?.kind === "diff" ? ( + + + + ) : activeRightPanelSurface?.kind === "plan" ? ( + + ) : (activeRightPanelSurface?.kind === "files" || + activeRightPanelSurface?.kind === "file") && + activeProject && + activeWorkspaceRoot ? ( + + + + ) : null} + ) : null} @@ -4243,3 +5201,11 @@ export default function ChatView(props: ChatViewProps) {
); } + +export default function ChatView(props: ChatViewProps) { + return ( + + + + ); +} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 2fcbd929e17..18579cda6d3 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -64,7 +64,7 @@ import { type TerminalContextDraft, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; -import { basenameOfPath } from "~/vscode-icons"; +import { basenameOfPath } from "~/pierre-icons"; import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c6d00d4bece..7b492020e39 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -23,8 +23,8 @@ import { openInPreferredEditor } from "../editorPreferences"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; +import { openDiffFilePrimaryAction } from "../diffFileActions"; import { readLocalApi } from "../localApi"; -import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; import { @@ -328,16 +328,22 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { target?.scrollIntoView({ block: "nearest" }); }, [selectedFilePath, renderableFiles]); - const openDiffFileInEditor = useCallback( + const openDiffFile = useCallback( (filePath: string) => { - const api = readLocalApi(); - if (!api) return; - const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); + openDiffFilePrimaryAction({ + threadRef: routeThreadRef, + filePath, + activeCwd, + openInEditor: (targetPath) => { + const api = readLocalApi(); + if (!api) return; + void openInPreferredEditor(api, targetPath).catch((error) => { + console.warn("Failed to open diff file in editor.", error); + }); + }, }); }, - [activeCwd], + [activeCwd, routeThreadRef], ); const toggleDiffFileCollapsed = useCallback((fileKey: string) => { setCollapsedDiffFileKeys((current) => { @@ -664,7 +670,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { return node.hasAttribute("data-title"); }); if (!clickedHeader) return; - openDiffFileInEditor(filePath); + openDiffFile(filePath); }} > { + it("uses the shared compact surface subheader in embedded mode", async () => { + const screen = await render( + Diff controls}> +
Diff content
+
, + ); + const subheader = screen.container.querySelector("[data-surface-subheader]"); + + expect(subheader).not.toBeNull(); + expect(subheader?.getBoundingClientRect().height).toBe(40); + expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px"); + expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px"); + }); +}); diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 829ed4159d4..4dd569d2823 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -5,15 +5,15 @@ import { cn } from "~/lib/utils"; import { Skeleton } from "./ui/skeleton"; -export type DiffPanelMode = "inline" | "sheet" | "sidebar"; +export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet"; + const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; return cn( "flex items-center justify-between gap-2 px-4", shouldUseDragRegion ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" - : "h-12 wco:max-h-[env(titlebar-area-height)]", + : "surface-subheader", ); } @@ -22,7 +22,7 @@ export function DiffPanelShell(props: { header: ReactNode; children: ReactNode; }) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet"; + const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; return (
{props.header}
) : ( -
-
{props.header}
+
+ {props.header}
)} {props.children} diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 5babd4248ad..8f7addc5bc7 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -47,6 +47,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { highlighterOptions={{ theme: diffThemeName, tokenizeMaxLineLength: 1_000, + useTokenTransformer: true, }} > diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 0a79f2f2cf3..996bf5ff8fc 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -94,6 +94,7 @@ vi.mock("~/lib/sourceControlActions", () => ({ })); vi.mock("~/lib/vcsStatusState", () => ({ + getVcsStatusDataForTarget: (state: { data: unknown }) => state.data, refreshVcsStatus: refreshVcsStatusSpy, resetVcsStatusStateForTests: () => undefined, useVcsStatus: vi.fn(() => ({ diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 95c6ae7aa6d..3781fad8336 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1100,6 +1100,15 @@ describe("resolveLiveThreadBranchUpdate", () => { assert.equal(update, null); }); + + it("allows a temporary worktree ref to reconcile to a semantic branch", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "t3code/a9628676", + gitStatus: status({ refName: "feature/diff-panel-toggle" }), + }); + + assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); + }); }); describe("resolveAutoFeatureBranchName", () => { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6a8dc53fbca..8c7356e2829 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -71,7 +71,7 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; +import { getVcsStatusDataForTarget, refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -1058,10 +1058,13 @@ export default function GitActionsControl({ [persistThreadBranchSync], ); - const { data: gitStatus, error: gitStatusError } = useVcsStatus({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - }); + const vcsStatusTarget = useMemo( + () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), + [activeEnvironmentId, gitCwd], + ); + const gitStatusQuery = useVcsStatus(vcsStatusTarget); + const { error: gitStatusError } = gitStatusQuery; + const gitStatus = getVcsStatusDataForTarget(gitStatusQuery, vcsStatusTarget); const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 40c2289c301..e9006f5188f 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -68,6 +68,7 @@ vi.mock("../lib/vcsStatusState", () => { }; return { + getVcsStatusDataForTarget: (state: typeof status) => state.data, getVcsStatusSnapshot: () => status, useVcsStatus: () => status, useVcsStatuses: () => new Map(), @@ -325,8 +326,7 @@ const worker = setupWorker( }); }), ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), - http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), - http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), + http.get("*/api/assets/*", () => new HttpResponse(null, { status: 204 })), ); function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index a2a801a3b4e..c874ee58a98 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -10,13 +10,11 @@ export function NoActiveThreadState() {
{isElectron ? ( - + No active thread ) : ( diff --git a/apps/web/src/components/PlanSidebar.fork.tsx b/apps/web/src/components/PlanSidebar.fork.tsx new file mode 100644 index 00000000000..d63818a4636 --- /dev/null +++ b/apps/web/src/components/PlanSidebar.fork.tsx @@ -0,0 +1,272 @@ +import { memo, useState, useCallback } from "react"; +import type { EnvironmentId, OrchestrationThreadGoal } from "@t3tools/contracts"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { Button } from "./ui/button"; +import ChatMarkdown from "./ChatMarkdown"; +import { + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + EllipsisIcon, + LoaderIcon, +} from "lucide-react"; +import { cn } from "~/lib/utils"; +import type { ActivePlanState } from "../session-logic"; +import type { LatestProposedPlanState } from "../session-logic"; +import { formatTimestamp } from "../timestampFormat"; +import { + proposedPlanTitle, + buildProposedPlanMarkdownFilename, + normalizePlanMarkdownForExport, + downloadPlanAsTextFile, + stripDisplayedPlanMarkdown, +} from "../proposedPlan"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { readEnvironmentApi } from "~/environmentApi"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { ThreadGoalPanel } from "./ThreadGoalPanel"; +import { ThreadSidebar } from "./ThreadSidebar"; + +function stepStatusIcon(status: string): React.ReactNode { + if (status === "completed") { + return ( + + + + ); + } + if (status === "inProgress") { + return ( + + + + ); + } + return ( + + + + ); +} + +interface PlanSidebarProps { + activePlan: ActivePlanState | null; + activeProposedPlan: LatestProposedPlanState | null; + activeGoal: OrchestrationThreadGoal | null; + goalCommandDisabled?: boolean | null; + onSubmitGoalCommand?: (command: "/goal pause" | "/goal resume" | "/goal clear") => void; + label?: string; + environmentId: EnvironmentId; + markdownCwd: string | undefined; + workspaceRoot: string | undefined; + timestampFormat: TimestampFormat; + mode?: "sheet" | "sidebar"; + onClose: () => void; +} + +const PlanSidebar = memo(function PlanSidebar({ + activePlan, + activeProposedPlan, + activeGoal, + goalCommandDisabled = false, + onSubmitGoalCommand, + label = "Plan", + environmentId, + markdownCwd, + workspaceRoot, + timestampFormat, + mode = "sidebar", + onClose, +}: PlanSidebarProps) { + const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); + const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const { copyToClipboard, isCopied } = useCopyToClipboard(); + + const planMarkdown = activeProposedPlan?.planMarkdown ?? null; + const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; + const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; + + const handleCopyPlan = useCallback(() => { + if (!planMarkdown) return; + copyToClipboard(planMarkdown); + }, [planMarkdown, copyToClipboard]); + + const handleDownload = useCallback(() => { + if (!planMarkdown) return; + const filename = buildProposedPlanMarkdownFilename(planMarkdown); + downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); + }, [planMarkdown]); + + const handleSaveToWorkspace = useCallback(() => { + const api = readEnvironmentApi(environmentId); + if (!api || !workspaceRoot || !planMarkdown) return; + const filename = buildProposedPlanMarkdownFilename(planMarkdown); + setIsSavingToWorkspace(true); + void api.projects + .writeFile({ + cwd: workspaceRoot, + relativePath: filename, + contents: normalizePlanMarkdownForExport(planMarkdown), + }) + .then((result) => { + toastManager.add({ + type: "success", + title: "Plan saved", + description: result.relativePath, + }); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save plan", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }) + .then( + () => setIsSavingToWorkspace(false), + () => setIsSavingToWorkspace(false), + ); + }, [environmentId, planMarkdown, workspaceRoot]); + + const headerMeta = activePlan ? ( + + {formatTimestamp(activePlan.createdAt, timestampFormat)} + + ) : null; + + const actions = ( + <> + {planMarkdown ? ( + + + } + > + + + + + {isCopied ? "Copied!" : "Copy to clipboard"} + + Download as markdown + + Save to workspace + + + + ) : null} + + ); + + return ( + + {activeGoal ? ( + + ) : null} + + {/* Explanation */} + {activePlan?.explanation ? ( +

+ {activePlan.explanation} +

+ ) : null} + + {/* Plan Steps */} + {activePlan && activePlan.steps.length > 0 ? ( +
+

+ Steps +

+ {activePlan.steps.map((step) => ( +
+ {stepStatusIcon(step.status)} +

+ {step.step} +

+
+ ))} +
+ ) : null} + + {/* Proposed Plan Markdown */} + {planMarkdown ? ( +
+ + {proposedPlanExpanded ? ( +
+ +
+ ) : null} +
+ ) : null} + + {/* Empty state */} + {!activeGoal && !activePlan && !planMarkdown ? ( +
+

No active plan yet.

+

+ Plans will appear here when generated. +

+
+ ) : null} +
+ ); +}); + +export default PlanSidebar; +export type { PlanSidebarProps }; diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index d63818a4636..8c596e20360 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,7 +1,9 @@ import { memo, useState, useCallback } from "react"; -import type { EnvironmentId, OrchestrationThreadGoal } from "@t3tools/contracts"; +import type { EnvironmentId, OrchestrationThreadGoal, ScopedThreadRef } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; +import { ScrollArea } from "./ui/scroll-area"; import ChatMarkdown from "./ChatMarkdown"; import { CheckIcon, @@ -9,6 +11,7 @@ import { ChevronRightIcon, EllipsisIcon, LoaderIcon, + PanelRightCloseIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; import type { ActivePlanState } from "../session-logic"; @@ -26,7 +29,6 @@ import { readEnvironmentApi } from "~/environmentApi"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { ThreadGoalPanel } from "./ThreadGoalPanel"; -import { ThreadSidebar } from "./ThreadSidebar"; function stepStatusIcon(status: string): React.ReactNode { if (status === "completed") { @@ -58,10 +60,11 @@ interface PlanSidebarProps { onSubmitGoalCommand?: (command: "/goal pause" | "/goal resume" | "/goal clear") => void; label?: string; environmentId: EnvironmentId; + threadRef?: ScopedThreadRef | undefined; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar"; + mode?: "sheet" | "sidebar" | "embedded"; onClose: () => void; } @@ -73,6 +76,7 @@ const PlanSidebar = memo(function PlanSidebar({ onSubmitGoalCommand, label = "Plan", environmentId, + threadRef, markdownCwd, workspaceRoot, timestampFormat, @@ -131,140 +135,165 @@ const PlanSidebar = memo(function PlanSidebar({ ); }, [environmentId, planMarkdown, workspaceRoot]); - const headerMeta = activePlan ? ( - - {formatTimestamp(activePlan.createdAt, timestampFormat)} - - ) : null; - - const actions = ( - <> - {planMarkdown ? ( - - - } - > - - - - - {isCopied ? "Copied!" : "Copy to clipboard"} - - Download as markdown - - Save to workspace - - - - ) : null} - - ); - return ( - - {activeGoal ? ( - - ) : null} + {/* Header */} +
+
+ + {label} + + {activePlan ? ( + + {formatTimestamp(activePlan.createdAt, timestampFormat)} + + ) : null} +
+
+ {planMarkdown ? ( + + + } + > + + + + + {isCopied ? "Copied!" : "Copy to clipboard"} + + Download as markdown + + Save to workspace + + + + ) : null} + +
+
+ + {/* Content */} + +
+ {activeGoal ? ( + + ) : null} - {/* Explanation */} - {activePlan?.explanation ? ( -

- {activePlan.explanation} -

- ) : null} + {/* Explanation */} + {activePlan?.explanation ? ( +

+ {activePlan.explanation} +

+ ) : null} - {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
-

- Steps -

- {activePlan.steps.map((step) => ( -
- {stepStatusIcon(step.status)} -

- {step.step} + {/* Plan Steps */} + {activePlan && activePlan.steps.length > 0 ? ( +

+

+ Steps

+ {activePlan.steps.map((step) => ( +
+ {stepStatusIcon(step.status)} +

+ {step.step} +

+
+ ))}
- ))} -
- ) : null} + ) : null} - {/* Proposed Plan Markdown */} - {planMarkdown ? ( -
- - {proposedPlanExpanded ? ( -
- + {/* Proposed Plan Markdown */} + {planMarkdown ? ( +
+ + {proposedPlanExpanded ? ( +
+ +
+ ) : null}
) : null} -
- ) : null} - {/* Empty state */} - {!activeGoal && !activePlan && !planMarkdown ? ( -
-

No active plan yet.

-

- Plans will appear here when generated. -

+ {/* Empty state */} + {!activeGoal && !activePlan && !planMarkdown ? ( +
+

No active plan yet.

+

+ Plans will appear here when generated. +

+
+ ) : null}
- ) : null} - + +
); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index ad47e01bb11..0b849bc563e 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,7 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { FolderIcon } from "lucide-react"; -import { useState } from "react"; -import { resolveEnvironmentHttpUrl } from "../environments/runtime"; +import { useEffect, useState } from "react"; +import { useAssetUrl } from "../assets/assetUrls"; const loadedProjectFaviconSrcs = new Set(); @@ -10,20 +10,16 @@ export function ProjectFavicon(input: { cwd: string; className?: string; }) { - const src = (() => { - try { - return resolveEnvironmentHttpUrl({ - environmentId: input.environmentId, - pathname: "/api/project-favicon", - searchParams: { cwd: input.cwd }, - }); - } catch { - return null; - } - })(); + const src = useAssetUrl(input.environmentId, { + _tag: "project-favicon", + cwd: input.cwd, + }); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", ); + useEffect(() => { + setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading"); + }, [src]); if (!src) { return ( diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 4588ac51bdd..a9c218c0c9e 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -85,6 +85,10 @@ export interface NewProjectScriptInput { icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; keybinding: string | null; + /** Optional URL to open in the in-app preview when this script runs. */ + previewUrl: string | null; + /** When true, automatically open the preview panel pointed at `previewUrl`. */ + autoOpenPreview: boolean; } interface ProjectScriptsControlProps { @@ -115,6 +119,8 @@ export default function ProjectScriptsControl({ const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); const [keybinding, setKeybinding] = useState(""); + const [previewUrl, setPreviewUrl] = useState(""); + const [autoOpenPreview, setAutoOpenPreview] = useState(false); const [validationError, setValidationError] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -166,12 +172,15 @@ export default function ProjectScriptsControl({ keybinding, command: commandForProjectScript(scriptIdForValidation), }); + const trimmedPreviewUrl = previewUrl.trim(); const payload = { name: trimmedName, command: trimmedCommand, icon, runOnWorktreeCreate, keybinding: keybindingRule?.key ?? null, + previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, + autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, } satisfies NewProjectScriptInput; if (editingScriptId) { await onUpdateScript(editingScriptId, payload); @@ -193,6 +202,8 @@ export default function ProjectScriptsControl({ setIconPickerOpen(false); setRunOnWorktreeCreate(false); setKeybinding(""); + setPreviewUrl(""); + setAutoOpenPreview(false); setValidationError(null); setDialogOpen(true); }; @@ -205,6 +216,8 @@ export default function ProjectScriptsControl({ setIconPickerOpen(false); setRunOnWorktreeCreate(script.runOnWorktreeCreate); setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); + setPreviewUrl(script.previewUrl ?? ""); + setAutoOpenPreview(script.autoOpenPreview ?? false); setValidationError(null); setDialogOpen(true); }; @@ -327,6 +340,8 @@ export default function ProjectScriptsControl({ setIcon("play"); setRunOnWorktreeCreate(false); setKeybinding(""); + setPreviewUrl(""); + setAutoOpenPreview(false); setValidationError(null); }} open={dialogOpen} @@ -413,6 +428,18 @@ export default function ProjectScriptsControl({ onChange={(event) => setCommand(event.target.value)} />
+
+ + setPreviewUrl(event.target.value)} + /> +

+ Open this URL in the in-app preview when this action runs. +

+
+ {validationError &&

{validationError}

} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx new file mode 100644 index 00000000000..bf99a7f2048 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -0,0 +1,404 @@ +import type { PreviewSessionSnapshot } from "@t3tools/contracts"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react"; + +import { isElectron } from "~/env"; +import type { RightPanelSurface } from "~/rightPanelStore"; +import { cn } from "~/lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { faviconUrlForOrigin } from "~/lib/favicon"; +import { useTheme } from "~/hooks/useTheme"; + +import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; +import { PierreEntryIcon } from "./chat/PierreEntryIcon"; + +interface RightPanelTabsProps { + mode: PreviewPanelMode; + maximized?: boolean; + surfaces: readonly RightPanelSurface[]; + activeSurfaceId: string | null; + pendingSurfaceIds: ReadonlySet; + previewSessions: Readonly>; + terminalLabelsById: ReadonlyMap; + onActivate: (surface: RightPanelSurface) => void; + onCloseSurface: (surface: RightPanelSurface) => void; + onAddBrowser: () => void; + onAddTerminal: () => void; + onAddDiff: () => void; + onAddFiles: () => void; + browserAvailable: boolean; + diffAvailable: boolean; + filesAvailable: boolean; + children: ReactNode; +} + +const SURFACE_DISABLED_REASONS = { + browser: "Browser previews are only available in the T3 Code desktop app.", + files: "Files are only available when a project is open.", + diff: "Diff is only available for server threads in Git repositories.", +} as const; + +function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { + return ( + + + {props.reason} + + ); +} + +function SurfaceMenuItem(props: { + available: boolean; + disabledReason?: string; + onClick: () => void; + children: ReactNode; +}) { + const item = ( + + {props.children} + + ); + if (props.available || !props.disabledReason) return item; + return ; +} + +function RightPanelEmptyState(props: { + onAddBrowser: () => void; + onAddTerminal: () => void; + onAddDiff: () => void; + onAddFiles: () => void; + browserAvailable: boolean; + diffAvailable: boolean; + filesAvailable: boolean; +}) { + const actions = [ + { + label: "Browser", + description: "Open a local app or URL.", + icon: Globe2, + available: props.browserAvailable, + disabledReason: SURFACE_DISABLED_REASONS.browser, + onClick: props.onAddBrowser, + }, + { + label: "Terminal", + description: "Start a shell in this workspace.", + icon: TerminalSquare, + available: true, + disabledReason: null, + onClick: props.onAddTerminal, + }, + { + label: "Files", + description: "Browse and read workspace files.", + icon: Files, + available: props.filesAvailable, + disabledReason: SURFACE_DISABLED_REASONS.files, + onClick: props.onAddFiles, + }, + { + label: "Diff", + description: "Review changes in this thread.", + icon: FileDiff, + available: props.diffAvailable, + disabledReason: SURFACE_DISABLED_REASONS.diff, + onClick: props.onAddDiff, + }, + ] as const; + + return ( +
+
+
+

Open a surface

+

+ Choose what to show in the right panel. +

+
+
+ {actions.map((action) => { + const Icon = action.icon; + const content = ( + <> + + {action.label} + + {action.description} + + + ); + if (action.available) { + return ( + + ); + } + const disabledCard = ( + + ); + return ( + + ); + })} +
+
+
+ ); +} + +function surfaceTitle( + surface: RightPanelSurface, + sessions: Readonly>, + terminalLabelsById: ReadonlyMap, +): string { + switch (surface.kind) { + case "diff": + return "Diff"; + case "files": + return "Files"; + case "file": + return surface.relativePath.slice(surface.relativePath.lastIndexOf("/") + 1); + case "terminal": + return ( + terminalLabelsById.get(surface.activeTerminalId) ?? + getTerminalLabel(surface.activeTerminalId) + ); + case "plan": + return "Plan"; + case "preview": { + const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; + if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; + if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; + try { + return new URL(snapshot.navStatus.url).host || "Browser"; + } catch { + return "Browser"; + } + } + } +} + +function PreviewFavicon({ url }: { url: string | null }) { + const faviconUrl = faviconUrlForOrigin(url, 32); + const [failedUrl, setFailedUrl] = useState(null); + if (!faviconUrl || failedUrl === faviconUrl) return ; + return ( + setFailedUrl(faviconUrl)} + /> + ); +} + +function SurfaceIcon({ + surface, + sessions, + theme, +}: { + surface: RightPanelSurface; + sessions: Readonly>; + theme: "light" | "dark"; +}) { + switch (surface.kind) { + case "preview": { + const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; + const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return ; + } + case "diff": + return ; + case "files": + return ; + case "file": + return ( + + ); + case "terminal": + return ; + case "plan": + return ; + } +} + +export function RightPanelTabs(props: RightPanelTabsProps) { + const ownsDesktopTitleBar = isElectron && props.mode === "inline"; + const { resolvedTheme } = useTheme(); + const tabListRef = useRef(null); + + useEffect(() => { + const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); + activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [props.activeSurfaceId]); + + return ( + +
+ +
+ {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const pending = props.pendingSurfaceIds.has(surface.id); + const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); + return ( +
+ + props.onActivate(surface)} + > + + {title} + + } + /> + {title} + + +
+ ); + })} + {props.surfaces.length > 0 ? ( + + + + + + + + Browser + + + + Terminal + + + + Files + + + + Diff + + + + ) : null} +
+
+
+
+ {props.activeSurfaceId === null ? ( + + ) : ( + props.children + )} +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 39b4333c0ab..ac79cd36631 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, CloudIcon, FolderPlusIcon, + Globe2Icon, SearchIcon, SettingsIcon, SquarePenIcon, @@ -75,6 +76,7 @@ import { } from "../store"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -198,6 +200,7 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -349,6 +352,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -433,6 +440,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [attemptArchiveThread, clearConfirmingArchive, threadRef], ); + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void openDiscoveredPort({ threadRef, port }); + }, + [discoveredPorts, navigateToThread, threadRef], + ); const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; @@ -625,6 +643,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )}
+ {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} {terminalStatus && ( ({ })); vi.mock("~/environmentApi", () => ({ + ensureEnvironmentApi: (environmentId: string) => { + const api = readEnvironmentApiMock(environmentId); + if (!api) { + throw new Error(`Environment API not found for ${environmentId}`); + } + return api; + }, readEnvironmentApi: readEnvironmentApiMock, })); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d09a77ed6f8..19ab2e160e7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,5 +1,13 @@ import { FitAddon } from "@xterm/addon-fit"; -import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; +import { + Globe2, + Plus, + SquareSplitHorizontal, + SquareSplitVertical, + TerminalSquare, + Trash2, + XIcon, +} from "lucide-react"; import { type ContextMenuItem, type ProjectId, @@ -22,6 +30,7 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { openInPreferredEditor } from "../editorPreferences"; import { @@ -38,6 +47,7 @@ import { isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, + isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, terminalDeleteShortcutData, terminalNavigationShortcutData, @@ -50,6 +60,9 @@ import { import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { attachTerminalSession } from "../terminalSessionState"; +import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; +import { useDiscoveredPorts } from "../portDiscoveryState"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -289,6 +302,7 @@ interface TerminalViewportProps { interface TerminalLaunchLocation { readonly cwd: string; readonly worktreePath?: string | null; + readonly runtimeEnv?: Record; } export function TerminalViewport({ @@ -344,7 +358,7 @@ export function TerminalViewport({ const fitAddon = new FitAddon(); const terminal = new Terminal({ cursorBlink: true, - lineHeight: 1.2, + lineHeight: 1, fontSize: 12, scrollback: 5_000, fontFamily: @@ -471,6 +485,7 @@ export function TerminalViewport({ if ( isTerminalToggleShortcut(event, currentKeybindings, options) || isTerminalSplitShortcut(event, currentKeybindings, options) || + isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || isTerminalNewShortcut(event, currentKeybindings, options) || isTerminalCloseShortcut(event, currentKeybindings, options) || isDiffToggleShortcut(event, currentKeybindings, options) @@ -545,11 +560,26 @@ export function TerminalViewport({ } if (match.kind === "url") { - void localApi.shell.openExternal(match.text).catch((error: unknown) => { - writeSystemMessage( - latestTerminal, - error instanceof Error ? error.message : "Unable to open link", - ); + const fallbackToBrowser = () => { + void localApi.shell.openExternal(match.text).catch((error: unknown) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open link", + ); + }); + }; + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + fallbackToBrowser(); + return; + } + void openTerminalLinkInPreview({ + url: match.text, + position: { x: event.clientX, y: event.clientY }, + threadRef, + api, + localApi, + fallbackToBrowser, }); return; } @@ -743,12 +773,44 @@ export function TerminalViewport({ .catch(() => undefined); }, 30); attachTerminal(); + let resizeFrame = 0; + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => { + if (resizeFrame !== 0) return; + resizeFrame = window.requestAnimationFrame(() => { + resizeFrame = 0; + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + const wasAtBottom = + activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; + fitTerminalSafely(activeFitAddon); + if (wasAtBottom) { + activeTerminal.scrollToBottom(); + } + void api.terminal + .resize({ + threadId, + terminalId, + cols: activeTerminal.cols, + rows: activeTerminal.rows, + }) + .catch(() => undefined); + }); + }); + resizeObserver?.observe(mount); return () => { disposed = true; unsubscribeAttach?.(); unsubscribeAttach = null; window.clearTimeout(fitTimer); + if (resizeFrame !== 0) { + window.cancelAnimationFrame(resizeFrame); + } + resizeObserver?.disconnect(); inputDisposable.dispose(); selectionDisposable.dispose(); terminalLinksDisposable.dispose(); @@ -812,11 +874,13 @@ export function TerminalViewport({ } interface ThreadTerminalDrawerProps { + mode?: "drawer" | "panel"; threadRef: ScopedThreadRef; threadId: ThreadId; projectId: ProjectId; cwd: string; worktreePath?: string | null; + runtimeEnv?: Record; visible?: boolean; height: number; terminalIds: string[]; @@ -825,8 +889,10 @@ interface ThreadTerminalDrawerProps { activeTerminalGroupId: string; focusRequestId: number; onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; onNewTerminal: () => void; splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; @@ -870,11 +936,13 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA } export default function ThreadTerminalDrawer({ + mode = "drawer", threadRef, threadId, projectId, cwd, worktreePath, + runtimeEnv, visible = true, height, terminalIds, @@ -883,8 +951,10 @@ export default function ThreadTerminalDrawer({ activeTerminalGroupId, focusRequestId, onSplitTerminal, + onSplitTerminalVertical, onNewTerminal, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, onActiveTerminalChange, @@ -895,6 +965,7 @@ export default function ThreadTerminalDrawer({ terminalLabelsById, terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { + const isPanel = mode === "panel"; const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); @@ -974,6 +1045,9 @@ export default function ThreadTerminalDrawer({ nextGroups.push({ id: assignUniqueGroupId(baseGroupId), terminalIds: nextTerminalIds, + ...(terminalGroup.splitDirection === "vertical" + ? { splitDirection: "vertical" as const } + : {}), }); } @@ -1011,6 +1085,8 @@ export default function ThreadTerminalDrawer({ const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); + const splitDirection = + resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; const showGroupHeaders = @@ -1024,22 +1100,39 @@ export default function ThreadTerminalDrawer({ } return next; }, [normalizedTerminalIds, terminalLabelsById]); + const discoveredPorts = useDiscoveredPorts(threadRef.environmentId); + const discoveredPortByTerminalId = useMemo(() => { + const next = new Map(); + for (const port of discoveredPorts) { + if (port.terminal?.threadId !== threadId) continue; + if (!next.has(port.terminal.terminalId)) { + next.set(port.terminal.terminalId, port); + } + } + return next; + }, [discoveredPorts, threadId]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( terminalLaunchLocationsById?.get(terminalId) ?? { cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(runtimeEnv ? { runtimeEnv } : {}), } ); }, - [cwd, terminalLaunchLocationsById, worktreePath], + [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); const splitTerminalActionLabel = hasReachedSplitLimit - ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` + ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel - ? `Split Terminal (${splitShortcutLabel})` - : "Split Terminal"; + ? `Split Terminal Horizontally (${splitShortcutLabel})` + : "Split Terminal Horizontally"; + const splitTerminalVerticalActionLabel = hasReachedSplitLimit + ? `Split Terminal Vertically (max ${MAX_TERMINALS_PER_GROUP} per group)` + : splitVerticalShortcutLabel + ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` + : "Split Terminal Vertically"; const newTerminalActionLabel = newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"; @@ -1050,6 +1143,10 @@ export default function ThreadTerminalDrawer({ if (hasReachedSplitLimit) return; onSplitTerminal(); }, [hasReachedSplitLimit, onSplitTerminal]); + const onSplitTerminalVerticalAction = useCallback(() => { + if (hasReachedSplitLimit) return; + onSplitTerminalVertical(); + }, [hasReachedSplitLimit, onSplitTerminalVertical]); const onNewTerminalAction = useCallback(() => { onNewTerminal(); }, [onNewTerminal]); @@ -1159,16 +1256,22 @@ export default function ThreadTerminalDrawer({ if (normalizedTerminalIds.length === 0) { return (